Skip to main content
Glama
ViewHierarchy.ts63.3 kB
import fs from "fs-extra"; import path from "path"; import xml2js from "xml2js"; import { AdbUtils } from "../../utils/android-cmdline-tools/adb"; import { logger } from "../../utils/logger"; import { CryptoUtils } from "../../utils/crypto"; import { BootedDevice, ViewHierarchyCache } from "../../models"; import { Element } from "../../models"; import { ViewHierarchyResult } from "../../models"; import { TakeScreenshot } from "./TakeScreenshot"; import { ElementUtils } from "../utility/ElementUtils"; import { readdirAsync, readFileAsync, statAsync, writeFileAsync } from "../../utils/io"; import { ScreenshotUtils } from "../../utils/screenshot-utils"; import { DEFAULT_FUZZY_MATCH_TOLERANCE_PERCENT } from "../../utils/constants"; import { SourceMapper } from "../../utils/sourceMapper"; import { ActivityInfo, FragmentInfo, ViewInfo, ComposableInfo, ViewHierarchyQueryOptions } from "../../models"; import { AccessibilityServiceClient } from "./AccessibilityServiceClient"; import { WebDriverAgent } from "../../utils/ios-cmdline-tools/webdriver"; /** * Interface for activity top data */ interface ActivityTopData { classOverrides: Map<string, string>; fragmentData: Map<string, string>; viewData: Map<string, string>; } /** * Interface for element bounds */ interface ElementBounds { left: number; top: number; right: number; bottom: number; } /** * Interface for element with Z-order information */ interface ElementWithZOrder { element: any; bounds: ElementBounds; zOrder: number; isClickable: boolean; } /** * Extended ViewHierarchyResult with source indexing information */ interface ExtendedViewHierarchyResult extends ViewHierarchyResult { sourceInfo?: { activity?: ActivityInfo; fragments?: FragmentInfo[]; views?: ViewInfo[]; composables?: ComposableInfo[]; appId?: string; }; } export class ViewHierarchy { private device: BootedDevice; private readonly adb: AdbUtils; private readonly webdriver: WebDriverAgent; private takeScreenshot: TakeScreenshot; private elementUtils: ElementUtils; private sourceMapper: SourceMapper; private accessibilityServiceClient: AccessibilityServiceClient; private static viewHierarchyCache: Map<string, ViewHierarchyCache> = new Map(); private static cacheDir: string = path.join("/tmp/auto-mobile", "view_hierarchy"); private static screenshotCacheDir: string = path.join("/tmp/auto-mobile", "screenshots"); private static readonly MAX_CACHE_SIZE_BYTES = 128 * 1024 * 1024; // 128MB private static readonly CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes /** * Create a ViewHierarchy instance * @param device - Optional device * @param adb - Optional AdbUtils instance for testing * @param webdriver - Optional IdbPython instance for testing * @param takeScreenshot - Optional TakeScreenshot instance for testing * @param accessibilityServiceClient - Optional AccessibilityServiceClient instance for testing */ constructor( device: BootedDevice, adb: AdbUtils | null = null, webdriver: WebDriverAgent | null = null, takeScreenshot: TakeScreenshot | null = null, accessibilityServiceClient: AccessibilityServiceClient | null = null, ) { this.device = device; this.adb = adb || new AdbUtils(device); this.webdriver = webdriver || new WebDriverAgent(device); this.takeScreenshot = takeScreenshot || new TakeScreenshot(device, this.adb); this.elementUtils = new ElementUtils(); this.sourceMapper = SourceMapper.getInstance(); this.accessibilityServiceClient = accessibilityServiceClient || new AccessibilityServiceClient(device, this.adb); // Ensure cache directories exist if (!fs.existsSync(ViewHierarchy.cacheDir)) { fs.mkdirSync(ViewHierarchy.cacheDir, { recursive: true }); } if (!fs.existsSync(ViewHierarchy.screenshotCacheDir)) { fs.mkdirSync(ViewHierarchy.screenshotCacheDir, { recursive: true }); } } /** * Parse bounds string to ElementBounds object * @param boundsStr - Bounds string in format "[left,top][right,bottom]" * @returns ElementBounds object or null if invalid */ private parseBounds(boundsStr: string): ElementBounds | null { if (!boundsStr) {return null;} const match = boundsStr.match(/\[(\d+),(\d+)\]\[(\d+),(\d+)\]/); if (!match) {return null;} return { left: parseInt(match[1], 10), top: parseInt(match[2], 10), right: parseInt(match[3], 10), bottom: parseInt(match[4], 10) }; } /** * Calculate the intersection area of two rectangles * @param rect1 - First rectangle * @param rect2 - Second rectangle * @returns Intersection area or 0 if no intersection */ private calculateIntersectionArea(rect1: ElementBounds, rect2: ElementBounds): number { const left = Math.max(rect1.left, rect2.left); const top = Math.max(rect1.top, rect2.top); const right = Math.min(rect1.right, rect2.right); const bottom = Math.min(rect1.bottom, rect2.bottom); if (left >= right || top >= bottom) { return 0; // No intersection } return (right - left) * (bottom - top); } /** * Calculate the total area of a rectangle * @param bounds - Rectangle bounds * @returns Total area */ private calculateArea(bounds: ElementBounds): number { return (bounds.right - bounds.left) * (bounds.bottom - bounds.top); } /** * Collect all elements with their Z-order information from the view hierarchy * @param node - Root node to start traversal * @param zOrder - Current Z-order (depth-first traversal order) * @param result - Array to collect elements */ private collectElementsWithZOrder(node: any, zOrder: { value: number }, result: ElementWithZOrder[]): void { if (!node) {return;} const bounds = this.parseBounds(node.bounds); if (bounds) { const isClickable = node.clickable === "true" || node.clickable === true; result.push({ element: node, bounds, zOrder: zOrder.value++, isClickable }); } // Process children (later children have higher Z-order) if (node.node) { const children = Array.isArray(node.node) ? node.node : [node.node]; for (const child of children) { this.collectElementsWithZOrder(child, zOrder, result); } } } /** * Calculate accessibility percentage for a clickable element * @param targetElement - The clickable element to analyze * @param allElements - All elements in the hierarchy with Z-order * @returns Accessibility percentage (0.0 to 1.0) */ private calculateAccessibility(targetElement: ElementWithZOrder, allElements: ElementWithZOrder[]): number { const totalArea = this.calculateArea(targetElement.bounds); if (totalArea === 0) {return 0;} let coveredArea = 0; // Find all elements that are above this element in Z-order and intersect with it for (const otherElement of allElements) { // Skip if it's the same element or if the other element is below in Z-order if (otherElement === targetElement || otherElement.zOrder <= targetElement.zOrder) { continue; } // Calculate intersection area const intersectionArea = this.calculateIntersectionArea(targetElement.bounds, otherElement.bounds); coveredArea += intersectionArea; } // Calculate accessible percentage (ensure it doesn't exceed 100% due to floating point errors) const accessibleArea = Math.max(0, totalArea - coveredArea); const accessibilityPercentage = Math.min(1.0, accessibleArea / totalArea); // Round to 3 decimal places return Math.round(accessibilityPercentage * 1000) / 1000; } /** * Analyze Z-index accessibility for all clickable elements in the view hierarchy * @param viewHierarchy - The view hierarchy to analyze */ private analyzeZIndexAccessibility(viewHierarchy: ViewHierarchyResult): void { if (!viewHierarchy || !viewHierarchy.hierarchy) { return; } const startTime = Date.now(); // Collect all elements with their Z-order information const allElements: ElementWithZOrder[] = []; const zOrder = { value: 0 }; this.collectElementsWithZOrder(viewHierarchy.hierarchy, zOrder, allElements); logger.debug(`[Z_INDEX_ANALYSIS] Collected ${allElements.length} elements for Z-index analysis`); // Filter clickable elements const clickableElements = allElements.filter(el => el.isClickable); logger.debug(`[Z_INDEX_ANALYSIS] Found ${clickableElements.length} clickable elements`); // Calculate accessibility for each clickable element for (const clickableElement of clickableElements) { clickableElement.element.accessible = this.calculateAccessibility(clickableElement, allElements); } const duration = Date.now() - startTime; logger.debug(`[Z_INDEX_ANALYSIS] Z-index accessibility analysis completed in ${duration}ms`); } /** * Calculate screenshot hash from buffer * @param screenshotBuffer - Buffer containing screenshot data * @returns MD5 hash of the screenshot */ calculateScreenshotHash(screenshotBuffer: Buffer): string { return CryptoUtils.generateCacheKey(screenshotBuffer); } /** * Check in-memory cache for view hierarchy using fuzzy matching * @param targetBuffer - Screenshot buffer to compare against * @returns Cached view hierarchy or null if not found/expired */ async checkInMemoryCache(targetBuffer: Buffer): Promise<ViewHierarchyResult | null> { const cacheSize = ViewHierarchy.viewHierarchyCache.size; logger.debug(`Checking in-memory cache with fuzzy matching, cache size: ${cacheSize}`); if (cacheSize === 0) { logger.debug("In-memory cache is empty"); return null; } const cacheTtl = ViewHierarchy.CACHE_TTL_MS; const now = Date.now(); // First, remove expired entries const expiredKeys: string[] = []; for (const [key, cachedEntry] of ViewHierarchy.viewHierarchyCache.entries()) { const age = now - cachedEntry.timestamp; if (age >= cacheTtl) { expiredKeys.push(key); logger.debug(`Removing expired cache entry: ${key} (age: ${age}ms > TTL: ${cacheTtl}ms)`); } } for (const key of expiredKeys) { ViewHierarchy.viewHierarchyCache.delete(key); } if (ViewHierarchy.viewHierarchyCache.size === 0) { logger.debug("All cache entries were expired and removed"); return null; } // Try to find a cached screenshot that matches with fuzzy comparison logger.debug(`Performing fuzzy matching against ${ViewHierarchy.viewHierarchyCache.size} cached entries (tolerance: ${DEFAULT_FUZZY_MATCH_TOLERANCE_PERCENT}%)`); const screenshotFiles = await ScreenshotUtils.getScreenshotFiles(ViewHierarchy.screenshotCacheDir); if (screenshotFiles.length === 0) { logger.debug("No screenshot files found for fuzzy comparison"); return null; } const similarResult = await ScreenshotUtils.findSimilarScreenshots( targetBuffer, ViewHierarchy.screenshotCacheDir, DEFAULT_FUZZY_MATCH_TOLERANCE_PERCENT, 5 // Limit to 5 comparisons for in-memory cache check ); if (similarResult.matchFound) { // Extract hash from the similar screenshot filename const hash = ScreenshotUtils.extractHashFromFilename(similarResult.filePath); if (hash) { const cachedEntry = ViewHierarchy.viewHierarchyCache.get(hash); if (cachedEntry) { const age = now - cachedEntry.timestamp; if (age < cacheTtl) { logger.debug(`Found fuzzy match in memory cache: ${hash} (${similarResult.similarity.toFixed(2)}% similarity, age: ${age}ms)`); return cachedEntry.viewHierarchy; } else { logger.debug(`Fuzzy match found but cache entry expired: ${hash} (age: ${age}ms > TTL: ${cacheTtl}ms)`); ViewHierarchy.viewHierarchyCache.delete(hash); } } } } logger.debug("No fuzzy match found in in-memory cache"); return null; } /** * Check disk cache for view hierarchy using fuzzy matching * @param targetBuffer - Screenshot buffer to compare against * @returns Promise with cached view hierarchy or null if not found/expired */ async checkDiskCache(targetBuffer: Buffer): Promise<ViewHierarchyResult | null> { logger.debug("Checking disk cache with fuzzy matching"); const cacheTtl = ViewHierarchy.CACHE_TTL_MS; const similarResult = await ScreenshotUtils.findSimilarScreenshots( targetBuffer, ViewHierarchy.screenshotCacheDir, DEFAULT_FUZZY_MATCH_TOLERANCE_PERCENT, 10 // Check up to 10 recent screenshots for disk cache ); if (!similarResult.matchFound) { logger.debug("No fuzzy match found in disk cache"); return null; } // Extract hash from the matched screenshot const hash = ScreenshotUtils.extractHashFromFilename(similarResult.filePath); if (!hash) { logger.warn(`Could not extract hash from matched file: ${similarResult.filePath}`); return null; } const diskCachePath = path.join(ViewHierarchy.cacheDir, `${hash}.json`); logger.debug(`Found fuzzy match in disk cache: ${path.basename(similarResult.filePath)} (${similarResult.similarity.toFixed(2)}% similarity)`); if (!fs.existsSync(diskCachePath)) { logger.warn(`Disk cache JSON file does not exist: ${diskCachePath}`); return null; } try { const fileStats = await statAsync(diskCachePath); const fileAge = Date.now() - fileStats.mtimeMs; logger.debug(`Disk cache file found with age ${fileAge}ms (TTL: ${cacheTtl}ms)`); if (fileAge < cacheTtl) { logger.debug(`Using fuzzy matched disk cached view hierarchy: ${hash}`); const cacheData = await readFileAsync(diskCachePath, "utf8"); const cachedViewHierarchy = JSON.parse(cacheData); // Also update the in-memory cache with the fuzzy-matched result ViewHierarchy.viewHierarchyCache.set(hash, { timestamp: Date.now(), activityHash: hash, viewHierarchy: cachedViewHierarchy }); logger.debug(`Updated in-memory cache from fuzzy matched disk cache: ${hash}`); return cachedViewHierarchy; } else { logger.debug(`Fuzzy matched disk cache file expired (age: ${fileAge}ms > TTL: ${cacheTtl}ms)`); } } catch (err) { logger.warn(`Failed to load fuzzy matched disk cached view hierarchy: ${err}`); } return null; } /** * Check cache hierarchy (both in-memory and disk) using fuzzy matching * @param screenshotBuffer - Screenshot buffer to compare against * @returns Promise with cached view hierarchy or null if not found */ public async checkCacheHierarchyWithFuzzyMatching(screenshotBuffer: Buffer): Promise<ViewHierarchyResult | null> { logger.debug("Checking cache hierarchy using fuzzy matching"); // Check in-memory cache first const cachedResult = await this.checkInMemoryCache(screenshotBuffer); if (cachedResult) { logger.debug("Found result in in-memory cache using fuzzy matching"); return cachedResult; } // Check disk cache const diskCachedResult = await this.checkDiskCache(screenshotBuffer); if (diskCachedResult) { logger.debug("Found result in disk cache using fuzzy matching"); return diskCachedResult; } logger.debug("No cached result found using fuzzy matching"); return null; } /** * Check cache hierarchy (both in-memory and disk) - legacy hash-based method for backward compatibility * @param screenshotHash - Hash of the screenshot * @returns Promise with cached view hierarchy or null if not found */ public async checkCacheHierarchy(screenshotHash: string): Promise<ViewHierarchyResult | null> { // Check in-memory cache first using exact hash match const cachedEntry = ViewHierarchy.viewHierarchyCache.get(screenshotHash); const cacheTtl = ViewHierarchy.CACHE_TTL_MS; const now = Date.now(); if (cachedEntry) { const age = now - cachedEntry.timestamp; logger.debug(`Found cached entry with age ${age}ms (TTL: ${cacheTtl}ms)`); if (age < cacheTtl) { logger.debug(`Using cached view hierarchy for screenshot hash ${screenshotHash}`); return cachedEntry.viewHierarchy; } else { logger.debug(`Cached entry expired (age: ${age}ms > TTL: ${cacheTtl}ms), removing from cache`); ViewHierarchy.viewHierarchyCache.delete(screenshotHash); } } // Check disk cache using exact hash match const diskCachePath = path.join(ViewHierarchy.cacheDir, `${screenshotHash}.json`); if (!fs.existsSync(diskCachePath)) { return null; } try { const fileStats = await statAsync(diskCachePath); const fileAge = Date.now() - fileStats.mtimeMs; if (fileAge < cacheTtl) { logger.debug(`Using disk cached view hierarchy for screenshot hash ${screenshotHash}`); const cacheData = await readFileAsync(diskCachePath, "utf8"); const cachedViewHierarchy = JSON.parse(cacheData); // Also update the in-memory cache ViewHierarchy.viewHierarchyCache.set(screenshotHash, { timestamp: Date.now(), activityHash: screenshotHash, viewHierarchy: cachedViewHierarchy }); return cachedViewHierarchy; } else { logger.debug(`Disk cache file expired (age: ${fileAge}ms > TTL: ${cacheTtl}ms)`); } } catch (err) { logger.warn(`Failed to load disk cached view hierarchy: ${err}`); } logger.debug(`No cached result found for hash ${screenshotHash}`); return null; } /** * Cache view hierarchy result * @param timestamp - Timestamp for unique filename * @param viewHierarchy - View hierarchy to cache */ public async cacheViewHierarchy(timestamp: number, viewHierarchy: ViewHierarchyResult): Promise<void> { // Cache the result using the timestamp logger.debug(`Caching view hierarchy with timestamp ${timestamp}, in-memory cache size will be: ${ViewHierarchy.viewHierarchyCache.size + 1}`); const timestampKey = timestamp.toString(); ViewHierarchy.viewHierarchyCache.set(timestampKey, { timestamp: Date.now(), activityHash: timestampKey, viewHierarchy }); // Save to disk cache await this.saveToDiskCache(timestampKey, viewHierarchy); this.maintainCacheSize(); } /** * Retrieve the view hierarchy of the current screen * @param queryOptions - Optional query options for targeted element retrieval * @returns Promise with parsed XML view hierarchy */ async getViewHierarchy(queryOptions?: ViewHierarchyQueryOptions): Promise<ViewHierarchyResult> { switch (this.device.platform) { case "ios": return this.getiOSViewHierarchy(); case "android": return this.getAndroidViewHierarchy(queryOptions); default: throw new Error("Unsupported platform"); } } /** * Retrieve the view hierarchy of the current screen * @returns Promise with parsed XML view hierarchy */ async getiOSViewHierarchy(): Promise<ViewHierarchyResult> { const startTime = Date.now(); logger.info(`[VIEW_HIERARCHY] Starting getViewHierarchy for iOS`); const viewHierarchy = await this.webdriver.getViewHierarchy(this.device); const duration = Date.now() - startTime; logger.info(`[VIEW_HIERARCHY] Successfully retrieved hierarchy from accessibility service in ${duration}ms`); return await this.augmentWithSourceIndexing(viewHierarchy as ExtendedViewHierarchyResult); } /** * Retrieve the view hierarchy of the current screen * @param queryOptions - Optional query options for targeted element retrieval * @returns Promise with parsed XML view hierarchy */ async getAndroidViewHierarchy(queryOptions?: ViewHierarchyQueryOptions): Promise<ViewHierarchyResult> { const startTime = Date.now(); logger.debug(`[VIEW_HIERARCHY] Starting Android getViewHierarchy`); // First try accessibility service if available and not skipped try { const accessibilityHierarchy = await this.accessibilityServiceClient.getAccessibilityHierarchy(queryOptions); if (accessibilityHierarchy) { const accessibilityDuration = Date.now() - startTime; logger.debug(`[VIEW_HIERARCHY] Successfully retrieved hierarchy from accessibility service in ${accessibilityDuration}ms`); return await this.augmentWithSourceIndexing(accessibilityHierarchy as ExtendedViewHierarchyResult); } } catch (err) { logger.warn(`[VIEW_HIERARCHY] Failed to get hierarchy from accessibility service: ${err}`); } try { // Get fresh view hierarchy const freshStartTime = Date.now(); const viewHierarchy = await this._getViewHierarchyWithoutCache(); const freshDuration = Date.now() - freshStartTime; logger.debug(`[VIEW_HIERARCHY] Fresh hierarchy fetched in ${freshDuration}ms`); // Augment with source indexing information const sourceStartTime = Date.now(); const extendedViewHierarchy = await this.augmentWithSourceIndexing(viewHierarchy as ExtendedViewHierarchyResult); const sourceDuration = Date.now() - sourceStartTime; logger.debug(`[VIEW_HIERARCHY] Source indexing augmentation took ${sourceDuration}ms`); // Cache the result using a timestamp const cacheStartTime = Date.now(); const timestamp = Date.now(); logger.debug(`[VIEW_HIERARCHY] Caching view hierarchy with timestamp: ${timestamp}`); await this.cacheViewHierarchy(timestamp, extendedViewHierarchy); const cacheDuration = Date.now() - cacheStartTime; logger.debug(`[VIEW_HIERARCHY] Caching completed in ${cacheDuration}ms`); const totalDuration = Date.now() - startTime; logger.debug(`[VIEW_HIERARCHY] *** FRESH HIERARCHY: getViewHierarchy completed in ${totalDuration}ms (fresh hierarchy) ***`); return viewHierarchy; } catch (err) { const totalDuration = Date.now() - startTime; logger.warn(`[VIEW_HIERARCHY] getViewHierarchy failed after ${totalDuration}ms:`, err); // If the error is one of the specific ADB errors, re-call _getViewHierarchyWithoutCache // to ensure its specific error message is returned. if (err instanceof Error && (err.message.includes("null root node returned by UiTestAutomationBridge") || err.message.includes("cat:") || err.message.includes("No such file or directory"))) { logger.debug("[VIEW_HIERARCHY] Specific ADB error detected, calling _getViewHierarchyWithoutCache to get its specific error message."); return await this._getViewHierarchyWithoutCache(); } // If screenshot-related error, fall back to getting view hierarchy without cache // (this might also lead to one of the specific errors above if _getViewHierarchyWithoutCache fails) if (err instanceof Error && err.message.includes("screenshot")) { logger.debug("[VIEW_HIERARCHY] Screenshot error detected, falling back to view hierarchy without cache"); const fallbackResult = await this._getViewHierarchyWithoutCache(); // If the fallback result has a specific error message, preserve it if (fallbackResult.hierarchy && (fallbackResult.hierarchy as any).error) { return fallbackResult; } return fallbackResult; } // For all other unhandled errors from getViewHierarchy itself, return the generic message. logger.debug("[VIEW_HIERARCHY] Unhandled error in getViewHierarchy, returning generic error message."); return { hierarchy: { error: "Failed to retrieve view hierarchy" } } as unknown as ViewHierarchyResult; } } /** * Find a fuzzy match with cache by scanning up to 100 recent screenshots * @param targetBuffer - Screenshot buffer to compare against * @param limit - Number of recent screenshots to scan * @returns Promise with cached view hierarchy or null if not found */ private async findFuzzyMatchWithCache(targetBuffer: Buffer, limit: number): Promise<ViewHierarchyResult | null> { logger.debug(`Scanning up to ${limit} recent screenshots for fuzzy match with cached view hierarchy`); try { // Get list of recent screenshots (up to limit, sorted by modification time) const screenshotFiles = await ScreenshotUtils.getScreenshotFiles(ViewHierarchy.screenshotCacheDir); if (screenshotFiles.length === 0) { logger.debug("No recent screenshots found for fuzzy comparison"); return null; } // Sort files by modification time (newest first) and limit const filesWithStats = await Promise.all( screenshotFiles.map(async filePath => { const stats = await statAsync(filePath); return { filePath, mtime: stats.mtime.getTime() }; }) ); filesWithStats.sort((a, b) => b.mtime - a.mtime); const recentFiles = filesWithStats.slice(0, limit); logger.debug(`Pre-filtering ${recentFiles.length} recent screenshots for cached data availability`); // Pre-filter: Find files that have cached view hierarchy data (parallel check) const filesWithCachePromises = recentFiles.map(async ({ filePath }) => { const hash = ScreenshotUtils.extractHashFromFilename(filePath); if (!hash) {return null;} const cachedResult = await this.checkCacheHierarchy(hash); return cachedResult ? { filePath, hash, cachedResult } : null; }); const filesWithCache = (await Promise.all(filesWithCachePromises)) .filter((item): item is NonNullable<typeof item> => item !== null); if (filesWithCache.length === 0) { logger.debug("No recent screenshots have valid cached view hierarchy data"); return null; } logger.debug(`Found ${filesWithCache.length} screenshots with valid cached data, starting parallel fuzzy matching`); // Use optimized batch comparison for even better performance const filePaths = filesWithCache.map(item => item.filePath); const batchComparisonResults = await ScreenshotUtils.optimizedBatchCompareScreenshots( targetBuffer, filePaths, DEFAULT_FUZZY_MATCH_TOLERANCE_PERCENT, true // Use fast mode for bulk comparisons ); // Match comparison results back to cached data for (const comparisonResult of batchComparisonResults) { const matchingFile = filesWithCache.find(item => item.filePath === comparisonResult.filePath); if (matchingFile && comparisonResult.matchFound) { logger.debug(`✓ Found fuzzy match with cached data: ${matchingFile.hash} (${comparisonResult.similarity.toFixed(2)}% similarity)`); return matchingFile.cachedResult; } } // Log all comparison results for debugging batchComparisonResults.forEach(result => { logger.debug(`${path.basename(result.filePath)}: ${result.similarity.toFixed(2)}% similarity (cached data available)`); }); logger.debug("No fuzzy match found with cached view hierarchy data"); return null; } catch (error) { logger.warn(`Error in findFuzzyMatchWithCache: ${(error as Error).message}`); return null; } } async getMostRecentCachedViewHierarchy(): Promise<ViewHierarchyResult> { if (ViewHierarchy.viewHierarchyCache.size === 0) { logger.debug("View hierarchy cache is empty."); // Consider if a more specific error or a different return type for "not found" is appropriate. // For now, returning a structure indicating an error, similar to other methods. return { hierarchy: { error: "No cached view hierarchy available" } } as unknown as ViewHierarchyResult; } let mostRecentEntry: ViewHierarchyCache | null = null; for (const entry of ViewHierarchy.viewHierarchyCache.values()) { if (!mostRecentEntry || entry.timestamp > mostRecentEntry.timestamp) { mostRecentEntry = entry; } } if (mostRecentEntry) { logger.debug(`Returning most recent cached view hierarchy from timestamp: ${new Date(mostRecentEntry.timestamp).toISOString()}`); return mostRecentEntry.viewHierarchy; } // This case should ideally not be reached if cache.size > 0, but as a fallback: return { hierarchy: { error: "Failed to retrieve most recent cached view hierarchy" } } as unknown as ViewHierarchyResult; } /** * Execute uiautomator dump command and get XML content (optimized version) * @returns Promise with XML data string */ public async executeUiAutomatorDump(): Promise<string> { // Optimized: Use /data/local/tmp which is more reliable than /sdcard const tempFile = "/data/local/tmp/window_dump.xml"; // Use shell subcommand to ensure atomicity and avoid separate rm command const { stdout } = await this.adb.executeCommand(`shell "(uiautomator dump ${tempFile} >/dev/null 2>&1 && cat ${tempFile}; rm -f ${tempFile}) 2>/dev/null"`); return this.extractXmlFromAdbOutput(stdout, tempFile); } /** * Process XML data into view hierarchy result * @param xmlData - XML string to process * @returns Promise with processed view hierarchy result */ public async processXmlData(xmlData: string): Promise<ViewHierarchyResult> { // Check that we have valid XML data if (!this.validateXmlData(xmlData)) { logger.warn("Invalid XML data received from uiautomator"); return { hierarchy: { error: "Failed to retrieve view hierarchy" } } as unknown as ViewHierarchyResult; } logger.debug("Starting analysis on view hierarchy"); const analysisStart = Date.now(); const result = await this.parseXmlToViewHierarchy(xmlData); // Add Z-index accessibility analysis this.analyzeZIndexAccessibility(result); logger.debug(`hierarchy analysis took ${Date.now() - analysisStart}ms`); return result; } /** * Check if node meets filter criteria (either string or boolean based) * @param props - Node properties * @returns True if node meets any filter criteria */ public meetsFilterCriteria(props: any): boolean { return this.meetsStringFilterCriteria(props) || this.meetsBooleanFilterCriteria(props); } /** * Filter a single node and its children * @param node - Node to filter * @param isRootNode - Whether this is the root node * @returns Filtered node or null */ public filterSingleNode(node: any, isRootNode: boolean = false): any | null { if (!node) { return null; } if (isRootNode) { const rootCopy = JSON.parse(JSON.stringify(node)); if (node.node) { const processedChildren = this.processNodeChildren(node, child => this.filterSingleNode(child)); if (processedChildren.length > 0) { rootCopy.node = this.normalizeNodeStructure(processedChildren); } } return rootCopy; } const props = node.$ || node; const meetsFilterCriteria = this.meetsFilterCriteria(props); const relevantChildren = this.processNodeChildren(node, child => this.filterSingleNode(child)); if (meetsFilterCriteria) { const cleanedNode = this.cleanNodeProperties(node); if (relevantChildren.length > 0) { cleanedNode.node = this.normalizeNodeStructure(relevantChildren); } return cleanedNode; } if (relevantChildren.length > 0) { return relevantChildren; } return null; } /** * Filter the view hierarchy to only include elements that meet specific criteria: * - Have resourceId, text, or contentDesc * - OR have clickable, scrollable, or focused set to true * - Include descendants that meet criteria even if parents don't * - Omit boolean fields not set to true and class="android.view.View" * @param viewHierarchy - The view hierarchy to filter * @returns Filtered view hierarchy */ filterViewHierarchy(viewHierarchy: any): any { if (!viewHierarchy || !viewHierarchy.hierarchy) { logger.debug("No hierarchy found"); return viewHierarchy; } const result = JSON.parse(JSON.stringify(viewHierarchy)); result.hierarchy = this.filterSingleNode(viewHierarchy.hierarchy, true); return result; } /** * Extract XML content from ADB output * @param stdout - Raw stdout from ADB command * @param tempFile - Temp file path used in command * @returns Cleaned XML data */ extractXmlFromAdbOutput(stdout: string, tempFile: string): string { let xmlData = stdout; const uiHierarchyMessage = "UI hierchary dumped to:"; if (xmlData.includes(uiHierarchyMessage)) { const prefixEnd = xmlData.indexOf(uiHierarchyMessage) + uiHierarchyMessage.length + tempFile.length + 1; xmlData = xmlData.substring(prefixEnd); } return xmlData; } /** * Validate XML data * @param xmlData - XML string to validate * @returns True if valid XML data */ validateXmlData(xmlData: string): boolean { return !!(xmlData && xmlData.trim() && xmlData.includes("<hierarchy")); } /** * Parse XML to view hierarchy * @param xmlData - XML string to parse * @returns Promise with parsed and filtered view hierarchy */ async parseXmlToViewHierarchy(xmlData: string): Promise<ViewHierarchyResult> { const parser = new xml2js.Parser({ explicitArray: false }); const result = await parser.parseStringPromise(xmlData); return this.filterViewHierarchy(result); } /** * Calculate and log filtering statistics * @param original - Original view hierarchy * @param filtered - Filtered view hierarchy */ calculateFilteringStats(original: any, filtered: any): void { const originalResultSize = Buffer.byteLength(JSON.stringify(original), "utf8"); const filteredResultSize = Buffer.byteLength(JSON.stringify(filtered), "utf8"); logger.debug(`filtering ${originalResultSize} bytes down to ${filteredResultSize} bytes`); } /** * Check if node meets string-based filter criteria * @param props - Node properties * @returns True if node has meaningful string properties */ meetsStringFilterCriteria(props: any): boolean { return Boolean( (props.resourceId && props.resourceId !== "") || (props["resource-id"] && props["resource-id"] !== "") || (props.text && props.text !== "") || (props.contentDesc && props.contentDesc !== "") || (props["content-desc"] && props["content-desc"] !== "") ); } /** * Check if node meets boolean-based filter criteria * @param props - Node properties * @returns True if node has meaningful boolean properties */ meetsBooleanFilterCriteria(props: any): boolean { return Boolean( (props.clickable === "true") || (props.scrollable === "true") || (props.focused === "true") ); } /** * Process node children with filter function * @param node - Parent node * @param filterFn - Filter function to apply to children * @returns Array of filtered children */ processNodeChildren(node: any, filterFn: (child: any) => any): any[] { const relevantChildren: any[] = []; if (node.node) { const children = (Array.isArray(node.node) ? node.node : [node.node]).slice(0, 64); for (const child of children) { const filteredChild = filterFn(child); if (filteredChild) { if (Array.isArray(filteredChild)) { relevantChildren.push(...filteredChild); } else { relevantChildren.push(filteredChild); } } } } return relevantChildren; } /** * Normalize node structure for filtered children * @param filteredChildren - Array of filtered children * @returns Normalized node structure (single item or array) */ normalizeNodeStructure(filteredChildren: any[]): any { return filteredChildren.length === 1 ? filteredChildren[0] : filteredChildren; } /** * Retrieve the view hierarchy of the current screen without using cache * @returns Promise with parsed XML view hierarchy */ async _getViewHierarchyWithoutCache(): Promise<ViewHierarchyResult> { const dumpStart = Date.now(); try { // Run uiautomator dump and dumpsys activity top in parallel const [xmlData, dumpsysResult] = await Promise.all([ this.executeUiAutomatorDump(), // Returns string this.adb.executeCommand("shell dumpsys activity top") // Returns ExecResult ]); const dumpsysOutput = dumpsysResult.stdout || ""; logger.debug(`uiautomator dump && dumpsys activity top took ${Date.now() - dumpStart}ms`); // Process XML data into view hierarchy result const hierarchyResult = await this.processXmlData(xmlData); // Augment the view hierarchy with class and fragment info from dumpsys output const activityTopData = this.parseDumpsysActivityTop(dumpsysOutput); logger.debug(`Found ${activityTopData.classOverrides.size} class overrides, ${activityTopData.fragmentData.size} fragments, and ${activityTopData.viewData.size} custom views from dumpsys activity top`); this.augmentViewHierarchyWithClassAndFragment(hierarchyResult, activityTopData); return hierarchyResult; } catch (err) { logger.warn("Failed to get view hierarchy:", err); // Check for specific error that indicates screen is off or device is locked const errStr = String(err); if (errStr.includes("null root node returned by UiTestAutomationBridge")) { return { hierarchy: { error: "Failed to retrieve view hierarchy - screen appears to be off or device is locked" } } as unknown as ViewHierarchyResult; } if (errStr.includes("cat:") && errStr.includes("No such file or directory")) { return { hierarchy: { error: "Failed to retrieve view hierarchy - screen appears to be off or device is locked" } } as unknown as ViewHierarchyResult; } // For all other errors, return generic error message return { hierarchy: { error: "Failed to retrieve view hierarchy data" } } as unknown as ViewHierarchyResult; } } /** * Maintain the view hierarchy cache size * If cache exceeds MAX_CACHE_SIZE_BYTES, removes oldest entries */ async maintainCacheSize(): Promise<void> { try { // Get all files in the cache directory const files = await readdirAsync(ViewHierarchy.cacheDir); let totalSize = 0; const fileStats: { path: string, size: number, mtime: Date }[] = []; // Collect file stats for (const file of files) { if (file.endsWith(".json")) { const filePath = path.join(ViewHierarchy.cacheDir, file); const stats = await statAsync(filePath); totalSize += stats.size; fileStats.push({ path: filePath, size: stats.size, mtime: stats.mtime }); } } // If cache is too large, remove oldest files if (totalSize > ViewHierarchy.MAX_CACHE_SIZE_BYTES) { // Sort by modification time (oldest first) fileStats.sort((a, b) => a.mtime.getTime() - b.mtime.getTime()); // Remove oldest files until we're under the limit let sizeToFree = totalSize - ViewHierarchy.MAX_CACHE_SIZE_BYTES; for (const file of fileStats) { await fs.remove(file.path); sizeToFree -= file.size; if (sizeToFree <= 0) {break;} } logger.debug(`Cleared ${fileStats.length} old cache files to maintain cache size limit`); } } catch (err) { logger.warn(`Error maintaining cache size: ${err}`); } } /** * Save view hierarchy to disk cache * @param timestamp - Timestamp for filename * @param viewHierarchy - View hierarchy to save */ public async saveToDiskCache(timestamp: string, viewHierarchy: ViewHierarchyResult): Promise<void> { try { await writeFileAsync( path.join(ViewHierarchy.cacheDir, `hierarchy_${timestamp}.json`), JSON.stringify(viewHierarchy) ); logger.debug(`Saved view hierarchy to disk cache with timestamp ${timestamp}`); } catch (err) { logger.warn(`Failed to save view hierarchy to disk cache: ${err}`); } } /** * Get or create screenshot buffer * @param screenshotPath - Optional existing screenshot path * @returns Promise with buffer and path */ public async getOrCreateScreenshotBuffer(screenshotPath: string | null): Promise<{ buffer: Buffer; path: string }> { const startTime = Date.now(); if (screenshotPath) { logger.debug(`[VIEW_HIERARCHY] Using provided screenshot for view hierarchy caching: ${screenshotPath}`); const readStartTime = Date.now(); const buffer = await readFileAsync(screenshotPath); const readDuration = Date.now() - readStartTime; const totalDuration = Date.now() - startTime; logger.debug(`[VIEW_HIERARCHY] Read existing screenshot in ${readDuration}ms, total getOrCreateScreenshotBuffer: ${totalDuration}ms`); return { buffer, path: screenshotPath }; } else { logger.debug("[VIEW_HIERARCHY] Taking new screenshot for view hierarchy caching"); const screenshotStartTime = Date.now(); const screenshotResult = await this.takeScreenshot.execute(); const screenshotDuration = Date.now() - screenshotStartTime; if (!screenshotResult.success || !screenshotResult.path) { throw new Error(screenshotResult.error || "Failed to take screenshot for view hierarchy caching"); } const readStartTime = Date.now(); const buffer = await readFileAsync(screenshotResult.path); const readDuration = Date.now() - readStartTime; const totalDuration = Date.now() - startTime; logger.debug(`[VIEW_HIERARCHY] Screenshot capture: ${screenshotDuration}ms, file read: ${readDuration}ms, total getOrCreateScreenshotBuffer: ${totalDuration}ms`); return { buffer, path: screenshotResult.path }; } } /** * Find the focused element in the view hierarchy * @param viewHierarchy - The view hierarchy to search * @returns The focused element or null if none found */ findFocusedElement(viewHierarchy: any): Element | null { if (!viewHierarchy || !viewHierarchy.hierarchy) { return null; } let focusedElement: Element | null = null; const traverseNode = (node: any): void => { if (focusedElement) { return; // Already found focused element, stop traversing } // Check if current node is focused const props = node.$ || node; if (props.focused === "true" || props.focused === true) { // Parse the node into an Element const element = this.parseNodeBounds(node); if (element) { // Ensure focused property is a boolean element.focused = true; focusedElement = element; return; } } // Continue traversing children if (node.node) { const children = Array.isArray(node.node) ? node.node : [node.node]; for (const child of children) { traverseNode(child); if (focusedElement) { break; // Stop if we found the focused element } } } }; traverseNode(viewHierarchy.hierarchy); return focusedElement; } /** * Calculate the center coordinates of an element * @param element - The element to calculate center for * @returns The center coordinates */ getElementCenter(element: Element): { x: number, y: number } { return this.elementUtils.getElementCenter(element); } /** * Parse a node's bounds if they're in string format * @param node - The node to parse * @returns The node with parsed bounds or null */ parseNodeBounds(node: any): Element | null { return this.elementUtils.parseNodeBounds(node); } /** * Traverse the view hierarchy and process each node with a provided function * @param node - The node to start traversal from * @param processNode - Function to process each node */ traverseViewHierarchy(node: any, processNode: (node: any) => void): void { this.elementUtils.traverseNode(node, processNode); } cleanNodeProperties(node: any): any { const result: any = {}; const allowedProperties = ["text", "resourceId", "resource-id", "contentDesc", "content-desc", "clickable", "scrollable", "enabled", "bounds", "accessible"]; if (node["$"]) { const cleanedProps: any = {}; for (const key in node.$) { if (allowedProperties.includes(key)) { const normalizedKey = key === "resourceId" ? "resource-id" : key === "contentDesc" ? "content-desc" : key; if (node.$[key] === "") {continue;} if (key === "enabled" && (node.$[key] === true || node.$[key] === "true")) {continue;} if (key !== "enabled" && (node.$[key] === false || node.$[key] === "false")) {continue;} cleanedProps[normalizedKey] = node.$[key]; } } if (Object.keys(cleanedProps).length > 0) { for (const key in cleanedProps) { result[key] = cleanedProps[key]; } } for (const key in node) { if (key !== "$" && key !== "node") { result[key] = node[key]; } } } else { for (const key in node) { if (key === "node") {continue;} if (!allowedProperties.includes(key)) {continue;} if (node[key] === "") {continue;} if (key === "enabled" && (node[key] === true || node[key] === "true")) {continue;} if (key !== "enabled" && (node[key] === false || node[key] === "false")) {continue;} result[key] = node[key]; } } return result; } /** * Save screenshot for fuzzy matching * @param screenshotBuffer - Buffer of the screenshot * @param timestamp - Timestamp to use for the filename */ private async saveScreenshotForFuzzyMatching(screenshotBuffer: Buffer, timestamp: string): Promise<void> { try { const screenshotPath = path.join(ViewHierarchy.screenshotCacheDir, `screenshot_${timestamp}.png`); await fs.writeFile(screenshotPath, screenshotBuffer); logger.debug(`Saved screenshot for fuzzy matching with timestamp ${timestamp}`); } catch (err) { logger.warn(`Failed to save screenshot for fuzzy matching: ${err}`); } } /** * Parse dumpsys activity top output to extract class and fragment information * @param dumpsysOutput - Raw output from dumpsys activity top * @returns ActivityTopData containing class and fragment mappings */ private parseDumpsysActivityTop(dumpsysOutput: string): ActivityTopData { const classOverrides = new Map<string, string>(); const fragmentData = new Map<string, string>(); const viewData = new Map<string, string>(); const lines = dumpsysOutput.split("\n"); let inViewHierarchy = false; let inActiveFragments = false; for (let i = 0; i < lines.length; i++) { const line = lines[i]; // Check if we're entering the View Hierarchy section if (line.includes("View Hierarchy:")) { inViewHierarchy = true; continue; } // Check if we're leaving the View Hierarchy section if (inViewHierarchy && line.includes("Looper (main")) { inViewHierarchy = false; continue; } // Check if we're entering the Active Fragments section if (line.includes("Active Fragments:")) { inActiveFragments = true; continue; } // Reset when we hit other major sections after Active Fragments if (inActiveFragments && line.match(/^[A-Z][a-zA-Z\s]+:/)) { inActiveFragments = false; } // Parse View Hierarchy for class names that don't match standard Android patterns if (inViewHierarchy) { // Look for lines with class definitions like: // com.zillow.android.ui.base.ZillowToolbar{9958b11 VFE...... ........ 0,0-1080,173 #7f0a078a app:id/search_toolbar aid=1073742017} const classMatch = line.match(/^\s*([a-zA-Z][a-zA-Z0-9._$]*)\{[^}]+\}/); if (classMatch) { const className = classMatch[1]; // Check if class doesn't match android.*, com.android.*, or androidx.* patterns if (!className.match(/^(android\.|com\.android\.|androidx\.)/)) { // Extract resource-id from the same line if present const resourceIdMatch = line.match(/#([a-zA-Z0-9_:]+)\s+app:id\/([a-zA-Z0-9_]+)/); const boundsMatch = line.match(/(\d+,\d+-\d+,\d+)/); if (resourceIdMatch) { const resourceId = `${resourceIdMatch[1]}`; classOverrides.set(resourceId, className); // Detect if this is a custom View class (not Fragment or Activity) if (!className.includes("Fragment") && !className.includes("Activity")) { viewData.set(resourceId, className); } } else if (boundsMatch) { const bounds = boundsMatch[1]; classOverrides.set(bounds, className); // Detect if this is a custom View class (not Fragment or Activity) if (!className.includes("Fragment") && !className.includes("Activity")) { viewData.set(bounds, className); } } } } } // Parse Active Fragments for fragment information if (inActiveFragments) { // Look for fragment definitions like: // SearchTabContainerFragment{fc93440} (92eba8cc-8e59-4c67-9dcf-2f98fc626dd1 id=0x7f0a012b tag=8ba7a0d8-1d7d-4159-ab80-e3adbf1888ca) const fragmentMatch = line.match(/(\w*Fragment)\{[^}]+\}\s+\([^)]*id=(0x[0-9a-fA-F]+)/); if (fragmentMatch) { const fragmentName = fragmentMatch[1]; const fragmentId = fragmentMatch[2]; fragmentData.set(fragmentId, fragmentName); } // Alternative format for fragments like: // #0: SearchTabContainerFragment{fc93440} (92eba8cc-8e59-4c67-9dcf-2f98fc626dd1 id=0x7f0a012b tag=...) const altFragmentMatch = line.match(/#\d+:\s+(\w*Fragment)\{[^}]+\}\s+\([^)]*id=(0x[0-9a-fA-F]+)/); if (altFragmentMatch) { const fragmentName = altFragmentMatch[1]; const fragmentId = altFragmentMatch[2]; fragmentData.set(fragmentId, fragmentName); } } } return { classOverrides, fragmentData, viewData }; } /** * Augment view hierarchy with source indexing information * @param viewHierarchy - The view hierarchy to augment * @returns Augmented view hierarchy with source information */ private async augmentWithSourceIndexing(viewHierarchy: ExtendedViewHierarchyResult): Promise<ExtendedViewHierarchyResult> { try { // Skip if already has source info or if hierarchy has error if (viewHierarchy.sourceInfo || (viewHierarchy.hierarchy as any)?.error) { return viewHierarchy; } logger.debug("[SOURCE_INDEXING] Attempting to augment view hierarchy with source information"); // Extract activity information from the current activity const currentActivity = await this.getCurrentActivityInfo(); if (!currentActivity) { logger.debug("[SOURCE_INDEXING] No current activity found"); return viewHierarchy; } logger.debug(`[SOURCE_INDEXING] Current activity: ${currentActivity.activityName}, package: ${currentActivity.packageName}`); const matchingConfig = this.sourceMapper.getMatchingAppConfig(currentActivity.packageName); if (!matchingConfig) { logger.debug(`[SOURCE_INDEXING] No app configuration found for package: ${currentActivity.packageName}`); return viewHierarchy; } logger.debug(`[SOURCE_INDEXING] Found matching app config: ${matchingConfig.appId}`); // Find activity source information let activity: ActivityInfo | null = null; try { activity = await this.sourceMapper.findActivityInfo( matchingConfig.appId, currentActivity.activityName ); if (activity) { logger.debug(`[SOURCE_INDEXING] Found activity source: ${activity.sourceFile}`); } } catch (error) { logger.warn(`[SOURCE_INDEXING] Error finding activity source: ${error}`); } // Find fragment source information const fragments: FragmentInfo[] = []; const fragmentNames = this.extractFragmentNames(viewHierarchy); for (const fragmentName of fragmentNames) { try { const fragmentInfo = await this.sourceMapper.findFragmentInfo( matchingConfig.appId, fragmentName, activity ); if (fragmentInfo) { fragments.push(fragmentInfo); logger.debug(`[SOURCE_INDEXING] Found fragment source: ${fragmentInfo.sourceFile}`); } } catch (error) { logger.warn(`[SOURCE_INDEXING] Error finding fragment source for ${fragmentName}: ${error}`); } } // Find custom View source information const views: ViewInfo[] = []; const viewNames = this.extractViewNames(viewHierarchy); for (const viewName of viewNames) { try { const viewInfo = await this.sourceMapper.findViewInfo( matchingConfig.appId, viewName, activity || undefined, fragments.length > 0 ? fragments[0] : undefined ); if (viewInfo) { views.push(viewInfo); logger.debug(`[SOURCE_INDEXING] Found view source: ${viewInfo.sourceFile}`); } } catch (error) { logger.warn(`[SOURCE_INDEXING] Error finding view source for ${viewName}: ${error}`); } } // Find composable source information const composables: ComposableInfo[] = []; const composableNames = this.extractComposableNames(viewHierarchy); for (const composableName of composableNames) { try { const composableInfo = await this.sourceMapper.findComposableInfo( matchingConfig.appId, composableName, activity || undefined, fragments.length > 0 ? fragments[0] : undefined ); if (composableInfo) { composables.push(composableInfo); logger.debug(`[SOURCE_INDEXING] Found composable source: ${composableInfo.sourceFile}`); } } catch (error) { logger.warn(`[SOURCE_INDEXING] Error finding composable source for ${composableName}: ${error}`); } } // Add source information to the result viewHierarchy.sourceInfo = { activity: activity || undefined, fragments: fragments.length > 0 ? fragments : undefined, views: views.length > 0 ? views : undefined, composables: composables.length > 0 ? composables : undefined, appId: matchingConfig.appId }; logger.debug(`[SOURCE_INDEXING] Augmented view hierarchy with ${activity ? 1 : 0} activity, ${fragments.length} fragment, ${views.length} view, and ${composables.length} composable source references`); } catch (error) { logger.warn(`[SOURCE_INDEXING] Error during source indexing augmentation: ${error}`); } return viewHierarchy; } /** * Get current activity information from the device * @returns Current activity info or null */ private async getCurrentActivityInfo(): Promise<{ activityName: string; packageName: string } | null> { try { const result = await this.adb.executeCommand("shell dumpsys activity activities | grep -E 'mResumedActivity|mFocusedActivity' | head -1"); const output = result.stdout; // Parse the current activity from dumpsys output // Example: mResumedActivity: ActivityRecord{abc123 u0 com.example.myapp/.MainActivity t12345} const activityMatch = output.match(/ActivityRecord\{[^}]+\s+([^\s]+)\/([^\s]+)\s+/); if (activityMatch) { const packageName = activityMatch[1]; const activityPath = activityMatch[2]; // Extract class name from activity path (e.g., ".MainActivity" -> "MainActivity") const activityName = activityPath.startsWith(".") ? `${packageName}${activityPath}` : activityPath; return { activityName, packageName }; } logger.warn("[SOURCE_INDEXING] Could not parse current activity from dumpsys output"); return null; } catch (error) { logger.warn(`[SOURCE_INDEXING] Error getting current activity: ${error}`); return null; } } /** * Extract fragment names from view hierarchy augmentation data * @param viewHierarchy - The view hierarchy to search * @returns Array of fragment class names */ private extractFragmentNames(viewHierarchy: ViewHierarchyResult): string[] { const fragmentNames: string[] = []; // Traverse the hierarchy looking for fragment information added by augmentViewHierarchyWithClassAndFragment const traverseNode = (node: any): void => { if (node.fragment) { fragmentNames.push(node.fragment); } if (node.node) { const children = Array.isArray(node.node) ? node.node : [node.node]; for (const child of children) { traverseNode(child); } } }; if (viewHierarchy.hierarchy) { traverseNode(viewHierarchy.hierarchy); } // Remove duplicates return Array.from(new Set(fragmentNames)); } /** * Extract custom View class names from view hierarchy augmentation data * @param viewHierarchy - The view hierarchy to search * @returns Array of custom View class names */ private extractViewNames(viewHierarchy: ViewHierarchyResult): string[] { const viewNames: string[] = []; // Traverse the hierarchy looking for custom view class information added by augmentViewHierarchyWithClassAndFragment const traverseNode = (node: any): void => { // Check for custom view class from augmentation if (node.customView) { viewNames.push(node.customView); } // Check for class property that doesn't match Android framework patterns if (node.class && !node.class.match(/^(android\.|com\.android\.|androidx\.)/)) { // Exclude fragments and activities as they are handled separately if (!node.class.includes("Fragment") && !node.class.includes("Activity")) { viewNames.push(node.class); } } if (node.node) { const children = Array.isArray(node.node) ? node.node : [node.node]; for (const child of children) { traverseNode(child); } } }; if (viewHierarchy.hierarchy) { traverseNode(viewHierarchy.hierarchy); } // Remove duplicates return Array.from(new Set(viewNames)); } /** * Augment view hierarchy with class and fragment and custom view information from dumpsys activity top * @param viewHierarchy - The view hierarchy to augment * @param activityTopData - Class, fragment and view data from dumpsys activity top */ private augmentViewHierarchyWithClassAndFragment(viewHierarchy: ExtendedViewHierarchyResult, activityTopData: ActivityTopData): void { if (!viewHierarchy || !viewHierarchy.hierarchy) { return; } // Function to recursively augment nodes with class, fragment, view, and composable information const augmentNode = (node: any): void => { if (!node) { return; } // Check for class override based on resource-id const resourceId = node["resource-id"]; if (resourceId && activityTopData.classOverrides.has(resourceId)) { const customClass = activityTopData.classOverrides.get(resourceId); if (customClass) { node["class"] = customClass; } } // Check for class override based on bounds const bounds = node["bounds"]; if (bounds && activityTopData.classOverrides.has(bounds)) { const customClass = activityTopData.classOverrides.get(bounds); if (customClass) { node["class"] = customClass; } } // Add fragment information if the resource-id matches a fragment container if (resourceId && activityTopData.fragmentData.has(resourceId)) { const fragmentClass = activityTopData.fragmentData.get(resourceId); if (fragmentClass) { node["fragment"] = fragmentClass; } } // Add custom view information if the resource-id or bounds matches a custom view if (resourceId && activityTopData.viewData.has(resourceId)) { const viewClass = activityTopData.viewData.get(resourceId); if (viewClass) { node["customView"] = viewClass; } } else if (bounds && activityTopData.viewData.has(bounds)) { const viewClass = activityTopData.viewData.get(bounds); if (viewClass) { node["customView"] = viewClass; } } // Mark composable if node has "composable" property if (node.composable && typeof node.composable === "string" && node.composable.length > 0) { node["isComposable"] = true; // Optionally, establish a standard property for composable name node["composableName"] = node.composable; } // Recursively augment child nodes if (node.node) { const children = Array.isArray(node.node) ? node.node : [node.node]; for (const child of children) { augmentNode(child); } } }; // Start augmentation from the root node augmentNode(viewHierarchy.hierarchy); } /** * Extract composable names from view hierarchy augmentation data * @param viewHierarchy - The view hierarchy to search * @returns Array of composable names */ private extractComposableNames(viewHierarchy: ViewHierarchyResult): string[] { const composableNames: string[] = []; // Traverse the hierarchy looking for composable information const traverseNode = (node: any): void => { // Best-effort heuristic: look for nodes marked as composable or having a composable name if (node.composable && typeof node.composable === "string" && node.composable.length > 0) { composableNames.push(node.composable); } else if (node.composableName && typeof node.composableName === "string" && node.composableName.length > 0) { composableNames.push(node.composableName); } if (node.node) { const children = Array.isArray(node.node) ? node.node : [node.node]; for (const child of children) { traverseNode(child); } } }; if (viewHierarchy.hierarchy) { traverseNode(viewHierarchy.hierarchy); } // Remove duplicates return Array.from(new Set(composableNames)); } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/zillow/auto-mobile'

If you have feedback or need assistance with the MCP directory API, please join our Discord server