安卓基礎(語義樹)

進化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);}}} 

本文來自互聯網用戶投稿,該文觀點僅代表作者本人,不代表本站立場。本站僅提供信息存儲空間服務,不擁有所有權,不承擔相關法律責任。
如若轉載,請注明出處:http://www.pswp.cn/pingmian/84353.shtml
繁體地址,請注明出處:http://hk.pswp.cn/pingmian/84353.shtml
英文地址,請注明出處:http://en.pswp.cn/pingmian/84353.shtml

如若內容造成侵權/違法違規/事實不符,請聯系多彩編程網進行投訴反饋email:809451989@qq.com,一經查實,立即刪除!

相關文章

Linux基礎開發工具——vim工具

文章目錄 vim工具什么是vimvim的多模式和使用vim的基礎模式vim的三種基礎模式三種模式的初步了解 常用模式的詳細講解插入模式命令模式模式轉化光標的移動文本的編輯 底行模式替換模式視圖模式總結 使用vim的小技巧vim的配置(了解) vim工具 本文章仍然是繼續講解Linux系統下的…

C++_核心編程_多態案例二-制作飲品

#include <iostream> #include <string> using namespace std;/*制作飲品的大致流程為&#xff1a;煮水 - 沖泡 - 倒入杯中 - 加入輔料 利用多態技術實現本案例&#xff0c;提供抽象制作飲品基類&#xff0c;提供子類制作咖啡和茶葉*//*基類*/ class AbstractDr…

AcWing--數據結構1

用數組來模擬鏈表。這種實現鏈表的方式也叫靜態鏈表。 1.單鏈表 寫鄰接表&#xff1a;存儲圖和樹 我們定義&#xff1a;e[N]用來表示某個點的值是多少&#xff1b;ne[N]用來表示某個點的next指針是多少 e和ne是用下標關聯起來的 如&#xff1a;head->3->5->7->…

云啟出海,智聯未來|阿里云網絡「企業出海」系列客戶沙龍上海站圓滿落地

借阿里云中企出海大會的東風&#xff0c;以**「云啟出海&#xff0c;智聯未來&#xff5c;打造安全可靠的出海云網絡引擎」為主題的阿里云企業出海客戶沙龍云網絡&安全專場于5.28日下午在上海順利舉辦&#xff0c;現場吸引了來自攜程、小紅書、米哈游、嗶哩嗶哩、波克城市、…

多模態分類案例實現

以下是基于飛槳平臺實現的多模態分類詳細案例&#xff0c;結合圖像和文本信息進行分類任務。案例包含數據處理、模型構建、訓練和評估的完整流程&#xff0c;并提供詳細注釋&#xff1a; 一、多模態分類案例實現 import os import json import numpy as np from PIL import I…

Express框架:Node.js的輕量級Web應用利器

Hi,我是布蘭妮甜 !在當今快速發展的Web開發領域,Node.js已成為構建高性能、可擴展網絡應用的重要基石。而在這片肥沃的生態系統中,Express框架猶如一座經久不衰的燈塔,指引著無數開發者高效構建Web應用的方向。本文章在為讀者提供一份全面而深入的Express框架指南。無論您…

K-Means顏色變卦和漸變色

一、理論深度提升&#xff1a;補充算法細節與數學基礎 1. K-Means 算法核心公式&#xff08;增強專業性&#xff09; 在 “原理步驟” 中加入數學表達式&#xff0c;說明聚類目標&#xff1a; K-Means 的目標是最小化簇內平方和&#xff08;Within-Cluster Sum of Squares, W…

深入解析C#表達式求值:優先級、結合性與括號的魔法

—— 為什么2/6*4不等于1/12&#xff1f; &#x1f50d; 一、表達式求值順序為何重要&#xff1f; 表達式如精密儀器&#xff0c;子表達式求值順序直接決定結果。例如&#xff1a; int result 3 * 5 2;若先算乘法&#xff1a;(3*5)2 17 ?若先算加法&#xff1a;3*(52)21…

Docker 離線安裝指南

參考文章 1、確認操作系統類型及內核版本 Docker依賴于Linux內核的一些特性&#xff0c;不同版本的Docker對內核版本有不同要求。例如&#xff0c;Docker 17.06及之后的版本通常需要Linux內核3.10及以上版本&#xff0c;Docker17.09及更高版本對應Linux內核4.9.x及更高版本。…

Spring——Spring相關類原理與實戰

