進化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; // 可點擊節點@Overridepublic 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:優先檢查是否有集中式的contentDescriptionUnreadResult 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 {// 遞歸查找所有可能包含完整信息的contentDescriptionreturn 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 {// 檢查當前節點的contentDescriptionString desc = node.getContentDescription() != null ? node.getContentDescription().toString() : "";if (!desc.trim().isEmpty()) {Log.d(TAG, "策略0:檢查contentDescription: " + desc);// 解析contentDescriptionUnreadResult 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)) {// 火花數字通常在中間,且前面有ImageViewresult.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; // 可點擊節點@Overridepublic 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:優先檢查是否有集中式的contentDescriptionUnreadResult 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 {// 遞歸查找所有可能包含完整信息的contentDescriptionreturn 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 {// 檢查當前節點的contentDescriptionString desc = node.getContentDescription() != null ? node.getContentDescription().toString() : "";if (!desc.trim().isEmpty()) {// 解析contentDescriptionUnreadResult 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)) {// 火花數字通常在中間,且前面有ImageViewresult.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);}}}