进化1
package com.example.demotest.unread;
import android.accessibilityservice.AccessibilityService;
import android.content.res.Resources;
import android.graphics.Rect;
import android.util.DisplayMetrics;
import android.util.Log;
import android.view.accessibility.AccessibilityNodeInfo;
import java.util.ArrayList;
import java.util.List;
/**
* 无障碍树打印器 - 打印界面视图树和TalkBack语义树
*/
public class UnreadMessageAnalyzer {
private static final String TAG = "UnreadAnalysis";
private AccessibilityService accessibilityService;
private int screenWidth;
private int screenHeight;
public UnreadMessageAnalyzer(AccessibilityService service) {
this.accessibilityService = service;
DisplayMetrics metrics = Resources.getSystem().getDisplayMetrics();
this.screenWidth = metrics.widthPixels;
this.screenHeight = metrics.heightPixels;
}
/**
* 打印界面视图树和语义树
*/
public void printAccessibilityTrees() {
Log.d(TAG, "\n=== 开始打印无障碍树 ===");
AccessibilityNodeInfo rootNode = accessibilityService.getRootInActiveWindow();
if (rootNode == null) {
Log.e(TAG, "无法获取当前窗口信息");
return;
}
try {
// 打印界面视图树
Log.d(TAG, "\n【界面视图树】");
printUIViewTree(rootNode, 0);
// 打印语义树
Log.d(TAG, "\n【TalkBack语义树】");
printSemanticTree(rootNode, 0);
} catch (Exception e) {
Log.e(TAG, "打印无障碍树时出错: " + e.getMessage(), e);
} finally {
rootNode.recycle();
}
Log.d(TAG, "\n=== 无障碍树打印完成 ===");
}
/**
* 打印UI视图树
*/
private void printUIViewTree(AccessibilityNodeInfo node, int depth) {
if (node == null) return;
String indent = " ".repeat(depth);
try {
// 获取节点基本信息
String className = node.getClassName() != null ? node.getClassName().toString() : "null";
Rect bounds = new Rect();
node.getBoundsInScreen(bounds);
// 构建节点描述
StringBuilder nodeInfo = new StringBuilder();
nodeInfo.append(indent)
.append("├─ ")
.append(className);
// 添加文本内容
if (node.getText() != null && !node.getText().toString().trim().isEmpty()) {
nodeInfo.append(" [text=\"").append(node.getText()).append("\"]");
}
// 添加内容描述
if (node.getContentDescription() != null && !node.getContentDescription().toString().trim().isEmpty()) {
nodeInfo.append(" [desc=\"").append(node.getContentDescription()).append("\"]");
}
// 添加边界信息
nodeInfo.append(" [").append(bounds.toString()).append("]");
// 添加关键属性
List<String> attributes = new ArrayList<>();
if (node.isClickable()) attributes.add("clickable");
if (node.isLongClickable()) attributes.add("long-clickable");
if (node.isCheckable()) attributes.add(node.isChecked() ? "checked" : "checkable");
if (node.isScrollable()) attributes.add("scrollable");
if (node.isVisibleToUser()) attributes.add("visible");
if (!attributes.isEmpty()) {
nodeInfo.append(" {").append(String.join(", ", attributes)).append("}");
}
// 打印节点信息
Log.d(TAG, nodeInfo.toString());
// 递归打印子节点
for (int i = 0; i < node.getChildCount(); i++) {
AccessibilityNodeInfo child = node.getChild(i);
if (child != null) {
printUIViewTree(child, depth + 1);
}
}
} catch (Exception e) {
Log.w(TAG, indent + "├─ [打印节点出错: " + e.getMessage() + "]");
}
}
/**
* 打印语义树
*/
private void printSemanticTree(AccessibilityNodeInfo node, int depth) {
if (node == null) return;
try {
// 只处理对TalkBack有意义的节点
if (!shouldFocusNode(node)) {
// 继续检查子节点
for (int i = 0; i < node.getChildCount(); i++) {
AccessibilityNodeInfo child = node.getChild(i);
if (child != null) {
printSemanticTree(child, depth);
}
}
return;
}
String indent = " ".repeat(depth);
// 构建语义节点描述
StringBuilder semanticInfo = new StringBuilder();
semanticInfo.append(indent)
.append("├─ ");
// 添加文本内容
if (node.getText() != null && !node.getText().toString().trim().isEmpty()) {
semanticInfo.append("\"").append(node.getText()).append("\"");
}
// 添加内容描述
if (node.getContentDescription() != null && !node.getContentDescription().toString().trim().isEmpty()) {
if (node.getText() != null && !node.getText().toString().trim().isEmpty()) {
semanticInfo.append(" ");
}
semanticInfo.append("(").append(node.getContentDescription()).append(")");
}
// 如果既没有文本也没有描述,显示类名
if ((node.getText() == null || node.getText().toString().trim().isEmpty()) &&
(node.getContentDescription() == null || node.getContentDescription().toString().trim().isEmpty())) {
String className = node.getClassName() != null ? node.getClassName().toString() : "unknown";
semanticInfo.append("[").append(className).append("]");
}
// 添加操作信息和无障碍属性
List<String> actions = new ArrayList<>();
if (node.isClickable()) actions.add("可点击");
if (node.isLongClickable()) actions.add("可长按");
if (node.isCheckable()) actions.add(node.isChecked() ? "已选中" : "可选择");
if (node.isScrollable()) actions.add("可滚动");
if (node.isFocusable()) actions.add("可聚焦");
if (node.isAccessibilityFocused()) actions.add("当前焦点");
if (node.isSelected()) actions.add("已选择");
if (!node.isEnabled()) actions.add("已禁用");
// 添加角色信息 (getRoleDescription在较新版本才可用,这里暂时跳过)
// if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) {
// if (node.getRoleDescription() != null && !node.getRoleDescription().toString().trim().isEmpty()) {
// actions.add("角色:" + node.getRoleDescription());
// }
// }
// 添加状态信息 (getError方法在API 21以上可用)
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.LOLLIPOP) {
try {
CharSequence error = node.getError();
if (error != null && !error.toString().trim().isEmpty()) {
actions.add("错误:" + error);
}
} catch (Exception e) {
// 忽略getError方法调用异常
}
}
if (!actions.isEmpty()) {
semanticInfo.append(" [").append(String.join(", ", actions)).append("]");
}
// 打印语义节点信息
Log.d(TAG, semanticInfo.toString());
// 继续处理子节点
for (int i = 0; i < node.getChildCount(); i++) {
AccessibilityNodeInfo child = node.getChild(i);
if (child != null) {
printSemanticTree(child, depth + 1);
}
}
} catch (Exception e) {
Log.w(TAG, "打印语义节点时出错: " + e.getMessage());
}
}
/**
* 判断节点是否应该获得TalkBack焦点
* 基于Android无障碍服务的焦点规则进行完整判断
*/
private boolean shouldFocusNode(AccessibilityNodeInfo node) {
if (node == null) return false;
try {
// 1. 基本可见性检查
if (!node.isVisibleToUser()) {
return false;
}
// 2. 检查节点是否启用(禁用的节点可能仍需要语音反馈)
// 注意:即使isEnabled()为false,某些情况下仍可能需要TalkBack焦点
// 3. 检查是否明确设置为可获得无障碍焦点
if (node.isFocusable() || node.isAccessibilityFocused()) {
return true;
}
// 4. 检查是否有有意义的文本或描述内容
if ((node.getText() != null && !node.getText().toString().trim().isEmpty()) ||
(node.getContentDescription() != null && !node.getContentDescription().toString().trim().isEmpty())) {
return true;
}
// 5. 检查是否是可操作的交互元素
if (node.isClickable() || node.isLongClickable() ||
node.isCheckable() || node.isScrollable()) {
return true;
}
// 6. 检查特定的重要UI组件类型
String className = node.getClassName() != null ? node.getClassName().toString() : "";
if (isImportantUIComponent(className)) {
return true;
}
// 7. 检查是否是容器类型但有重要语义信息
if (isSemanticContainer(node)) {
return true;
}
// 8. 检查是否有无障碍操作可执行 (getActionList在API 21以上可用)
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.LOLLIPOP) {
try {
if (node.getActionList() != null && !node.getActionList().isEmpty()) {
// 过滤掉一些通用的无意义操作
boolean hasmeaningfulAction = false;
for (AccessibilityNodeInfo.AccessibilityAction action : node.getActionList()) {
if (!isCommonAction(action.getId())) {
hasmeaningfulAction = true;
break;
}
}
if (hasmeaningfulAction) {
return true;
}
}
} catch (Exception e) {
// 忽略getActionList方法调用异常
}
}
return false;
} catch (Exception e) {
Log.w(TAG, "判断节点焦点时出错: " + e.getMessage());
return false;
}
}
/**
* 判断是否为重要的UI组件类型
*/
private boolean isImportantUIComponent(String className) {
if (className == null || className.isEmpty()) return false;
// 重要的UI组件类型
return className.contains("Button") ||
className.contains("EditText") ||
className.contains("TextView") ||
className.contains("ImageView") ||
className.contains("CheckBox") ||
className.contains("RadioButton") ||
className.contains("Switch") ||
className.contains("ToggleButton") ||
className.contains("SeekBar") ||
className.contains("ProgressBar") ||
className.contains("Spinner") ||
className.contains("TabHost") ||
className.contains("WebView") ||
className.contains("VideoView");
}
/**
* 判断是否为有语义意义的容器
*/
private boolean isSemanticContainer(AccessibilityNodeInfo node) {
try {
String className = node.getClassName() != null ? node.getClassName().toString() : "";
// 检查是否是具有语义的容器类型
boolean isSemanticContainerType = className.contains("RecyclerView") ||
className.contains("ListView") ||
className.contains("GridView") ||
className.contains("ViewPager") ||
className.contains("TabLayout") ||
className.contains("NavigationView") ||
className.contains("ActionBar") ||
className.contains("Toolbar");
if (!isSemanticContainerType) return false;
// 容器如果有内容描述或者是空的(需要告知用户为空),则应该获得焦点
return (node.getContentDescription() != null && !node.getContentDescription().toString().trim().isEmpty()) ||
node.getChildCount() == 0;
} catch (Exception e) {
return false;
}
}
/**
* 判断是否为常见的无意义操作
*/
private boolean isCommonAction(int actionId) {
return actionId == AccessibilityNodeInfo.ACTION_FOCUS ||
actionId == AccessibilityNodeInfo.ACTION_CLEAR_FOCUS ||
actionId == AccessibilityNodeInfo.ACTION_SELECT ||
actionId == AccessibilityNodeInfo.ACTION_CLEAR_SELECTION ||
actionId == AccessibilityNodeInfo.ACTION_ACCESSIBILITY_FOCUS ||
actionId == AccessibilityNodeInfo.ACTION_CLEAR_ACCESSIBILITY_FOCUS;
}
}
进化2
package com.example.demotest.unread;
import android.accessibilityservice.AccessibilityService;
import android.content.res.Resources;
import android.graphics.Rect;
import android.util.DisplayMetrics;
import android.util.Log;
import android.view.accessibility.AccessibilityNodeInfo;
import java.util.ArrayList;
import java.util.List;
/**
* 无障碍树打印器 - 打印界面视图树和TalkBack语义树
*/
public class UnreadMessageAnalyzer {
private static final String TAG = "UnreadAnalysis";
private AccessibilityService accessibilityService;
private int screenWidth;
private int screenHeight;
public UnreadMessageAnalyzer(AccessibilityService service) {
this.accessibilityService = service;
DisplayMetrics metrics = Resources.getSystem().getDisplayMetrics();
this.screenWidth = metrics.widthPixels;
this.screenHeight = metrics.heightPixels;
}
/**
* 打印界面视图树和语义树
*/
public void printAccessibilityTrees() {
Log.d(TAG, "\n=== 开始打印无障碍树 ===");
AccessibilityNodeInfo rootNode = accessibilityService.getRootInActiveWindow();
if (rootNode == null) {
Log.e(TAG, "无法获取当前窗口信息");
return;
}
try {
// 打印界面视图树
Log.d(TAG, "\n【界面视图树】");
printUIViewTree(rootNode, 0);
// 打印语义树
Log.d(TAG, "\n【TalkBack语义树】");
printSemanticTree(rootNode, 0);
} catch (Exception e) {
Log.e(TAG, "打印无障碍树时出错: " + e.getMessage(), e);
} finally {
rootNode.recycle();
}
Log.d(TAG, "\n=== 无障碍树打印完成 ===");
}
/**
* 打印UI视图树
*/
private void printUIViewTree(AccessibilityNodeInfo node, int depth) {
if (node == null) return;
String indent = " ".repeat(depth);
try {
// 获取节点基本信息
String className = node.getClassName() != null ? node.getClassName().toString() : "null";
Rect bounds = new Rect();
node.getBoundsInScreen(bounds);
// 构建节点描述
StringBuilder nodeInfo = new StringBuilder();
nodeInfo.append(indent)
.append("├─ ")
.append(className);
// 添加文本内容
if (node.getText() != null && !node.getText().toString().trim().isEmpty()) {
nodeInfo.append(" [text=\"").append(node.getText()).append("\"]");
}
// 添加内容描述
if (node.getContentDescription() != null && !node.getContentDescription().toString().trim().isEmpty()) {
nodeInfo.append(" [desc=\"").append(node.getContentDescription()).append("\"]");
}
// 添加边界信息
nodeInfo.append(" [").append(bounds.toString()).append("]");
// 添加关键属性
List<String> attributes = new ArrayList<>();
if (node.isClickable()) attributes.add("clickable");
if (node.isLongClickable()) attributes.add("long-clickable");
if (node.isCheckable()) attributes.add(node.isChecked() ? "checked" : "checkable");
if (node.isScrollable()) attributes.add("scrollable");
if (node.isVisibleToUser()) attributes.add("visible");
if (!attributes.isEmpty()) {
nodeInfo.append(" {").append(String.join(", ", attributes)).append("}");
}
// 打印节点信息
Log.d(TAG, nodeInfo.toString());
// 递归打印子节点
for (int i = 0; i < node.getChildCount(); i++) {
AccessibilityNodeInfo child = node.getChild(i);
if (child != null) {
printUIViewTree(child, depth + 1);
}
}
} catch (Exception e) {
Log.w(TAG, indent + "├─ [打印节点出错: " + e.getMessage() + "]");
}
}
/**
* 打印语义树
*/
private void printSemanticTree(AccessibilityNodeInfo node, int depth) {
if (node == null) return;
try {
// 检查是否是聊天项目父级节点
if (shouldFocusNode(node)) {
// 找到聊天项目父级,打印父级信息
printChatItemParent(node, depth);
// 打印该父级下的所有子节点
printAllChildren(node, depth + 1);
// 处理完这个聊天项目后,不再继续处理其子节点
return;
}
// 如果不是聊天项目父级,继续检查子节点
for (int i = 0; i < node.getChildCount(); i++) {
AccessibilityNodeInfo child = node.getChild(i);
if (child != null) {
printSemanticTree(child, depth);
}
}
} catch (Exception e) {
Log.w(TAG, "打印语义节点时出错: " + e.getMessage());
}
}
/**
* 打印聊天项目父级节点信息
*/
private void printChatItemParent(AccessibilityNodeInfo node, int depth) {
String indent = " ".repeat(depth);
// 构建父级节点描述
StringBuilder parentInfo = new StringBuilder();
parentInfo.append(indent).append("├─ ");
// 添加文本内容
if (node.getText() != null && !node.getText().toString().trim().isEmpty()) {
parentInfo.append("\"").append(node.getText()).append("\"");
}
// 添加内容描述
if (node.getContentDescription() != null && !node.getContentDescription().toString().trim().isEmpty()) {
if (node.getText() != null && !node.getText().toString().trim().isEmpty()) {
parentInfo.append(" ");
}
parentInfo.append("(").append(node.getContentDescription()).append(")");
}
// 如果既没有文本也没有描述,显示类名
if ((node.getText() == null || node.getText().toString().trim().isEmpty()) &&
(node.getContentDescription() == null || node.getContentDescription().toString().trim().isEmpty())) {
String className = node.getClassName() != null ? node.getClassName().toString() : "unknown";
parentInfo.append("[").append(className).append("]");
}
// 添加操作信息
List<String> actions = new ArrayList<>();
if (node.isClickable()) actions.add("可点击");
if (node.isLongClickable()) actions.add("可长按");
if (node.isCheckable()) actions.add(node.isChecked() ? "已选中" : "可选择");
if (node.isScrollable()) actions.add("可滚动");
if (node.isFocusable()) actions.add("可聚焦");
if (node.isAccessibilityFocused()) actions.add("当前焦点");
if (node.isSelected()) actions.add("已选择");
if (!node.isEnabled()) actions.add("已禁用");
if (!actions.isEmpty()) {
parentInfo.append(" [").append(String.join(", ", actions)).append("]");
}
// 打印父级节点信息
Log.d(TAG, parentInfo.toString());
}
/**
* 打印节点下的所有子节点(递归显示)
*/
private void printAllChildren(AccessibilityNodeInfo parentNode, int depth) {
try {
for (int i = 0; i < parentNode.getChildCount(); i++) {
AccessibilityNodeInfo child = parentNode.getChild(i);
if (child != null && child.isVisibleToUser()) {
printChildNode(child, depth);
// 递归打印子节点的子节点
printAllChildren(child, depth + 1);
}
}
} catch (Exception e) {
Log.w(TAG, "打印所有子节点时出错: " + e.getMessage());
}
}
/**
* 打印单个子节点信息
*/
private void printChildNode(AccessibilityNodeInfo node, int depth) {
String indent = " ".repeat(depth);
StringBuilder childInfo = new StringBuilder();
childInfo.append(indent).append("├─ ");
// 添加文本内容
if (node.getText() != null && !node.getText().toString().trim().isEmpty()) {
childInfo.append("\"").append(node.getText()).append("\"");
}
// 添加内容描述
if (node.getContentDescription() != null && !node.getContentDescription().toString().trim().isEmpty()) {
if (node.getText() != null && !node.getText().toString().trim().isEmpty()) {
childInfo.append(" ");
}
childInfo.append("(").append(node.getContentDescription()).append(")");
}
// 如果既没有文本也没有描述,显示类名
if ((node.getText() == null || node.getText().toString().trim().isEmpty()) &&
(node.getContentDescription() == null || node.getContentDescription().toString().trim().isEmpty())) {
String className = node.getClassName() != null ? node.getClassName().toString() : "unknown";
childInfo.append("[").append(className).append("]");
}
// 打印子节点信息
Log.d(TAG, childInfo.toString());
}
/**
* 判断节点是否应该获得TalkBack焦点
* 专门识别聊天项目父级:可点击可聚焦且子节点包含时间信息的节点
*/
private boolean shouldFocusNode(AccessibilityNodeInfo node) {
if (node == null) return false;
try {
// 1. 基本可见性检查
if (!node.isVisibleToUser()) {
return false;
}
// 2. 必须是可交互的元素(聊天项目父级特征)
if (!node.isClickable() && !node.isLongClickable()) {
return false;
}
// 3. 必须是可聚焦的(聊天项目父级特征)
if (!node.isFocusable()) {
return false;
}
// 4. 检查子节点是否包含时间信息(关键过滤条件)
if (hasTimeInDirectChildren(node)) {
return true;
}
return false;
} catch (Exception e) {
Log.w(TAG, "判断节点焦点时出错: " + e.getMessage());
return false;
}
}
/**
* 检查节点的直接子节点是否包含时间信息
* 用于识别聊天项目父级节点
*/
private boolean hasTimeInDirectChildren(AccessibilityNodeInfo parentNode) {
try {
for (int i = 0; i < parentNode.getChildCount(); i++) {
AccessibilityNodeInfo child = parentNode.getChild(i);
if (child != null && child.isVisibleToUser()) {
// 检查直接子节点的文本内容
String childText = getNodeAllText(child);
if (isTimePattern(childText)) {
return true;
}
// 也检查子节点的子节点(递归一层)
if (hasTimeInChildren(child)) {
return true;
}
}
}
} catch (Exception e) {
Log.w(TAG, "检查直接子节点时间信息时出错: " + e.getMessage());
}
return false;
}
/**
* 获取节点的所有文本内容
*/
private String getNodeAllText(AccessibilityNodeInfo node) {
StringBuilder allText = new StringBuilder();
if (node.getText() != null) {
allText.append(node.getText().toString()).append(" ");
}
if (node.getContentDescription() != null) {
allText.append(node.getContentDescription().toString()).append(" ");
}
return allText.toString();
}
/**
* 递归检查子节点是否包含时间信息
*/
private boolean hasTimeInChildren(AccessibilityNodeInfo parentNode) {
try {
for (int i = 0; i < parentNode.getChildCount(); i++) {
AccessibilityNodeInfo child = parentNode.getChild(i);
if (child != null && child.isVisibleToUser()) {
String childText = getNodeAllText(child);
if (isTimePattern(childText)) {
return true;
}
// 递归检查更深层的子节点
if (hasTimeInChildren(child)) {
return true;
}
}
}
} catch (Exception e) {
Log.w(TAG, "检查子节点时间信息时出错: " + e.getMessage());
}
return false;
}
/**
* 判断文本是否包含时间模式
*/
private boolean isTimePattern(String text) {
if (text == null || text.trim().isEmpty()) {
return false;
}
String lowerText = text.toLowerCase().trim();
// 检查常见的时间模式
return lowerText.contains("分钟前") ||
lowerText.contains("小时前") ||
lowerText.contains("天前") ||
lowerText.contains("昨天") ||
lowerText.contains("前天") ||
lowerText.matches(".*\\d+:\\d+.*") || // HH:MM 格式
lowerText.matches(".*\\d{1,2}月\\d{1,2}日.*") || // MM月DD日 格式
lowerText.matches(".*\\d{4}-\\d{1,2}-\\d{1,2}.*") || // YYYY-MM-DD 格式
lowerText.contains("刚刚") ||
lowerText.contains("今天") ||
lowerText.contains("周一") ||
lowerText.contains("周二") ||
lowerText.contains("周三") ||
lowerText.contains("周四") ||
lowerText.contains("周五") ||
lowerText.contains("周六") ||
lowerText.contains("周日") ||
lowerText.contains("星期");
}
/**
* 判断是否为重要的UI组件类型
*/
private boolean isImportantUIComponent(String className) {
if (className == null || className.isEmpty()) return false;
// 重要的UI组件类型
return className.contains("Button") ||
className.contains("EditText") ||
className.contains("TextView") ||
className.contains("ImageView") ||
className.contains("CheckBox") ||
className.contains("RadioButton") ||
className.contains("Switch") ||
className.contains("ToggleButton") ||
className.contains("SeekBar") ||
className.contains("ProgressBar") ||
className.contains("Spinner") ||
className.contains("TabHost") ||
className.contains("WebView") ||
className.contains("VideoView");
}
/**
* 判断是否为有语义意义的容器
*/
private boolean isSemanticContainer(AccessibilityNodeInfo node) {
try {
String className = node.getClassName() != null ? node.getClassName().toString() : "";
// 检查是否是具有语义的容器类型
boolean isSemanticContainerType = className.contains("RecyclerView") ||
className.contains("ListView") ||
className.contains("GridView") ||
className.contains("ViewPager") ||
className.contains("TabLayout") ||
className.contains("NavigationView") ||
className.contains("ActionBar") ||
className.contains("Toolbar");
if (!isSemanticContainerType) return false;
// 容器如果有内容描述或者是空的(需要告知用户为空),则应该获得焦点
return (node.getContentDescription() != null && !node.getContentDescription().toString().trim().isEmpty()) ||
node.getChildCount() == 0;
} catch (Exception e) {
return false;
}
}
/**
* 判断是否为常见的无意义操作
*/
private boolean isCommonAction(int actionId) {
return actionId == AccessibilityNodeInfo.ACTION_FOCUS ||
actionId == AccessibilityNodeInfo.ACTION_CLEAR_FOCUS ||
actionId == AccessibilityNodeInfo.ACTION_SELECT ||
actionId == AccessibilityNodeInfo.ACTION_CLEAR_SELECTION ||
actionId == AccessibilityNodeInfo.ACTION_ACCESSIBILITY_FOCUS ||
actionId == AccessibilityNodeInfo.ACTION_CLEAR_ACCESSIBILITY_FOCUS;
}
}
时间回溯
package com.example.demotest.unread;
import android.accessibilityservice.AccessibilityService;
import android.content.res.Resources;
import android.graphics.Rect;
import android.util.DisplayMetrics;
import android.util.Log;
import android.view.accessibility.AccessibilityNodeInfo;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
/**
* 无障碍树打印器 - 打印界面视图树
*/
public class UnreadMessageAnalyzer {
private static final String TAG = "UnreadAnalysis";
private AccessibilityService accessibilityService;
private int screenWidth;
private int screenHeight;
public UnreadMessageAnalyzer(AccessibilityService service) {
this.accessibilityService = service;
DisplayMetrics metrics = Resources.getSystem().getDisplayMetrics();
this.screenWidth = metrics.widthPixels;
this.screenHeight = metrics.heightPixels;
}
/**
* 打印界面视图树和处理过的视图树
*/
public void printAccessibilityTrees() {
Log.d(TAG, "\n=== 开始打印无障碍树 ===");
AccessibilityNodeInfo rootNode = accessibilityService.getRootInActiveWindow();
if (rootNode == null) {
Log.e(TAG, "无法获取当前窗口信息");
return;
}
try {
// 打印完整的界面视图树
Log.d(TAG, "\n【界面视图树】");
printUIViewTree(rootNode, 0);
// 打印处理过的视图树(聊天项目)
Log.d(TAG, "\n【处理过的视图树(聊天项目)】");
printProcessedViewTree(rootNode);
} catch (Exception e) {
Log.e(TAG, "打印无障碍树时出错: " + e.getMessage(), e);
} finally {
rootNode.recycle();
}
Log.d(TAG, "\n=== 无障碍树打印完成 ===");
}
/**
* 打印UI视图树
*/
private void printUIViewTree(AccessibilityNodeInfo node, int depth) {
if (node == null) return;
String indent = " ".repeat(depth);
try {
// 获取节点基本信息
String className = node.getClassName() != null ? node.getClassName().toString() : "null";
Rect bounds = new Rect();
node.getBoundsInScreen(bounds);
// 构建节点描述
StringBuilder nodeInfo = new StringBuilder();
nodeInfo.append(indent)
.append("├─ ")
.append(className);
// 添加文本内容
if (node.getText() != null && !node.getText().toString().trim().isEmpty()) {
nodeInfo.append(" [text=\"").append(node.getText()).append("\"]");
}
// 添加内容描述
if (node.getContentDescription() != null && !node.getContentDescription().toString().trim().isEmpty()) {
nodeInfo.append(" [desc=\"").append(node.getContentDescription()).append("\"]");
}
// 添加边界信息
nodeInfo.append(" [").append(bounds.toString()).append("]");
// 添加关键属性
List<String> attributes = new ArrayList<>();
if (node.isClickable()) attributes.add("clickable");
if (node.isLongClickable()) attributes.add("long-clickable");
if (node.isCheckable()) attributes.add(node.isChecked() ? "checked" : "checkable");
if (node.isScrollable()) attributes.add("scrollable");
if (node.isVisibleToUser()) attributes.add("visible");
if (!attributes.isEmpty()) {
nodeInfo.append(" {").append(String.join(", ", attributes)).append("}");
}
// 打印节点信息
Log.d(TAG, nodeInfo.toString());
// 递归打印子节点
for (int i = 0; i < node.getChildCount(); i++) {
AccessibilityNodeInfo child = node.getChild(i);
if (child != null) {
printUIViewTree(child, depth + 1);
}
}
} catch (Exception e) {
Log.w(TAG, indent + "├─ [打印节点出错: " + e.getMessage() + "]");
}
}
/**
* 打印处理过的视图树 - 基于时间节点回溯的聊天项目
*/
private void printProcessedViewTree(AccessibilityNodeInfo rootNode) {
try {
// 第一步:收集所有时间节点
List<AccessibilityNodeInfo> timeNodes = new ArrayList<>();
collectTimeNodes(rootNode, timeNodes);
Log.d(TAG, "找到 " + timeNodes.size() + " 个时间节点");
// 第二步:对每个时间节点进行回溯,找到可点击父级
Set<AccessibilityNodeInfo> processedParents = new HashSet<>();
for (AccessibilityNodeInfo timeNode : timeNodes) {
AccessibilityNodeInfo clickableParent = findNearestClickableParent(timeNode);
if (clickableParent != null && !processedParents.contains(clickableParent)) {
// 打印找到的聊天项目
printChatItem(clickableParent, 0);
// 标记为已处理,避免重复
processedParents.add(clickableParent);
Log.d(TAG, ""); // 空行分隔不同的聊天项目
}
}
if (processedParents.isEmpty()) {
Log.d(TAG, "未找到符合条件的聊天项目");
}
} catch (Exception e) {
Log.w(TAG, "打印处理过的视图树时出错: " + e.getMessage());
}
}
/**
* 收集所有包含时间信息的节点
*/
private void collectTimeNodes(AccessibilityNodeInfo node, List<AccessibilityNodeInfo> timeNodes) {
if (node == null || !node.isVisibleToUser()) return;
try {
// 检查当前节点是否包含时间信息
String nodeText = getNodeText(node);
if (isTimePattern(nodeText)) {
timeNodes.add(node);
Log.d(TAG, "发现时间节点: " + nodeText.trim());
}
// 递归检查所有子节点
for (int i = 0; i < node.getChildCount(); i++) {
AccessibilityNodeInfo child = node.getChild(i);
if (child != null) {
collectTimeNodes(child, timeNodes);
}
}
} catch (Exception e) {
Log.w(TAG, "收集时间节点时出错: " + e.getMessage());
}
}
/**
* 向上回溯找到最近的可点击父级
*/
private AccessibilityNodeInfo findNearestClickableParent(AccessibilityNodeInfo timeNode) {
if (timeNode == null) return null;
try {
AccessibilityNodeInfo current = timeNode;
// 向上遍历父级节点,找到第一个可点击的父级
while (current != null) {
AccessibilityNodeInfo parent = current.getParent();
if (parent == null) break;
// 检查父级是否满足可点击条件
if (isClickableParent(parent)) {
Log.d(TAG, "找到可点击父级: " + parent.getClassName());
return parent;
}
current = parent;
}
return null;
} catch (Exception e) {
Log.w(TAG, "查找可点击父级时出错: " + e.getMessage());
return null;
}
}
/**
* 检查节点是否满足可点击父级条件
*/
private boolean isClickableParent(AccessibilityNodeInfo node) {
if (node == null || !node.isVisibleToUser()) return false;
// 满足条件:
// 1. {clickable, long-clickable} 或 {clickable, long-clickable, visible}
// 2. {clickable, visible}
return node.isClickable() && (node.isLongClickable() || node.isVisibleToUser());
}
/**
* 打印聊天项目(可点击父级及其所有子节点)
*/
private void printChatItem(AccessibilityNodeInfo parentNode, int depth) {
if (parentNode == null) return;
try {
// 打印父级节点信息
printNodeInfo(parentNode, depth);
// 递归打印所有子节点
printAllChildNodes(parentNode, depth + 1);
} catch (Exception e) {
Log.w(TAG, "打印聊天项目时出错: " + e.getMessage());
}
}
/**
* 递归打印所有子节点
*/
private void printAllChildNodes(AccessibilityNodeInfo parentNode, int depth) {
try {
for (int i = 0; i < parentNode.getChildCount(); i++) {
AccessibilityNodeInfo child = parentNode.getChild(i);
if (child != null && child.isVisibleToUser()) {
// 打印子节点信息
printNodeInfo(child, depth);
// 递归打印子节点的子节点
printAllChildNodes(child, depth + 1);
}
}
} catch (Exception e) {
Log.w(TAG, "打印子节点时出错: " + e.getMessage());
}
}
/**
* 打印节点信息(统一格式)
*/
private void printNodeInfo(AccessibilityNodeInfo node, int depth) {
String indent = " ".repeat(depth);
try {
// 获取节点基本信息
String className = node.getClassName() != null ? node.getClassName().toString() : "null";
Rect bounds = new Rect();
node.getBoundsInScreen(bounds);
// 构建节点描述
StringBuilder nodeInfo = new StringBuilder();
nodeInfo.append(indent)
.append("├─ ")
.append(className);
// 添加文本内容
if (node.getText() != null && !node.getText().toString().trim().isEmpty()) {
nodeInfo.append(" [text=\"").append(node.getText()).append("\"]");
}
// 添加内容描述
if (node.getContentDescription() != null && !node.getContentDescription().toString().trim().isEmpty()) {
nodeInfo.append(" [desc=\"").append(node.getContentDescription()).append("\"]");
}
// 添加边界信息
nodeInfo.append(" [").append(bounds.toString()).append("]");
// 添加关键属性
List<String> attributes = new ArrayList<>();
if (node.isClickable()) attributes.add("clickable");
if (node.isLongClickable()) attributes.add("long-clickable");
if (node.isCheckable()) attributes.add(node.isChecked() ? "checked" : "checkable");
if (node.isScrollable()) attributes.add("scrollable");
if (node.isVisibleToUser()) attributes.add("visible");
if (!attributes.isEmpty()) {
nodeInfo.append(" {").append(String.join(", ", attributes)).append("}");
}
// 打印节点信息
Log.d(TAG, nodeInfo.toString());
} catch (Exception e) {
Log.w(TAG, indent + "├─ [打印节点出错: " + e.getMessage() + "]");
}
}
/**
* 获取节点的文本内容
*/
private String getNodeText(AccessibilityNodeInfo node) {
StringBuilder text = new StringBuilder();
if (node.getText() != null) {
text.append(node.getText().toString()).append(" ");
}
if (node.getContentDescription() != null) {
text.append(node.getContentDescription().toString()).append(" ");
}
return text.toString();
}
/**
* 判断文本是否包含时间模式
*/
private boolean isTimePattern(String text) {
if (text == null || text.trim().isEmpty()) {
return false;
}
String lowerText = text.toLowerCase().trim();
// 检查常见的时间模式
return lowerText.contains("分钟前") ||
lowerText.contains("小时前") ||
lowerText.contains("天前") ||
lowerText.contains("昨天") ||
lowerText.contains("前天") ||
lowerText.contains("今天") ||
lowerText.contains("刚刚") ||
lowerText.matches(".*\\d+:\\d+.*") || // HH:MM 格式
lowerText.matches(".*\\d{1,2}月\\d{1,2}日.*") || // MM月DD日 格式
lowerText.matches(".*\\d{4}/\\d{1,2}/\\d{1,2}.*") || // YYYY/MM/DD 格式
lowerText.matches(".*\\d{4}-\\d{1,2}-\\d{1,2}.*") || // YYYY-MM-DD 格式
lowerText.contains("周一") ||
lowerText.contains("周二") ||
lowerText.contains("周三") ||
lowerText.contains("周四") ||
lowerText.contains("周五") ||
lowerText.contains("周六") ||
lowerText.contains("周日") ||
lowerText.contains("星期一") ||
lowerText.contains("星期二") ||
lowerText.contains("星期三") ||
lowerText.contains("星期四") ||
lowerText.contains("星期五") ||
lowerText.contains("星期六") ||
lowerText.contains("星期日");
}
}
最新版本
package com.example.demotest.unread;
import android.accessibilityservice.AccessibilityService;
import android.content.res.Resources;
import android.graphics.Rect;
import android.util.DisplayMetrics;
import android.util.Log;
import android.view.accessibility.AccessibilityNodeInfo;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
/**
* 无障碍树打印器 - 打印界面视图树
*/
public class UnreadMessageAnalyzer {
private static final String TAG = "UnreadAnalysis";
private AccessibilityService accessibilityService;
private int screenWidth;
private int screenHeight;
public UnreadMessageAnalyzer(AccessibilityService service) {
this.accessibilityService = service;
DisplayMetrics metrics = Resources.getSystem().getDisplayMetrics();
this.screenWidth = metrics.widthPixels;
this.screenHeight = metrics.heightPixels;
}
/**
* 打印界面视图树和处理过的视图树
*/
public void printAccessibilityTrees() {
Log.d(TAG, "\n=== 开始打印无障碍树 ===");
AccessibilityNodeInfo rootNode = accessibilityService.getRootInActiveWindow();
if (rootNode == null) {
Log.e(TAG, "无法获取当前窗口信息");
return;
}
try {
// 打印完整的界面视图树
Log.d(TAG, "\n【界面视图树】");
printUIViewTree(rootNode, 0);
// 打印处理过的视图树(聊天项目)
Log.d(TAG, "\n【处理过的视图树(聊天项目)】");
printProcessedViewTree(rootNode);
} catch (Exception e) {
Log.e(TAG, "打印无障碍树时出错: " + e.getMessage(), e);
} finally {
rootNode.recycle();
}
Log.d(TAG, "\n=== 无障碍树打印完成 ===");
}
/**
* 打印UI视图树
*/
private void printUIViewTree(AccessibilityNodeInfo node, int depth) {
if (node == null) return;
String indent = " ".repeat(depth);
try {
// 获取节点基本信息
String className = node.getClassName() != null ? node.getClassName().toString() : "null";
Rect bounds = new Rect();
node.getBoundsInScreen(bounds);
// 构建节点描述
StringBuilder nodeInfo = new StringBuilder();
nodeInfo.append(indent)
.append("├─ ")
.append(className);
// 添加文本内容
if (node.getText() != null && !node.getText().toString().trim().isEmpty()) {
nodeInfo.append(" [text=\"").append(node.getText()).append("\"]");
}
// 添加内容描述
if (node.getContentDescription() != null && !node.getContentDescription().toString().trim().isEmpty()) {
nodeInfo.append(" [desc=\"").append(node.getContentDescription()).append("\"]");
}
// 添加边界信息
nodeInfo.append(" [").append(bounds.toString()).append("]");
// 添加关键属性
List<String> attributes = new ArrayList<>();
if (node.isClickable()) attributes.add("clickable");
if (node.isLongClickable()) attributes.add("long-clickable");
if (node.isCheckable()) attributes.add(node.isChecked() ? "checked" : "checkable");
if (node.isScrollable()) attributes.add("scrollable");
if (node.isVisibleToUser()) attributes.add("visible");
if (!attributes.isEmpty()) {
nodeInfo.append(" {").append(String.join(", ", attributes)).append("}");
}
// 打印节点信息
Log.d(TAG, nodeInfo.toString());
// 递归打印子节点
for (int i = 0; i < node.getChildCount(); i++) {
AccessibilityNodeInfo child = node.getChild(i);
if (child != null) {
printUIViewTree(child, depth + 1);
}
}
} catch (Exception e) {
Log.w(TAG, indent + "├─ [打印节点出错: " + e.getMessage() + "]");
}
}
/**
* 打印处理过的视图树 - 基于时间节点回溯的聊天项目
*/
private void printProcessedViewTree(AccessibilityNodeInfo rootNode) {
try {
// 第一步:收集所有时间节点
List<AccessibilityNodeInfo> timeNodes = new ArrayList<>();
collectTimeNodes(rootNode, timeNodes);
Log.d(TAG, "找到 " + timeNodes.size() + " 个时间节点");
// 第二步:对每个时间节点进行回溯,找到可点击父级
Set<AccessibilityNodeInfo> processedParents = new HashSet<>();
for (AccessibilityNodeInfo timeNode : timeNodes) {
AccessibilityNodeInfo clickableParent = findNearestClickableParent(timeNode);
if (clickableParent != null && !processedParents.contains(clickableParent)) {
// 打印找到的聊天项目
printChatItem(clickableParent, 0);
// 标记为已处理,避免重复
processedParents.add(clickableParent);
Log.d(TAG, ""); // 空行分隔不同的聊天项目
}
}
if (processedParents.isEmpty()) {
Log.d(TAG, "未找到符合条件的聊天项目");
}
} catch (Exception e) {
Log.w(TAG, "打印处理过的视图树时出错: " + e.getMessage());
}
}
/**
* 收集所有包含时间信息的节点
*/
private void collectTimeNodes(AccessibilityNodeInfo node, List<AccessibilityNodeInfo> timeNodes) {
if (node == null || !node.isVisibleToUser()) return;
try {
// 检查当前节点是否包含时间信息
String nodeText = getNodeText(node);
if (isTimePattern(nodeText)) {
timeNodes.add(node);
Log.d(TAG, "发现时间节点: " + nodeText.trim());
}
// 递归检查所有子节点
for (int i = 0; i < node.getChildCount(); i++) {
AccessibilityNodeInfo child = node.getChild(i);
if (child != null) {
collectTimeNodes(child, timeNodes);
}
}
} catch (Exception e) {
Log.w(TAG, "收集时间节点时出错: " + e.getMessage());
}
}
/**
* 向上回溯找到最近的可点击父级
*/
private AccessibilityNodeInfo findNearestClickableParent(AccessibilityNodeInfo timeNode) {
if (timeNode == null) return null;
try {
AccessibilityNodeInfo current = timeNode;
// 向上遍历父级节点,找到第一个可点击的父级
while (current != null) {
AccessibilityNodeInfo parent = current.getParent();
if (parent == null) break;
// 检查父级是否满足可点击条件
if (isClickableParent(parent)) {
Log.d(TAG, "找到可点击父级: " + parent.getClassName());
return parent;
}
current = parent;
}
return null;
} catch (Exception e) {
Log.w(TAG, "查找可点击父级时出错: " + e.getMessage());
return null;
}
}
/**
* 检查节点是否满足可点击父级条件
*/
private boolean isClickableParent(AccessibilityNodeInfo node) {
if (node == null || !node.isVisibleToUser()) return false;
// 满足条件:
// 1. {clickable, long-clickable} 或 {clickable, long-clickable, visible}
// 2. {clickable, visible}
return node.isClickable() && (node.isLongClickable() || node.isVisibleToUser());
}
/**
* 打印聊天项目(可点击父级及其所有子节点)
*/
private void printChatItem(AccessibilityNodeInfo parentNode, int depth) {
if (parentNode == null) return;
try {
// 打印父级节点信息
printNodeInfo(parentNode, depth);
// 递归打印所有子节点
printAllChildNodes(parentNode, depth + 1);
} catch (Exception e) {
Log.w(TAG, "打印聊天项目时出错: " + e.getMessage());
}
}
/**
* 递归打印所有子节点
*/
private void printAllChildNodes(AccessibilityNodeInfo parentNode, int depth) {
try {
for (int i = 0; i < parentNode.getChildCount(); i++) {
AccessibilityNodeInfo child = parentNode.getChild(i);
if (child != null && child.isVisibleToUser()) {
// 打印子节点信息
printNodeInfo(child, depth);
// 递归打印子节点的子节点
printAllChildNodes(child, depth + 1);
}
}
} catch (Exception e) {
Log.w(TAG, "打印子节点时出错: " + e.getMessage());
}
}
/**
* 打印节点信息(统一格式)
*/
private void printNodeInfo(AccessibilityNodeInfo node, int depth) {
String indent = " ".repeat(depth);
try {
// 获取节点基本信息
String className = node.getClassName() != null ? node.getClassName().toString() : "null";
Rect bounds = new Rect();
node.getBoundsInScreen(bounds);
// 构建节点描述
StringBuilder nodeInfo = new StringBuilder();
nodeInfo.append(indent)
.append("├─ ")
.append(className);
// 添加文本内容
if (node.getText() != null && !node.getText().toString().trim().isEmpty()) {
nodeInfo.append(" [text=\"").append(node.getText()).append("\"]");
}
// 添加内容描述
if (node.getContentDescription() != null && !node.getContentDescription().toString().trim().isEmpty()) {
nodeInfo.append(" [desc=\"").append(node.getContentDescription()).append("\"]");
}
// 添加边界信息
nodeInfo.append(" [").append(bounds.toString()).append("]");
// 添加关键属性
List<String> attributes = new ArrayList<>();
if (node.isClickable()) attributes.add("clickable");
if (node.isLongClickable()) attributes.add("long-clickable");
if (node.isCheckable()) attributes.add(node.isChecked() ? "checked" : "checkable");
if (node.isScrollable()) attributes.add("scrollable");
if (node.isVisibleToUser()) attributes.add("visible");
if (!attributes.isEmpty()) {
nodeInfo.append(" {").append(String.join(", ", attributes)).append("}");
}
// 打印节点信息
Log.d(TAG, nodeInfo.toString());
} catch (Exception e) {
Log.w(TAG, indent + "├─ [打印节点出错: " + e.getMessage() + "]");
}
}
/**
* 获取节点的文本内容
*/
private String getNodeText(AccessibilityNodeInfo node) {
StringBuilder text = new StringBuilder();
if (node.getText() != null) {
text.append(node.getText().toString()).append(" ");
}
if (node.getContentDescription() != null) {
text.append(node.getContentDescription().toString()).append(" ");
}
return text.toString();
}
/**
* 判断文本是否包含时间模式
*/
private boolean isTimePattern(String text) {
if (text == null || text.trim().isEmpty()) {
return false;
}
String lowerText = text.toLowerCase().trim();
// 检查常见的时间模式
return lowerText.contains("分钟前") ||
lowerText.contains("小时前") ||
lowerText.contains("天前") ||
lowerText.contains("昨天") ||
lowerText.contains("前天") ||
lowerText.contains("今天") ||
lowerText.contains("刚刚") ||
lowerText.matches(".*\\d+:\\d+.*") || // HH:MM 格式
lowerText.matches(".*\\d{1,2}月\\d{1,2}日.*") || // MM月DD日 格式
lowerText.matches(".*\\d{4}/\\d{1,2}/\\d{1,2}.*") || // YYYY/MM/DD 格式
lowerText.matches(".*\\d{4}-\\d{1,2}-\\d{1,2}.*") || // YYYY-MM-DD 格式
lowerText.contains("周一") ||
lowerText.contains("周二") ||
lowerText.contains("周三") ||
lowerText.contains("周四") ||
lowerText.contains("周五") ||
lowerText.contains("周六") ||
lowerText.contains("周日") ||
lowerText.contains("星期一") ||
lowerText.contains("星期二") ||
lowerText.contains("星期三") ||
lowerText.contains("星期四") ||
lowerText.contains("星期五") ||
lowerText.contains("星期六") ||
lowerText.contains("星期日");
}
}
最终版本
UnreadMessageAnalyzer.java
package com.example.demotest.unread;
import android.accessibilityservice.AccessibilityService;
import android.content.res.Resources;
import android.graphics.Rect;
import android.util.DisplayMetrics;
import android.util.Log;
import android.view.accessibility.AccessibilityNodeInfo;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
/**
* 无障碍树打印器 - 打印界面视图树
*/
public class UnreadMessageAnalyzer {
private static final String TAG = "UnreadAnalysis";
private AccessibilityService accessibilityService;
private int screenWidth;
private int screenHeight;
public UnreadMessageAnalyzer(AccessibilityService service) {
this.accessibilityService = service;
DisplayMetrics metrics = Resources.getSystem().getDisplayMetrics();
this.screenWidth = metrics.widthPixels;
this.screenHeight = metrics.heightPixels;
}
/**
* 打印界面视图树和处理过的视图树
*/
public void printAccessibilityTrees() {
Log.d(TAG, "\n=== 开始打印无障碍树 ===");
AccessibilityNodeInfo rootNode = accessibilityService.getRootInActiveWindow();
if (rootNode == null) {
Log.e(TAG, "无法获取当前窗口信息");
return;
}
try {
// 打印完整的界面视图树
Log.d(TAG, "\n【界面视图树】");
printUIViewTree(rootNode, 0);
// 打印处理过的视图树(聊天项目)
Log.d(TAG, "\n【处理过的视图树(聊天项目)】");
printProcessedViewTree(rootNode);
} catch (Exception e) {
Log.e(TAG, "打印无障碍树时出错: " + e.getMessage(), e);
} finally {
rootNode.recycle();
}
Log.d(TAG, "\n=== 无障碍树打印完成 ===");
}
/**
* 打印UI视图树
*/
private void printUIViewTree(AccessibilityNodeInfo node, int depth) {
if (node == null) return;
String indent = " ".repeat(depth);
try {
// 获取节点基本信息
String className = node.getClassName() != null ? node.getClassName().toString() : "null";
Rect bounds = new Rect();
node.getBoundsInScreen(bounds);
// 构建节点描述
StringBuilder nodeInfo = new StringBuilder();
nodeInfo.append(indent)
.append("├─ ")
.append(className);
// 添加文本内容
if (node.getText() != null && !node.getText().toString().trim().isEmpty()) {
nodeInfo.append(" [text=\"").append(node.getText()).append("\"]");
}
// 添加内容描述
if (node.getContentDescription() != null && !node.getContentDescription().toString().trim().isEmpty()) {
nodeInfo.append(" [desc=\"").append(node.getContentDescription()).append("\"]");
}
// 添加边界信息
nodeInfo.append(" [").append(bounds.toString()).append("]");
// 添加关键属性
List<String> attributes = new ArrayList<>();
if (node.isClickable()) attributes.add("clickable");
if (node.isLongClickable()) attributes.add("long-clickable");
if (node.isCheckable()) attributes.add(node.isChecked() ? "checked" : "checkable");
if (node.isScrollable()) attributes.add("scrollable");
if (node.isVisibleToUser()) attributes.add("visible");
if (!attributes.isEmpty()) {
nodeInfo.append(" {").append(String.join(", ", attributes)).append("}");
}
// 打印节点信息
Log.d(TAG, nodeInfo.toString());
// 递归打印子节点
for (int i = 0; i < node.getChildCount(); i++) {
AccessibilityNodeInfo child = node.getChild(i);
if (child != null) {
printUIViewTree(child, depth + 1);
}
}
} catch (Exception e) {
Log.w(TAG, indent + "├─ [打印节点出错: " + e.getMessage() + "]");
}
}
/**
* 打印处理过的视图树 - 基于时间节点回溯的聊天项目
*/
private void printProcessedViewTree(AccessibilityNodeInfo rootNode) {
try {
// 第一步:收集所有时间节点
List<AccessibilityNodeInfo> timeNodes = new ArrayList<>();
collectTimeNodes(rootNode, timeNodes);
Log.d(TAG, "找到 " + timeNodes.size() + " 个时间节点");
// 第二步:对每个时间节点进行回溯,找到可点击父级
Set<AccessibilityNodeInfo> processedParents = new HashSet<>();
List<AccessibilityNodeInfo> chatItems = new ArrayList<>();
for (AccessibilityNodeInfo timeNode : timeNodes) {
AccessibilityNodeInfo clickableParent = findNearestClickableParent(timeNode);
if (clickableParent != null && !processedParents.contains(clickableParent)) {
// 打印找到的聊天项目
printChatItem(clickableParent, 0);
// 添加到聊天项目列表中
chatItems.add(clickableParent);
// 标记为已处理,避免重复
processedParents.add(clickableParent);
Log.d(TAG, ""); // 空行分隔不同的聊天项目
}
}
if (processedParents.isEmpty()) {
Log.d(TAG, "未找到符合条件的聊天项目");
} else {
// 使用未读消息检测器分析所有聊天项
analyzeUnreadMessages(chatItems);
}
} catch (Exception e) {
Log.w(TAG, "打印处理过的视图树时出错: " + e.getMessage());
}
}
/**
* 收集所有包含时间信息的节点
*/
private void collectTimeNodes(AccessibilityNodeInfo node, List<AccessibilityNodeInfo> timeNodes) {
if (node == null || !node.isVisibleToUser()) return;
try {
// 检查当前节点是否包含时间信息
String nodeText = getNodeText(node);
if (isTimePattern(nodeText)) {
timeNodes.add(node);
Log.d(TAG, "发现时间节点: " + nodeText.trim());
}
// 递归检查所有子节点
for (int i = 0; i < node.getChildCount(); i++) {
AccessibilityNodeInfo child = node.getChild(i);
if (child != null) {
collectTimeNodes(child, timeNodes);
}
}
} catch (Exception e) {
Log.w(TAG, "收集时间节点时出错: " + e.getMessage());
}
}
/**
* 向上回溯找到最近的可点击父级
*/
private AccessibilityNodeInfo findNearestClickableParent(AccessibilityNodeInfo timeNode) {
if (timeNode == null) return null;
try {
AccessibilityNodeInfo current = timeNode;
// 向上遍历父级节点,找到第一个可点击的父级
while (current != null) {
AccessibilityNodeInfo parent = current.getParent();
if (parent == null) break;
// 检查父级是否满足可点击条件
if (isClickableParent(parent)) {
Log.d(TAG, "找到可点击父级: " + parent.getClassName());
return parent;
}
current = parent;
}
return null;
} catch (Exception e) {
Log.w(TAG, "查找可点击父级时出错: " + e.getMessage());
return null;
}
}
/**
* 检查节点是否满足可点击父级条件
*/
private boolean isClickableParent(AccessibilityNodeInfo node) {
if (node == null || !node.isVisibleToUser()) return false;
// 满足条件:
// 1. {clickable, long-clickable} 或 {clickable, long-clickable, visible}
// 2. {clickable, visible}
return node.isClickable() && (node.isLongClickable() || node.isVisibleToUser());
}
/**
* 打印聊天项目(可点击父级及其所有子节点)
*/
private void printChatItem(AccessibilityNodeInfo parentNode, int depth) {
if (parentNode == null) return;
try {
// 打印父级节点信息
printNodeInfo(parentNode, depth);
// 递归打印所有子节点
printAllChildNodes(parentNode, depth + 1);
} catch (Exception e) {
Log.w(TAG, "打印聊天项目时出错: " + e.getMessage());
}
}
/**
* 递归打印所有子节点
*/
private void printAllChildNodes(AccessibilityNodeInfo parentNode, int depth) {
try {
for (int i = 0; i < parentNode.getChildCount(); i++) {
AccessibilityNodeInfo child = parentNode.getChild(i);
if (child != null && child.isVisibleToUser()) {
// 打印子节点信息
printNodeInfo(child, depth);
// 递归打印子节点的子节点
printAllChildNodes(child, depth + 1);
}
}
} catch (Exception e) {
Log.w(TAG, "打印子节点时出错: " + e.getMessage());
}
}
/**
* 打印节点信息(统一格式)
*/
private void printNodeInfo(AccessibilityNodeInfo node, int depth) {
String indent = " ".repeat(depth);
try {
// 获取节点基本信息
String className = node.getClassName() != null ? node.getClassName().toString() : "null";
Rect bounds = new Rect();
node.getBoundsInScreen(bounds);
// 构建节点描述
StringBuilder nodeInfo = new StringBuilder();
nodeInfo.append(indent)
.append("├─ ")
.append(className);
// 添加文本内容
if (node.getText() != null && !node.getText().toString().trim().isEmpty()) {
nodeInfo.append(" [text=\"").append(node.getText()).append("\"]");
}
// 添加内容描述
if (node.getContentDescription() != null && !node.getContentDescription().toString().trim().isEmpty()) {
nodeInfo.append(" [desc=\"").append(node.getContentDescription()).append("\"]");
}
// 添加边界信息
nodeInfo.append(" [").append(bounds.toString()).append("]");
// 添加关键属性
List<String> attributes = new ArrayList<>();
if (node.isClickable()) attributes.add("clickable");
if (node.isLongClickable()) attributes.add("long-clickable");
if (node.isCheckable()) attributes.add(node.isChecked() ? "checked" : "checkable");
if (node.isScrollable()) attributes.add("scrollable");
if (node.isVisibleToUser()) attributes.add("visible");
if (!attributes.isEmpty()) {
nodeInfo.append(" {").append(String.join(", ", attributes)).append("}");
}
// 打印节点信息
Log.d(TAG, nodeInfo.toString());
} catch (Exception e) {
Log.w(TAG, indent + "├─ [打印节点出错: " + e.getMessage() + "]");
}
}
/**
* 获取节点的文本内容
*/
private String getNodeText(AccessibilityNodeInfo node) {
StringBuilder text = new StringBuilder();
if (node.getText() != null) {
text.append(node.getText().toString()).append(" ");
}
if (node.getContentDescription() != null) {
text.append(node.getContentDescription().toString()).append(" ");
}
return text.toString();
}
/**
* 判断文本是否包含时间模式
*/
private boolean isTimePattern(String text) {
if (text == null || text.trim().isEmpty()) {
return false;
}
String lowerText = text.toLowerCase().trim();
// 检查常见的时间模式
return lowerText.contains("分钟前") ||
lowerText.contains("小时前") ||
lowerText.contains("天前") ||
lowerText.contains("昨天") ||
lowerText.contains("前天") ||
lowerText.contains("今天") ||
lowerText.contains("刚刚") ||
lowerText.matches(".*\\d+:\\d+.*") || // HH:MM 格式
lowerText.matches(".*\\d{1,2}月\\d{1,2}日.*") || // MM月DD日 格式
lowerText.matches(".*\\d{4}/\\d{1,2}/\\d{1,2}.*") || // YYYY/MM/DD 格式
lowerText.matches(".*\\d{4}-\\d{1,2}-\\d{1,2}.*") || // YYYY-MM-DD 格式
lowerText.contains("周一") ||
lowerText.contains("周二") ||
lowerText.contains("周三") ||
lowerText.contains("周四") ||
lowerText.contains("周五") ||
lowerText.contains("周六") ||
lowerText.contains("周日") ||
lowerText.contains("星期一") ||
lowerText.contains("星期二") ||
lowerText.contains("星期三") ||
lowerText.contains("星期四") ||
lowerText.contains("星期五") ||
lowerText.contains("星期六") ||
lowerText.contains("星期日");
}
/**
* 分析聊天项的未读消息
*/
private void analyzeUnreadMessages(List<AccessibilityNodeInfo> chatItems) {
Log.d(TAG, "\n🔍 ===== 开始未读消息分析 =====");
try {
// 创建未读消息检测器
UnreadMessageDetector detector = new UnreadMessageDetector(screenWidth);
// 检测所有聊天项的未读消息
List<UnreadMessageDetector.UnreadResult> unreadResults =
detector.detectMultipleUnreadMessages(chatItems);
// 输出分析结果
if (unreadResults.isEmpty()) {
Log.d(TAG, "🟢 当前页面没有发现未读消息");
} else {
Log.d(TAG, "🔴 发现 " + unreadResults.size() + " 个有未读消息的聊天项:");
for (int i = 0; i < unreadResults.size(); i++) {
UnreadMessageDetector.UnreadResult result = unreadResults.get(i);
Log.d(TAG, String.format("\n📱 第%d个未读消息:", i + 1));
Log.d(TAG, " 👤 昵称: " + (result.nickname != null ? result.nickname : "未知"));
Log.d(TAG, " 💬 消息: " + (result.lastMessage != null ? result.lastMessage : "无"));
Log.d(TAG, " ⏰ 时间: " + (result.time != null ? result.time : "未知"));
Log.d(TAG, " 🔴 未读标识: " + result.unreadCount);
Log.d(TAG, " 📍 点击坐标: " + result.clickBounds.toString());
Log.d(TAG, " 📱 坐标中心: (" + result.clickBounds.centerX() + ", " + result.clickBounds.centerY() + ")");
}
// 输出可直接使用的坐标列表
Log.d(TAG, "\n📍 未读消息用户点击坐标汇总:");
for (int i = 0; i < unreadResults.size(); i++) {
UnreadMessageDetector.UnreadResult result = unreadResults.get(i);
Log.d(TAG, String.format("用户%d [%s] 未读标识[%s] → 点击坐标(%d, %d)",
i + 1,
result.nickname != null ? result.nickname : "未知用户",
result.unreadCount,
result.clickBounds.centerX(),
result.clickBounds.centerY()));
}
}
} catch (Exception e) {
Log.e(TAG, "分析未读消息时出错: " + e.getMessage(), e);
}
Log.d(TAG, "🔍 ===== 未读消息分析完成 =====\n");
}
}
UnreadMessageDetector.java
package com.example.demotest.unread;
import android.graphics.Rect;
import android.util.Log;
import android.view.accessibility.AccessibilityNodeInfo;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Pattern;
/**
* 未读消息检测器
* 分析聊天项的布局结构并检测未读消息
*/
public class UnreadMessageDetector {
private static final String TAG = "UnreadDetector";
// 屏幕宽度,用于计算相对位置
private int screenWidth;
/**
* 文本元素信息
*/
private static class TextElement {
String text;
String description;
Rect bounds;
AccessibilityNodeInfo node;
TextElement(String text, String description, Rect bounds, AccessibilityNodeInfo node) {
this.text = text;
this.description = description;
this.bounds = bounds;
this.node = node;
}
/**
* 获取有效文本内容
*/
String getEffectiveText() {
if (text != null && !text.trim().isEmpty()) {
return text.trim();
}
if (description != null && !description.trim().isEmpty()) {
return description.trim();
}
return "";
}
/**
* 获取X坐标中心点
*/
int getCenterX() {
return bounds.left + (bounds.width() / 2);
}
/**
* 获取Y坐标中心点
*/
int getCenterY() {
return bounds.top + (bounds.height() / 2);
}
}
/**
* 未读消息结果
*/
public static class UnreadResult {
public String nickname; // 昵称
public String lastMessage; // 最后消息
public String time; // 时间
public String unreadCount; // 未读数
public Rect clickBounds; // 可点击区域坐标
public AccessibilityNodeInfo clickableNode; // 可点击节点
@Override
public String toString() {
return String.format("未读消息 - 昵称:%s, 消息:%s, 时间:%s, 未读标识:%s, 坐标:%s",
nickname, lastMessage, time, unreadCount, clickBounds.toString());
}
}
public UnreadMessageDetector(int screenWidth) {
this.screenWidth = screenWidth;
}
/**
* 检测聊天项是否有未读消息
*/
public UnreadResult detectUnreadMessage(AccessibilityNodeInfo chatItemNode) {
try {
Log.d(TAG, "\n=== 开始检测未读消息 ===");
// 策略0:优先检查是否有集中式的contentDescription
UnreadResult contentDescResult = detectFromContentDescription(chatItemNode);
if (contentDescResult != null) {
Log.d(TAG, "🔴 策略0成功:从contentDescription检测到未读消息");
return contentDescResult;
}
// 收集所有文本元素
List<TextElement> textElements = new ArrayList<>();
collectTextElements(chatItemNode, textElements);
if (textElements.isEmpty()) {
Log.d(TAG, "未找到任何文本元素");
return null;
}
Log.d(TAG, "收集到 " + textElements.size() + " 个文本元素");
// 按Y坐标分层
LayerAnalysis layerAnalysis = analyzeLayersByY(textElements);
// 分析第一层元素(昵称、时间、火花)
FirstLayerElements firstLayer = analyzeFirstLayer(layerAnalysis.firstLayerElements, textElements);
// 分析第二层元素(内容、未读数)
SecondLayerElements secondLayer = analyzeSecondLayer(layerAnalysis.secondLayerElements);
// 四种策略检测未读消息(1-3为原有策略)
String unreadIndicator = detectUnreadIndicator(firstLayer, secondLayer, textElements);
// 检测是否有未读消息
if (unreadIndicator != null && !unreadIndicator.isEmpty()) {
UnreadResult result = new UnreadResult();
result.nickname = firstLayer.nickname;
result.lastMessage = secondLayer.content;
result.time = firstLayer.time;
result.unreadCount = unreadIndicator;
result.clickableNode = chatItemNode;
// 获取点击坐标
Rect bounds = new Rect();
chatItemNode.getBoundsInScreen(bounds);
result.clickBounds = bounds;
Log.d(TAG, "🔴 发现未读消息: " + result.toString());
return result;
} else {
Log.d(TAG, "该聊天项无未读消息");
return null;
}
} catch (Exception e) {
Log.e(TAG, "检测未读消息时出错: " + e.getMessage(), e);
return null;
}
}
/**
* 策略0:从集中式contentDescription检测未读消息
* 适用于所有信息都集中在一个contentDescription中的情况
* 格式示例:"VSCode技术交流群, ,有164条未读,[有新文件]树木上的林: [图片]这个打不开谁能帮我下载一下里面的东西,15:12"
*/
private UnreadResult detectFromContentDescription(AccessibilityNodeInfo chatItemNode) {
try {
// 递归查找所有可能包含完整信息的contentDescription
return findContentDescriptionInTree(chatItemNode);
} catch (Exception e) {
Log.w(TAG, "策略0:解析contentDescription出错: " + e.getMessage());
return null;
}
}
/**
* 在节点树中递归查找包含完整聊天信息的contentDescription
*/
private UnreadResult findContentDescriptionInTree(AccessibilityNodeInfo node) {
if (node == null) return null;
try {
// 检查当前节点的contentDescription
String desc = node.getContentDescription() != null ?
node.getContentDescription().toString() : "";
if (!desc.trim().isEmpty()) {
Log.d(TAG, "策略0:检查contentDescription: " + desc);
// 解析contentDescription
UnreadResult result = parseContentDescription(desc, node);
if (result != null) {
return result;
}
}
// 递归检查子节点
for (int i = 0; i < node.getChildCount(); i++) {
AccessibilityNodeInfo child = node.getChild(i);
if (child != null) {
UnreadResult childResult = findContentDescriptionInTree(child);
if (childResult != null) {
return childResult;
}
}
}
} catch (Exception e) {
Log.w(TAG, "策略0:遍历节点树出错: " + e.getMessage());
}
return null;
}
/**
* 解析集中式contentDescription字符串
* 支持的格式:
* 1. "昵称, ,有X条未读,消息内容,时间"
* 2. "昵称, ,有X条未读,消息内容"
* 3. "昵称,消息内容,有X条未读,时间"
*/
private UnreadResult parseContentDescription(String desc, AccessibilityNodeInfo node) {
if (desc == null || desc.trim().isEmpty()) return null;
String trimmedDesc = desc.trim();
Log.d(TAG, "策略0:解析描述字符串: " + trimmedDesc);
// 检查是否包含未读标识
if (!containsUnreadIndicator(trimmedDesc)) {
Log.d(TAG, "策略0:描述字符串不包含未读标识");
return null;
}
try {
// 按逗号分割描述字符串
String[] parts = trimmedDesc.split(",");
if (parts.length < 3) {
Log.d(TAG, "策略0:描述字符串格式不符合预期,部分数量: " + parts.length);
return null;
}
// 清理每个部分的空白字符
for (int i = 0; i < parts.length; i++) {
parts[i] = parts[i].trim();
}
Log.d(TAG, "策略0:分割后的部分数量: " + parts.length);
for (int i = 0; i < parts.length; i++) {
Log.d(TAG, String.format("策略0:部分[%d]: \"%s\"", i, parts[i]));
}
// 解析各个部分
UnreadResult result = new UnreadResult();
result.clickableNode = node;
// 获取点击坐标
Rect bounds = new Rect();
node.getBoundsInScreen(bounds);
result.clickBounds = bounds;
// 提取信息
extractInfoFromParts(parts, result);
// 验证解析结果
if (isValidUnreadResult(result)) {
Log.d(TAG, String.format("策略0:解析成功 - 昵称:%s, 未读:%s, 消息:%s, 时间:%s",
result.nickname, result.unreadCount, result.lastMessage, result.time));
return result;
} else {
Log.d(TAG, "策略0:解析结果验证失败");
return null;
}
} catch (Exception e) {
Log.w(TAG, "策略0:解析描述字符串出错: " + e.getMessage());
return null;
}
}
/**
* 检查描述字符串是否包含未读标识
*/
private boolean containsUnreadIndicator(String desc) {
String lowerDesc = desc.toLowerCase();
return lowerDesc.contains("未读") ||
lowerDesc.contains("unread") ||
lowerDesc.matches(".*有\\d+条.*");
}
/**
* 从分割的部分中提取信息
*/
private void extractInfoFromParts(String[] parts, UnreadResult result) {
// 通常第一个部分是昵称(排除空字符串)
for (int i = 0; i < parts.length; i++) {
if (!parts[i].isEmpty() && result.nickname == null) {
result.nickname = parts[i];
Log.d(TAG, "策略0:提取昵称: " + result.nickname);
break;
}
}
// 查找未读数信息
for (String part : parts) {
if (part.contains("未读") || part.contains("unread")) {
result.unreadCount = extractUnreadCount(part);
Log.d(TAG, "策略0:提取未读数: " + result.unreadCount);
break;
}
}
// 查找时间信息(通常是最后一个非空部分,且符合时间格式)
for (int i = parts.length - 1; i >= 0; i--) {
if (!parts[i].isEmpty() && isTimePattern(parts[i])) {
result.time = parts[i];
Log.d(TAG, "策略0:提取时间: " + result.time);
break;
}
}
// 查找消息内容(排除昵称、未读数、时间后的其他内容)
StringBuilder messageBuilder = new StringBuilder();
for (String part : parts) {
if (!part.isEmpty() &&
!part.equals(result.nickname) &&
!part.contains("未读") &&
!part.contains("unread") &&
!isTimePattern(part)) {
if (messageBuilder.length() > 0) {
messageBuilder.append(",");
}
messageBuilder.append(part);
}
}
if (messageBuilder.length() > 0) {
result.lastMessage = messageBuilder.toString();
Log.d(TAG, "策略0:提取消息内容: " + result.lastMessage);
}
}
/**
* 从未读标识字符串中提取具体的未读数
*/
private String extractUnreadCount(String unreadText) {
if (unreadText == null) return null;
// 匹配 "有X条未读" 格式
java.util.regex.Pattern pattern = java.util.regex.Pattern.compile("有(\\d+)条");
java.util.regex.Matcher matcher = pattern.matcher(unreadText);
if (matcher.find()) {
return matcher.group(1);
}
// 匹配其他数字格式
pattern = java.util.regex.Pattern.compile("(\\d+)");
matcher = pattern.matcher(unreadText);
if (matcher.find()) {
return matcher.group(1);
}
// 如果没有具体数字,返回原始文本
return unreadText;
}
/**
* 验证解析结果是否有效
*/
private boolean isValidUnreadResult(UnreadResult result) {
return result != null &&
result.nickname != null && !result.nickname.trim().isEmpty() &&
result.unreadCount != null && !result.unreadCount.trim().isEmpty();
}
/**
* 收集所有文本元素
*/
private void collectTextElements(AccessibilityNodeInfo node, List<TextElement> elements) {
if (node == null || !node.isVisibleToUser()) return;
try {
// 检查当前节点是否有文本内容
String text = node.getText() != null ? node.getText().toString() : "";
String desc = node.getContentDescription() != null ? node.getContentDescription().toString() : "";
if (!text.trim().isEmpty() || !desc.trim().isEmpty()) {
Rect bounds = new Rect();
node.getBoundsInScreen(bounds);
TextElement element = new TextElement(text, desc, bounds, node);
elements.add(element);
Log.d(TAG, String.format("文本元素: \"%s\" [%s]",
element.getEffectiveText(), bounds.toString()));
}
// 递归处理子节点
for (int i = 0; i < node.getChildCount(); i++) {
AccessibilityNodeInfo child = node.getChild(i);
if (child != null) {
collectTextElements(child, elements);
}
}
} catch (Exception e) {
Log.w(TAG, "收集文本元素时出错: " + e.getMessage());
}
}
/**
* 层级分析结果
*/
private static class LayerAnalysis {
List<TextElement> firstLayerElements = new ArrayList<>();
List<TextElement> secondLayerElements = new ArrayList<>();
int layerThreshold; // Y坐标分层阈值
}
/**
* 按Y坐标分层分析
*/
private LayerAnalysis analyzeLayersByY(List<TextElement> elements) {
LayerAnalysis analysis = new LayerAnalysis();
if (elements.isEmpty()) return analysis;
// 找到最小和最大Y坐标
int minY = Integer.MAX_VALUE;
int maxY = Integer.MIN_VALUE;
for (TextElement element : elements) {
int centerY = element.getCenterY();
minY = Math.min(minY, centerY);
maxY = Math.max(maxY, centerY);
}
// 计算分层阈值(约在中间位置)
analysis.layerThreshold = minY + (maxY - minY) / 2;
Log.d(TAG, String.format("Y坐标范围: %d - %d, 分层阈值: %d", minY, maxY, analysis.layerThreshold));
// 分层分配元素
for (TextElement element : elements) {
if (element.getCenterY() <= analysis.layerThreshold) {
analysis.firstLayerElements.add(element);
Log.d(TAG, String.format("第一层: \"%s\" Y=%d", element.getEffectiveText(), element.getCenterY()));
} else {
analysis.secondLayerElements.add(element);
Log.d(TAG, String.format("第二层: \"%s\" Y=%d", element.getEffectiveText(), element.getCenterY()));
}
}
return analysis;
}
/**
* 第一层元素分析结果
*/
private static class FirstLayerElements {
String nickname; // 昵称
String time; // 时间
String sparkCount; // 火花数字
TextElement nicknameElement; // 昵称元素(用于检测左侧未读数)
}
/**
* 分析第一层元素(昵称、时间、火花)
*/
private FirstLayerElements analyzeFirstLayer(List<TextElement> elements, List<TextElement> allElements) {
FirstLayerElements result = new FirstLayerElements();
if (elements.isEmpty()) return result;
// 按X坐标排序
elements.sort((a, b) -> Integer.compare(a.getCenterX(), b.getCenterX()));
// 找到内容区域中Y坐标最小的元素作为昵称
TextElement nicknameElement = null;
int minY = Integer.MAX_VALUE;
for (TextElement element : elements) {
String text = element.getEffectiveText();
int relativeX = element.getCenterX() * 100 / screenWidth; // 转换为相对位置百分比
Log.d(TAG, String.format("第一层元素分析: \"%s\" X位置=%d%% Y位置=%d", text, relativeX, element.getCenterY()));
if (isTimePattern(text)) {
// 时间通常在右侧
result.time = text;
Log.d(TAG, "识别为时间: " + text);
} else if (isSparkNumber(text, element)) {
// 火花数字通常在中间,且前面有ImageView
result.sparkCount = text;
Log.d(TAG, "识别为火花数字: " + text);
} else if (relativeX >= 30) {
// 昵称应该在内容区域中(X >= 30%),在此区域中找Y坐标最小的
int elementY = element.getCenterY();
if (elementY < minY) {
minY = elementY;
nicknameElement = element;
result.nickname = text;
}
}
}
if (nicknameElement != null) {
Log.d(TAG, String.format("识别昵称: \"%s\" Y坐标: %d", result.nickname, nicknameElement.getCenterY()));
result.nicknameElement = nicknameElement;
}
return result;
}
/**
* 第二层元素分析结果
*/
private static class SecondLayerElements {
String content; // 消息内容
String unreadCount; // 未读数
}
/**
* 分析第二层元素(内容、未读数)
*/
private SecondLayerElements analyzeSecondLayer(List<TextElement> elements) {
SecondLayerElements result = new SecondLayerElements();
if (elements.isEmpty()) return result;
// 按X坐标排序
elements.sort((a, b) -> Integer.compare(a.getCenterX(), b.getCenterX()));
for (TextElement element : elements) {
String text = element.getEffectiveText();
int relativeX = element.getCenterX() * 100 / screenWidth; // 转换为相对位置百分比
Log.d(TAG, String.format("第二层元素分析: \"%s\" X位置=%d%%", text, relativeX));
if (isUnreadNumber(text, relativeX)) {
// 未读数:纯数字 + 在右侧位置
result.unreadCount = text;
Log.d(TAG, "✅ 识别为未读数: " + text);
} else if (relativeX < 80) {
// 消息内容通常在左侧或中间
if (result.content == null || result.content.isEmpty()) {
result.content = text;
} else {
result.content += " " + text; // 拼接多个内容元素
}
Log.d(TAG, "识别为消息内容: " + text);
}
}
return result;
}
/**
* 判断是否为时间模式
*/
private boolean isTimePattern(String text) {
if (text == null || text.trim().isEmpty()) return false;
String lowerText = text.toLowerCase().trim();
return lowerText.contains("分钟前") ||
lowerText.contains("小时前") ||
lowerText.contains("天前") ||
lowerText.contains("昨天") ||
lowerText.contains("前天") ||
lowerText.contains("今天") ||
lowerText.contains("刚刚") ||
lowerText.matches(".*\\d+:\\d+.*") ||
lowerText.matches(".*\\d{1,2}月\\d{1,2}日.*") ||
lowerText.contains("周一") || lowerText.contains("周二") ||
lowerText.contains("周三") || lowerText.contains("周四") ||
lowerText.contains("周五") || lowerText.contains("周六") ||
lowerText.contains("周日") || lowerText.contains("星期一") ||
lowerText.contains("星期二") || lowerText.contains("星期三") ||
lowerText.contains("星期四") || lowerText.contains("星期五") ||
lowerText.contains("星期六") || lowerText.contains("星期日");
}
/**
* 判断是否为火花数字
* 特征:数字 + 前后有空格 + 可能有前置ImageView
*/
private boolean isSparkNumber(String text, TextElement element) {
if (text == null || text.trim().isEmpty()) return false;
// 检查是否为纯数字(可能有空格)
String trimmed = text.trim();
if (!Pattern.matches("\\d+", trimmed)) return false;
// 检查是否有前后空格(火花数字的特征)
if (text.startsWith(" ") || text.endsWith(" ")) {
Log.d(TAG, "疑似火花数字(有空格): \"" + text + "\"");
return true;
}
// 检查X坐标是否在中间区域(30%-70%)
int relativeX = element.getCenterX() * 100 / screenWidth;
if (relativeX >= 30 && relativeX <= 70) {
Log.d(TAG, "疑似火花数字(中间位置): \"" + text + "\" X=" + relativeX + "%");
return true;
}
return false;
}
/**
* 判断是否为未读标识(数字或文字)
* 特征:数字未读数 或 文字未读标识 + 在右侧位置
*/
private boolean isUnreadNumber(String text, int relativeX) {
if (text == null || text.trim().isEmpty()) return false;
String trimmed = text.trim();
// 必须在右侧位置(75%以后,稍微放宽一点)
if (relativeX < 75) return false;
// 检查是否为数字类型的未读数
if (isNumericUnread(trimmed, text)) {
return true;
}
// 检查是否为文字类型的未读标识
if (isTextUnread(trimmed)) {
return true;
}
return false;
}
/**
* 判断是否为数字类型的未读数
*/
private boolean isNumericUnread(String trimmed, String originalText) {
// 必须是纯数字
if (!Pattern.matches("\\d+", trimmed)) return false;
// 未读数通常是1-999的范围
try {
int number = Integer.parseInt(trimmed);
if (number < 1 || number > 999) return false;
} catch (NumberFormatException e) {
return false;
}
// 不应该有前后空格(区别于火花数字)
if (originalText.startsWith(" ") || originalText.endsWith(" ")) return false;
return true;
}
/**
* 判断是否为文字类型的未读标识
*/
private boolean isTextUnread(String text) {
if (text == null || text.trim().isEmpty()) return false;
String lowerText = text.toLowerCase().trim();
// 中文未读标识
if (lowerText.equals("未读") || lowerText.equals("新消息") ||
lowerText.equals("新") || lowerText.equals("未读消息")) {
return true;
}
// 英文未读标识
if (lowerText.equals("unread") || lowerText.equals("new") ||
lowerText.equals("!") || lowerText.equals("new message") ||
lowerText.equals("message") || lowerText.equals("msg")) {
return true;
}
// 其他可能的标识
if (lowerText.equals("●") || lowerText.equals("•") ||
lowerText.equals("🔴") || lowerText.equals("红点")) {
return true;
}
return false;
}
/**
* 分散元素策略检测未读消息(策略1-3)
* 策略1:右侧区域未读数:在消息内容右边的数字/文本标识
* 策略2:昵称左侧未读数:在昵称左边的数字角标
* 策略3:文本形式未读标识:如"未读"、"new"等文字
* 注:策略0(集中式contentDescription)已在主检测方法中优先执行
*/
private String detectUnreadIndicator(FirstLayerElements firstLayer, SecondLayerElements secondLayer, List<TextElement> allElements) {
Log.d(TAG, "\n=== 开始分散元素策略检测未读消息(策略1-3)===");
// 策略1:传统的右侧区域未读数
if (secondLayer.unreadCount != null && !secondLayer.unreadCount.isEmpty()) {
Log.d(TAG, "策略1成功:右侧区域未读数 = " + secondLayer.unreadCount);
return secondLayer.unreadCount;
}
// 策略2:昵称左侧未读数(头像右上角)
String nicknameLeftUnread = detectNicknameLeftUnread(firstLayer, allElements);
if (nicknameLeftUnread != null && !nicknameLeftUnread.isEmpty()) {
Log.d(TAG, "策略2成功:昵称左侧未读数 = " + nicknameLeftUnread);
return nicknameLeftUnread;
}
// 策略3:文本形式未读标识
String textUnreadIndicator = detectTextUnreadIndicator(allElements);
if (textUnreadIndicator != null && !textUnreadIndicator.isEmpty()) {
Log.d(TAG, "策略3成功:文本形式未读标识 = " + textUnreadIndicator);
return textUnreadIndicator;
}
Log.d(TAG, "分散元素策略(1-3)均未检测到未读消息");
return null;
}
/**
* 策略2:检测昵称左侧的未读数(头像右上角)
*/
private String detectNicknameLeftUnread(FirstLayerElements firstLayer, List<TextElement> allElements) {
if (firstLayer.nicknameElement == null) {
Log.d(TAG, "策略2:无昵称元素,跳过");
return null;
}
TextElement nicknameElement = firstLayer.nicknameElement;
int nicknameX = nicknameElement.getCenterX();
int nicknameY = nicknameElement.getCenterY();
Log.d(TAG, String.format("策略2:昵称位置 X=%d Y=%d,搜索左侧数字", nicknameX, nicknameY));
for (TextElement element : allElements) {
String text = element.getEffectiveText();
// 检查是否在昵称左侧
if (element.getCenterX() >= nicknameX) continue;
// 检查Y坐标是否相近(±50像素内)
int deltaY = Math.abs(element.getCenterY() - nicknameY);
if (deltaY > 50) continue;
// 检查是否为纯数字
if (text != null && text.trim().matches("\\d+")) {
String trimmed = text.trim();
try {
int number = Integer.parseInt(trimmed);
if (number >= 1 && number <= 999) {
Log.d(TAG, String.format("策略2:找到昵称左侧未读数 \"%s\" X=%d Y=%d",
text, element.getCenterX(), element.getCenterY()));
return trimmed;
}
} catch (NumberFormatException e) {
// 忽略
}
}
}
Log.d(TAG, "策略2:未找到昵称左侧未读数");
return null;
}
/**
* 策略3:检测文本形式的未读标识
*/
private String detectTextUnreadIndicator(List<TextElement> allElements) {
Log.d(TAG, "策略3:搜索文本形式未读标识");
for (TextElement element : allElements) {
String text = element.getEffectiveText();
if (text == null || text.trim().isEmpty()) continue;
String trimmed = text.trim().toLowerCase();
// 检查各种文本形式的未读标识
if (trimmed.equals("未读") || trimmed.equals("新消息") ||
trimmed.equals("新") || trimmed.equals("未读消息") ||
trimmed.equals("unread") || trimmed.equals("new") ||
trimmed.equals("new message") || trimmed.equals("message") ||
trimmed.equals("●") || trimmed.equals("•") || trimmed.equals("🔴")) {
// 确保在右侧位置(避免误判)
int relativeX = element.getCenterX() * 100 / screenWidth;
if (relativeX >= 70) {
Log.d(TAG, String.format("策略3:找到文本未读标识 \"%s\" X位置=%d%%",
text, relativeX));
return text.trim();
}
}
}
Log.d(TAG, "策略3:未找到文本形式未读标识");
return null;
}
/**
* 检测多个聊天项的未读消息
*/
public List<UnreadResult> detectMultipleUnreadMessages(List<AccessibilityNodeInfo> chatItems) {
List<UnreadResult> results = new ArrayList<>();
Log.d(TAG, "\n🔍 开始批量检测未读消息,共 " + chatItems.size() + " 个聊天项");
for (int i = 0; i < chatItems.size(); i++) {
AccessibilityNodeInfo chatItem = chatItems.get(i);
Log.d(TAG, "\n--- 检测第 " + (i + 1) + " 个聊天项 ---");
UnreadResult result = detectUnreadMessage(chatItem);
if (result != null) {
results.add(result);
}
}
Log.d(TAG, "\n📊 检测完成,发现 " + results.size() + " 个有未读消息的聊天项");
// 输出所有未读消息的用户坐标
if (!results.isEmpty()) {
Log.d(TAG, "\n📍 有未读消息的用户坐标列表:");
for (int i = 0; i < results.size(); i++) {
UnreadResult result = results.get(i);
Log.d(TAG, String.format("%d. %s - 点击坐标: %s",
i + 1, result.nickname, result.clickBounds.toString()));
}
}
return results;
}
}
去除杂质log的简化版本
UnreadMessageDetector.java
package com.example.demotest.unread;
import android.graphics.Rect;
import android.util.Log;
import android.view.accessibility.AccessibilityNodeInfo;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Pattern;
/**
* 未读消息检测器
* 分析聊天项的布局结构并检测未读消息
*/
public class UnreadMessageDetector {
private static final String TAG = "UnreadDetector";
// 屏幕宽度,用于计算相对位置
private int screenWidth;
/**
* 文本元素信息
*/
private static class TextElement {
String text;
String description;
Rect bounds;
AccessibilityNodeInfo node;
TextElement(String text, String description, Rect bounds, AccessibilityNodeInfo node) {
this.text = text;
this.description = description;
this.bounds = bounds;
this.node = node;
}
/**
* 获取有效文本内容
*/
String getEffectiveText() {
if (text != null && !text.trim().isEmpty()) {
return text.trim();
}
if (description != null && !description.trim().isEmpty()) {
return description.trim();
}
return "";
}
/**
* 获取X坐标中心点
*/
int getCenterX() {
return bounds.left + (bounds.width() / 2);
}
/**
* 获取Y坐标中心点
*/
int getCenterY() {
return bounds.top + (bounds.height() / 2);
}
}
/**
* 未读消息结果
*/
public static class UnreadResult {
public String nickname; // 昵称
public String lastMessage; // 最后消息
public String time; // 时间
public String unreadCount; // 未读数
public Rect clickBounds; // 可点击区域坐标
public AccessibilityNodeInfo clickableNode; // 可点击节点
@Override
public String toString() {
return String.format("未读消息 - 昵称:%s, 消息:%s, 时间:%s, 未读标识:%s, 坐标:%s",
nickname, lastMessage, time, unreadCount, clickBounds.toString());
}
}
public UnreadMessageDetector(int screenWidth) {
this.screenWidth = screenWidth;
}
/**
* 检测聊天项是否有未读消息
*/
public UnreadResult detectUnreadMessage(AccessibilityNodeInfo chatItemNode) {
try {
// 策略0:优先检查是否有集中式的contentDescription
UnreadResult contentDescResult = detectFromContentDescription(chatItemNode);
if (contentDescResult != null) {
return contentDescResult;
}
// 收集所有文本元素
List<TextElement> textElements = new ArrayList<>();
collectTextElements(chatItemNode, textElements);
if (textElements.isEmpty()) {
return null;
}
// 按Y坐标分层
LayerAnalysis layerAnalysis = analyzeLayersByY(textElements);
// 分析第一层元素(昵称、时间、火花)
FirstLayerElements firstLayer = analyzeFirstLayer(layerAnalysis.firstLayerElements, textElements);
// 分析第二层元素(内容、未读数)
SecondLayerElements secondLayer = analyzeSecondLayer(layerAnalysis.secondLayerElements);
// 四种策略检测未读消息(1-3为原有策略)
String unreadIndicator = detectUnreadIndicator(firstLayer, secondLayer, textElements);
// 检测是否有未读消息
if (unreadIndicator != null && !unreadIndicator.isEmpty()) {
UnreadResult result = new UnreadResult();
result.nickname = firstLayer.nickname;
result.lastMessage = secondLayer.content;
result.time = firstLayer.time;
result.unreadCount = unreadIndicator;
result.clickableNode = chatItemNode;
// 获取点击坐标
Rect bounds = new Rect();
chatItemNode.getBoundsInScreen(bounds);
result.clickBounds = bounds;
return result;
} else {
return null;
}
} catch (Exception e) {
Log.e(TAG, "检测未读消息时出错: " + e.getMessage(), e);
return null;
}
}
/**
* 策略0:从集中式contentDescription检测未读消息
* 适用于所有信息都集中在一个contentDescription中的情况
* 格式示例:"VSCode技术交流群, ,有164条未读,[有新文件]树木上的林: [图片]这个打不开谁能帮我下载一下里面的东西,15:12"
*/
private UnreadResult detectFromContentDescription(AccessibilityNodeInfo chatItemNode) {
try {
// 递归查找所有可能包含完整信息的contentDescription
return findContentDescriptionInTree(chatItemNode);
} catch (Exception e) {
Log.w(TAG, "策略0:解析contentDescription出错: " + e.getMessage());
return null;
}
}
/**
* 在节点树中递归查找包含完整聊天信息的contentDescription
*/
private UnreadResult findContentDescriptionInTree(AccessibilityNodeInfo node) {
if (node == null) return null;
try {
// 检查当前节点的contentDescription
String desc = node.getContentDescription() != null ?
node.getContentDescription().toString() : "";
if (!desc.trim().isEmpty()) {
// 解析contentDescription
UnreadResult result = parseContentDescription(desc, node);
if (result != null) {
return result;
}
}
// 递归检查子节点
for (int i = 0; i < node.getChildCount(); i++) {
AccessibilityNodeInfo child = node.getChild(i);
if (child != null) {
UnreadResult childResult = findContentDescriptionInTree(child);
if (childResult != null) {
return childResult;
}
}
}
} catch (Exception e) {
// 静默处理错误
}
return null;
}
/**
* 解析集中式contentDescription字符串
* 支持的格式:
* 1. "昵称, ,有X条未读,消息内容,时间"
* 2. "昵称, ,有X条未读,消息内容"
* 3. "昵称,消息内容,有X条未读,时间"
*/
private UnreadResult parseContentDescription(String desc, AccessibilityNodeInfo node) {
if (desc == null || desc.trim().isEmpty()) return null;
String trimmedDesc = desc.trim();
// 检查是否包含未读标识
if (!containsUnreadIndicator(trimmedDesc)) {
return null;
}
try {
// 按逗号分割描述字符串
String[] parts = trimmedDesc.split(",");
if (parts.length < 3) {
return null;
}
// 清理每个部分的空白字符
for (int i = 0; i < parts.length; i++) {
parts[i] = parts[i].trim();
}
// 解析各个部分
UnreadResult result = new UnreadResult();
result.clickableNode = node;
// 获取点击坐标
Rect bounds = new Rect();
node.getBoundsInScreen(bounds);
result.clickBounds = bounds;
// 提取信息
extractInfoFromParts(parts, result);
// 验证解析结果
if (isValidUnreadResult(result)) {
return result;
} else {
return null;
}
} catch (Exception e) {
return null;
}
}
/**
* 检查描述字符串是否包含未读标识
*/
private boolean containsUnreadIndicator(String desc) {
String lowerDesc = desc.toLowerCase();
return lowerDesc.contains("未读") ||
lowerDesc.contains("unread") ||
lowerDesc.matches(".*有\\d+条.*");
}
/**
* 从分割的部分中提取信息
*/
private void extractInfoFromParts(String[] parts, UnreadResult result) {
// 通常第一个部分是昵称(排除空字符串)
for (int i = 0; i < parts.length; i++) {
if (!parts[i].isEmpty() && result.nickname == null) {
result.nickname = parts[i];
break;
}
}
// 查找未读数信息
for (String part : parts) {
if (part.contains("未读") || part.contains("unread")) {
result.unreadCount = extractUnreadCount(part);
break;
}
}
// 查找时间信息(通常是最后一个非空部分,且符合时间格式)
for (int i = parts.length - 1; i >= 0; i--) {
if (!parts[i].isEmpty() && isTimePattern(parts[i])) {
result.time = parts[i];
break;
}
}
// 查找消息内容(排除昵称、未读数、时间后的其他内容)
StringBuilder messageBuilder = new StringBuilder();
for (String part : parts) {
if (!part.isEmpty() &&
!part.equals(result.nickname) &&
!part.contains("未读") &&
!part.contains("unread") &&
!isTimePattern(part)) {
if (messageBuilder.length() > 0) {
messageBuilder.append(",");
}
messageBuilder.append(part);
}
}
if (messageBuilder.length() > 0) {
result.lastMessage = messageBuilder.toString();
}
}
/**
* 从未读标识字符串中提取具体的未读数
*/
private String extractUnreadCount(String unreadText) {
if (unreadText == null) return null;
// 匹配 "有X条未读" 格式
java.util.regex.Pattern pattern = java.util.regex.Pattern.compile("有(\\d+)条");
java.util.regex.Matcher matcher = pattern.matcher(unreadText);
if (matcher.find()) {
return matcher.group(1);
}
// 匹配其他数字格式
pattern = java.util.regex.Pattern.compile("(\\d+)");
matcher = pattern.matcher(unreadText);
if (matcher.find()) {
return matcher.group(1);
}
// 如果没有具体数字,返回原始文本
return unreadText;
}
/**
* 验证解析结果是否有效
*/
private boolean isValidUnreadResult(UnreadResult result) {
return result != null &&
result.nickname != null && !result.nickname.trim().isEmpty() &&
result.unreadCount != null && !result.unreadCount.trim().isEmpty();
}
/**
* 收集所有文本元素
*/
private void collectTextElements(AccessibilityNodeInfo node, List<TextElement> elements) {
if (node == null || !node.isVisibleToUser()) return;
try {
// 检查当前节点是否有文本内容
String text = node.getText() != null ? node.getText().toString() : "";
String desc = node.getContentDescription() != null ? node.getContentDescription().toString() : "";
if (!text.trim().isEmpty() || !desc.trim().isEmpty()) {
Rect bounds = new Rect();
node.getBoundsInScreen(bounds);
TextElement element = new TextElement(text, desc, bounds, node);
elements.add(element);
}
// 递归处理子节点
for (int i = 0; i < node.getChildCount(); i++) {
AccessibilityNodeInfo child = node.getChild(i);
if (child != null) {
collectTextElements(child, elements);
}
}
} catch (Exception e) {
// 静默处理错误
}
}
/**
* 层级分析结果
*/
private static class LayerAnalysis {
List<TextElement> firstLayerElements = new ArrayList<>();
List<TextElement> secondLayerElements = new ArrayList<>();
int layerThreshold; // Y坐标分层阈值
}
/**
* 按Y坐标分层分析
*/
private LayerAnalysis analyzeLayersByY(List<TextElement> elements) {
LayerAnalysis analysis = new LayerAnalysis();
if (elements.isEmpty()) return analysis;
// 找到最小和最大Y坐标
int minY = Integer.MAX_VALUE;
int maxY = Integer.MIN_VALUE;
for (TextElement element : elements) {
int centerY = element.getCenterY();
minY = Math.min(minY, centerY);
maxY = Math.max(maxY, centerY);
}
// 计算分层阈值(约在中间位置)
analysis.layerThreshold = minY + (maxY - minY) / 2;
// 分层分配元素
for (TextElement element : elements) {
if (element.getCenterY() <= analysis.layerThreshold) {
analysis.firstLayerElements.add(element);
} else {
analysis.secondLayerElements.add(element);
}
}
return analysis;
}
/**
* 第一层元素分析结果
*/
private static class FirstLayerElements {
String nickname; // 昵称
String time; // 时间
String sparkCount; // 火花数字
TextElement nicknameElement; // 昵称元素(用于检测左侧未读数)
}
/**
* 分析第一层元素(昵称、时间、火花)
*/
private FirstLayerElements analyzeFirstLayer(List<TextElement> elements, List<TextElement> allElements) {
FirstLayerElements result = new FirstLayerElements();
if (elements.isEmpty()) return result;
// 按X坐标排序
elements.sort((a, b) -> Integer.compare(a.getCenterX(), b.getCenterX()));
// 找到内容区域中Y坐标最小的元素作为昵称
TextElement nicknameElement = null;
int minY = Integer.MAX_VALUE;
for (TextElement element : elements) {
String text = element.getEffectiveText();
int relativeX = element.getCenterX() * 100 / screenWidth; // 转换为相对位置百分比
if (isTimePattern(text)) {
// 时间通常在右侧
result.time = text;
} else if (isSparkNumber(text, element)) {
// 火花数字通常在中间,且前面有ImageView
result.sparkCount = text;
} else if (relativeX >= 30) {
// 昵称应该在内容区域中(X >= 30%),在此区域中找Y坐标最小的
int elementY = element.getCenterY();
if (elementY < minY) {
minY = elementY;
nicknameElement = element;
result.nickname = text;
}
}
}
if (nicknameElement != null) {
result.nicknameElement = nicknameElement;
}
return result;
}
/**
* 第二层元素分析结果
*/
private static class SecondLayerElements {
String content; // 消息内容
String unreadCount; // 未读数
}
/**
* 分析第二层元素(内容、未读数)
*/
private SecondLayerElements analyzeSecondLayer(List<TextElement> elements) {
SecondLayerElements result = new SecondLayerElements();
if (elements.isEmpty()) return result;
// 按X坐标排序
elements.sort((a, b) -> Integer.compare(a.getCenterX(), b.getCenterX()));
for (TextElement element : elements) {
String text = element.getEffectiveText();
int relativeX = element.getCenterX() * 100 / screenWidth; // 转换为相对位置百分比
if (isUnreadNumber(text, relativeX)) {
// 未读数:纯数字 + 在右侧位置
result.unreadCount = text;
} else if (relativeX < 80) {
// 消息内容通常在左侧或中间
if (result.content == null || result.content.isEmpty()) {
result.content = text;
} else {
result.content += " " + text; // 拼接多个内容元素
}
}
}
return result;
}
/**
* 判断是否为时间模式
*/
private boolean isTimePattern(String text) {
if (text == null || text.trim().isEmpty()) return false;
String lowerText = text.toLowerCase().trim();
return lowerText.contains("分钟前") ||
lowerText.contains("小时前") ||
lowerText.contains("天前") ||
lowerText.contains("昨天") ||
lowerText.contains("前天") ||
lowerText.contains("今天") ||
lowerText.contains("刚刚") ||
lowerText.matches(".*\\d+:\\d+.*") ||
lowerText.matches(".*\\d{1,2}月\\d{1,2}日.*") ||
lowerText.contains("周一") || lowerText.contains("周二") ||
lowerText.contains("周三") || lowerText.contains("周四") ||
lowerText.contains("周五") || lowerText.contains("周六") ||
lowerText.contains("周日") || lowerText.contains("星期一") ||
lowerText.contains("星期二") || lowerText.contains("星期三") ||
lowerText.contains("星期四") || lowerText.contains("星期五") ||
lowerText.contains("星期六") || lowerText.contains("星期日");
}
/**
* 判断是否为火花数字
* 特征:数字 + 前后有空格 + 可能有前置ImageView
*/
private boolean isSparkNumber(String text, TextElement element) {
if (text == null || text.trim().isEmpty()) return false;
// 检查是否为纯数字(可能有空格)
String trimmed = text.trim();
if (!Pattern.matches("\\d+", trimmed)) return false;
// 检查是否有前后空格(火花数字的特征)
if (text.startsWith(" ") || text.endsWith(" ")) {
return true;
}
// 检查X坐标是否在中间区域(30%-70%)
int relativeX = element.getCenterX() * 100 / screenWidth;
if (relativeX >= 30 && relativeX <= 70) {
return true;
}
return false;
}
/**
* 判断是否为未读标识(数字或文字)
* 特征:数字未读数 或 文字未读标识 + 在右侧位置
*/
private boolean isUnreadNumber(String text, int relativeX) {
if (text == null || text.trim().isEmpty()) return false;
String trimmed = text.trim();
// 必须在右侧位置(75%以后,稍微放宽一点)
if (relativeX < 75) return false;
// 检查是否为数字类型的未读数
if (isNumericUnread(trimmed, text)) {
return true;
}
// 检查是否为文字类型的未读标识
if (isTextUnread(trimmed)) {
return true;
}
return false;
}
/**
* 判断是否为数字类型的未读数
*/
private boolean isNumericUnread(String trimmed, String originalText) {
// 必须是纯数字
if (!Pattern.matches("\\d+", trimmed)) return false;
// 未读数通常是1-999的范围
try {
int number = Integer.parseInt(trimmed);
if (number < 1 || number > 999) return false;
} catch (NumberFormatException e) {
return false;
}
// 不应该有前后空格(区别于火花数字)
if (originalText.startsWith(" ") || originalText.endsWith(" ")) return false;
return true;
}
/**
* 判断是否为文字类型的未读标识
*/
private boolean isTextUnread(String text) {
if (text == null || text.trim().isEmpty()) return false;
String lowerText = text.toLowerCase().trim();
// 中文未读标识
if (lowerText.equals("未读") || lowerText.equals("新消息") ||
lowerText.equals("新") || lowerText.equals("未读消息")) {
return true;
}
// 英文未读标识
if (lowerText.equals("unread") || lowerText.equals("new") ||
lowerText.equals("!") || lowerText.equals("new message") ||
lowerText.equals("message") || lowerText.equals("msg")) {
return true;
}
// 其他可能的标识
if (lowerText.equals("●") || lowerText.equals("•") ||
lowerText.equals("🔴") || lowerText.equals("红点")) {
return true;
}
return false;
}
/**
* 分散元素策略检测未读消息(策略1-3)
* 策略1:右侧区域未读数:在消息内容右边的数字/文本标识
* 策略2:昵称左侧未读数:在昵称左边的数字角标
* 策略3:文本形式未读标识:如"未读"、"new"等文字
* 注:策略0(集中式contentDescription)已在主检测方法中优先执行
*/
private String detectUnreadIndicator(FirstLayerElements firstLayer, SecondLayerElements secondLayer, List<TextElement> allElements) {
// 策略1:传统的右侧区域未读数
if (secondLayer.unreadCount != null && !secondLayer.unreadCount.isEmpty()) {
return secondLayer.unreadCount;
}
// 策略2:昵称左侧未读数(头像右上角)
String nicknameLeftUnread = detectNicknameLeftUnread(firstLayer, allElements);
if (nicknameLeftUnread != null && !nicknameLeftUnread.isEmpty()) {
return nicknameLeftUnread;
}
// 策略3:文本形式未读标识
String textUnreadIndicator = detectTextUnreadIndicator(allElements);
if (textUnreadIndicator != null && !textUnreadIndicator.isEmpty()) {
return textUnreadIndicator;
}
return null;
}
/**
* 策略2:检测昵称左侧的未读数(头像右上角)
*/
private String detectNicknameLeftUnread(FirstLayerElements firstLayer, List<TextElement> allElements) {
if (firstLayer.nicknameElement == null) {
return null;
}
TextElement nicknameElement = firstLayer.nicknameElement;
int nicknameX = nicknameElement.getCenterX();
int nicknameY = nicknameElement.getCenterY();
for (TextElement element : allElements) {
String text = element.getEffectiveText();
// 检查是否在昵称左侧
if (element.getCenterX() >= nicknameX) continue;
// 检查Y坐标是否相近(±50像素内)
int deltaY = Math.abs(element.getCenterY() - nicknameY);
if (deltaY > 50) continue;
// 检查是否为纯数字
if (text != null && text.trim().matches("\\d+")) {
String trimmed = text.trim();
try {
int number = Integer.parseInt(trimmed);
if (number >= 1 && number <= 999) {
return trimmed;
}
} catch (NumberFormatException e) {
// 忽略
}
}
}
return null;
}
/**
* 策略3:检测文本形式的未读标识
*/
private String detectTextUnreadIndicator(List<TextElement> allElements) {
for (TextElement element : allElements) {
String text = element.getEffectiveText();
if (text == null || text.trim().isEmpty()) continue;
String trimmed = text.trim().toLowerCase();
// 检查各种文本形式的未读标识
if (trimmed.equals("未读") || trimmed.equals("新消息") ||
trimmed.equals("新") || trimmed.equals("未读消息") ||
trimmed.equals("unread") || trimmed.equals("new") ||
trimmed.equals("new message") || trimmed.equals("message") ||
trimmed.equals("●") || trimmed.equals("•") || trimmed.equals("🔴")) {
// 确保在右侧位置(避免误判)
int relativeX = element.getCenterX() * 100 / screenWidth;
if (relativeX >= 70) {
return text.trim();
}
}
}
return null;
}
/**
* 检测多个聊天项的未读消息
*/
public List<UnreadResult> detectMultipleUnreadMessages(List<AccessibilityNodeInfo> chatItems) {
List<UnreadResult> results = new ArrayList<>();
for (int i = 0; i < chatItems.size(); i++) {
AccessibilityNodeInfo chatItem = chatItems.get(i);
UnreadResult result = detectUnreadMessage(chatItem);
if (result != null) {
results.add(result);
}
}
return results;
}
}
UnreadMessageAnalyzer.java
package com.example.demotest.unread;
import android.accessibilityservice.AccessibilityService;
import android.content.res.Resources;
import android.graphics.Rect;
import android.util.DisplayMetrics;
import android.util.Log;
import android.view.accessibility.AccessibilityNodeInfo;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
/**
* 无障碍树打印器 - 打印界面视图树
*/
public class UnreadMessageAnalyzer {
private static final String TAG = "UnreadAnalysis";
private AccessibilityService accessibilityService;
private int screenWidth;
private int screenHeight;
public UnreadMessageAnalyzer(AccessibilityService service) {
this.accessibilityService = service;
DisplayMetrics metrics = Resources.getSystem().getDisplayMetrics();
this.screenWidth = metrics.widthPixels;
this.screenHeight = metrics.heightPixels;
}
/**
* 打印界面视图树和处理过的视图树
*/
public void printAccessibilityTrees() {
AccessibilityNodeInfo rootNode = accessibilityService.getRootInActiveWindow();
if (rootNode == null) {
Log.e(TAG, "无法获取当前窗口信息");
return;
}
try {
// 打印处理过的视图树(聊天项目)
printProcessedViewTree(rootNode);
} catch (Exception e) {
Log.e(TAG, "打印无障碍树时出错: " + e.getMessage(), e);
} finally {
rootNode.recycle();
}
}
/**
* 打印处理过的视图树 - 基于时间节点回溯的聊天项目
*/
private void printProcessedViewTree(AccessibilityNodeInfo rootNode) {
try {
// 第一步:收集所有时间节点
List<AccessibilityNodeInfo> timeNodes = new ArrayList<>();
collectTimeNodes(rootNode, timeNodes);
// 第二步:对每个时间节点进行回溯,找到可点击父级
Set<AccessibilityNodeInfo> processedParents = new HashSet<>();
List<AccessibilityNodeInfo> chatItems = new ArrayList<>();
for (AccessibilityNodeInfo timeNode : timeNodes) {
AccessibilityNodeInfo clickableParent = findNearestClickableParent(timeNode);
if (clickableParent != null && !processedParents.contains(clickableParent)) {
// 添加到聊天项目列表中
chatItems.add(clickableParent);
// 标记为已处理,避免重复
processedParents.add(clickableParent);
}
}
if (!processedParents.isEmpty()) {
// 使用未读消息检测器分析所有聊天项
analyzeUnreadMessages(chatItems);
}
} catch (Exception e) {
Log.w(TAG, "打印处理过的视图树时出错: " + e.getMessage());
}
}
/**
* 收集所有包含时间信息的节点
*/
private void collectTimeNodes(AccessibilityNodeInfo node, List<AccessibilityNodeInfo> timeNodes) {
if (node == null || !node.isVisibleToUser()) return;
try {
// 检查当前节点是否包含时间信息
String nodeText = getNodeText(node);
if (isTimePattern(nodeText)) {
timeNodes.add(node);
}
// 递归检查所有子节点
for (int i = 0; i < node.getChildCount(); i++) {
AccessibilityNodeInfo child = node.getChild(i);
if (child != null) {
collectTimeNodes(child, timeNodes);
}
}
} catch (Exception e) {
// 静默处理错误
}
}
/**
* 向上回溯找到最近的可点击父级
*/
private AccessibilityNodeInfo findNearestClickableParent(AccessibilityNodeInfo timeNode) {
if (timeNode == null) return null;
try {
AccessibilityNodeInfo current = timeNode;
// 向上遍历父级节点,找到第一个可点击的父级
while (current != null) {
AccessibilityNodeInfo parent = current.getParent();
if (parent == null) break;
// 检查父级是否满足可点击条件
if (isClickableParent(parent)) {
return parent;
}
current = parent;
}
return null;
} catch (Exception e) {
Log.w(TAG, "查找可点击父级时出错: " + e.getMessage());
return null;
}
}
/**
* 检查节点是否满足可点击父级条件
*/
private boolean isClickableParent(AccessibilityNodeInfo node) {
if (node == null || !node.isVisibleToUser()) return false;
// 满足条件:
// 1. {clickable, long-clickable} 或 {clickable, long-clickable, visible}
// 2. {clickable, visible}
return node.isClickable() && (node.isLongClickable() || node.isVisibleToUser());
}
/**
* 获取节点的文本内容
*/
private String getNodeText(AccessibilityNodeInfo node) {
StringBuilder text = new StringBuilder();
if (node.getText() != null) {
text.append(node.getText().toString()).append(" ");
}
if (node.getContentDescription() != null) {
text.append(node.getContentDescription().toString()).append(" ");
}
return text.toString();
}
/**
* 判断文本是否包含时间模式
*/
private boolean isTimePattern(String text) {
if (text == null || text.trim().isEmpty()) {
return false;
}
String lowerText = text.toLowerCase().trim();
// 检查常见的时间模式
return lowerText.contains("分钟前") ||
lowerText.contains("小时前") ||
lowerText.contains("天前") ||
lowerText.contains("昨天") ||
lowerText.contains("前天") ||
lowerText.contains("今天") ||
lowerText.contains("刚刚") ||
lowerText.matches(".*\\d+:\\d+.*") || // HH:MM 格式
lowerText.matches(".*\\d{1,2}月\\d{1,2}日.*") || // MM月DD日 格式
lowerText.matches(".*\\d{4}/\\d{1,2}/\\d{1,2}.*") || // YYYY/MM/DD 格式
lowerText.matches(".*\\d{4}-\\d{1,2}-\\d{1,2}.*") || // YYYY-MM-DD 格式
lowerText.contains("周一") ||
lowerText.contains("周二") ||
lowerText.contains("周三") ||
lowerText.contains("周四") ||
lowerText.contains("周五") ||
lowerText.contains("周六") ||
lowerText.contains("周日") ||
lowerText.contains("星期一") ||
lowerText.contains("星期二") ||
lowerText.contains("星期三") ||
lowerText.contains("星期四") ||
lowerText.contains("星期五") ||
lowerText.contains("星期六") ||
lowerText.contains("星期日");
}
/**
* 分析聊天项的未读消息
*/
private void analyzeUnreadMessages(List<AccessibilityNodeInfo> chatItems) {
try {
// 创建未读消息检测器
UnreadMessageDetector detector = new UnreadMessageDetector(screenWidth);
// 检测所有聊天项的未读消息
List<UnreadMessageDetector.UnreadResult> unreadResults =
detector.detectMultipleUnreadMessages(chatItems);
// 输出分析结果
if (unreadResults.isEmpty()) {
Log.d(TAG, "🟢 当前页面没有发现未读消息");
} else {
Log.d(TAG, "🔴 发现 " + unreadResults.size() + " 个有未读消息的聊天项:");
for (int i = 0; i < unreadResults.size(); i++) {
UnreadMessageDetector.UnreadResult result = unreadResults.get(i);
Log.d(TAG, String.format("📱 第%d个未读消息:", i + 1));
Log.d(TAG, " 👤 昵称: " + (result.nickname != null ? result.nickname : "未知"));
Log.d(TAG, " 💬 消息: " + (result.lastMessage != null ? result.lastMessage : "无"));
Log.d(TAG, " ⏰ 时间: " + (result.time != null ? result.time : "未知"));
Log.d(TAG, " 🔴 未读标识: " + result.unreadCount);
Log.d(TAG, " 📍 点击坐标: " + result.clickBounds.toString());
Log.d(TAG, " 📱 坐标中心: (" + result.clickBounds.centerX() + ", " + result.clickBounds.centerY() + ")");
}
// 输出可直接使用的坐标列表
Log.d(TAG, "📍 未读消息用户点击坐标汇总:");
for (int i = 0; i < unreadResults.size(); i++) {
UnreadMessageDetector.UnreadResult result = unreadResults.get(i);
Log.d(TAG, String.format("用户%d [%s] 未读标识[%s] → 点击坐标(%d, %d)",
i + 1,
result.nickname != null ? result.nickname : "未知用户",
result.unreadCount,
result.clickBounds.centerX(),
result.clickBounds.centerY()));
}
}
} catch (Exception e) {
Log.e(TAG, "分析未读消息时出错: " + e.getMessage(), e);
}
}
}