摘要 本文深入探討了 Spring 框架中 InitializingBean 接口的原理與實戰應用&#xff0c;該接口是 Spring 提供的一個生命周期接口&#xff0c;用于在 Bean 屬性注入完成后執行初始化邏輯。文章詳細介紹了接口定義、作用、典型使用場景&#xff0c;并與其他相關概念如 PostCon…

Angular微前端架構:Module Federation + ngx-build-plus (Webpack)

以下是一個完整的 Angular 微前端示例&#xff0c;其中使用的是 Module Federation 和 npx-build-plus 實現了主應用&#xff08;Shell&#xff09;與子應用&#xff08;Remote&#xff09;的集成。 &#x1f6e0;? 項目結構 angular-mf/ ├── shell-app/ # 主應用&…

ESP32 I2S音頻總線學習筆記(四): INMP441采集音頻并實時播放

簡介 前面兩期文章我們介紹了I2S的讀取和寫入&#xff0c;一個是通過INMP441麥克風模塊采集音頻&#xff0c;一個是通過PCM5102A模塊播放音頻&#xff0c;那如果我們將兩者結合起來&#xff0c;將麥克風采集到的音頻通過PCM5102A播放&#xff0c;是不是就可以做一個擴音器了呢…

馮諾依曼架構是什么?

馮諾依曼架構是什么&#xff1f; 馮諾依曼架構&#xff08;Von Neumann Architecture&#xff09;是現代計算機的基礎設計框架&#xff0c;由數學家約翰馮諾依曼&#xff08;John von Neumann&#xff09;及其團隊在1945年提出。其核心思想是通過統一存儲程序與數據&#xff0…

【持續更新】linux網絡編程試題

問題1 請簡要說明TCP/IP協議棧的四層結構&#xff0c;并分別舉出每一層出現的典型協議或應用。 答案 應用層&#xff1a;ping,telnet,dns 傳輸層&#xff1a;tcp,udp 網絡層&#xff1a;ip,icmp 數據鏈路層&#xff1a;arp,rarp 問題2 下列協議或應用分別屬于TCP/IP協議…

橢圓曲線密碼學(ECC)

一、ECC算法概述 橢圓曲線密碼學&#xff08;Elliptic Curve Cryptography&#xff09;是基于橢圓曲線數學理論的公鑰密碼系統&#xff0c;由Neal Koblitz和Victor Miller在1985年獨立提出。相比RSA&#xff0c;ECC在相同安全強度下密鑰更短&#xff08;256位ECC ≈ 3072位RSA…

【JVM】- 內存結構

引言 JVM&#xff1a;Java Virtual Machine 定義&#xff1a;Java虛擬機&#xff0c;Java二進制字節碼的運行環境好處&#xff1a; 一次編寫&#xff0c;到處運行自動內存管理&#xff0c;垃圾回收的功能數組下標越界檢查&#xff08;會拋異常&#xff0c;不會覆蓋到其他代碼…

React 基礎入門筆記

一、JSX語法規則 1. 定義虛擬DOM時&#xff0c;不要寫引號 2.標簽中混入JS表達式時要用 {} &#xff08;1&#xff09;.JS表達式與JS語句&#xff08;代碼&#xff09;的區別 &#xff08;2&#xff09;.使用案例 3.樣式的類名指定不要用class&#xff0c;要用className 4.內…

Linux鏈表操作全解析

Linux C語言鏈表深度解析與實戰技巧 一、鏈表基礎概念與內核鏈表優勢1.1 為什么使用鏈表&#xff1f;1.2 Linux 內核鏈表與用戶態鏈表的區別 二、內核鏈表結構與宏解析常用宏/函數 三、內核鏈表的優點四、用戶態鏈表示例五、雙向循環鏈表在內核中的實現優勢5.1 插入效率5.2 安全…

SQL進階之旅 Day 19:統計信息與優化器提示

【SQL進階之旅 Day 19】統計信息與優化器提示 文章簡述 在數據庫性能調優中&#xff0c;統計信息和優化器提示是兩個至關重要的工具。統計信息幫助數據庫優化器評估查詢成本并選擇最佳執行計劃&#xff0c;而優化器提示則允許開發人員對優化器的行為進行微調。本文深入探討了…

安寶特方案丨船舶智造AR+AI+作業標準化管理系統解決方案(維保)

船舶維保管理現狀&#xff1a;設備維保主要由維修人員負責&#xff0c;根據設備運行狀況和維護計劃進行定期保養和故障維修。維修人員憑借經驗判斷設備故障原因&#xff0c;制定維修方案。 一、痛點與需求 1 Arbigtec 人工經驗限制維修效率&#xff1a; 復雜設備故障的診斷和…