Skip to main content
Glama
deepLinkManager.ts13.5 kB
import { logger } from "./logger"; import { AdbUtils } from "./android-cmdline-tools/adb"; import { DeepLinkResult, IntentFilter, DeepLinkInfo, IntentChooserResult, ViewHierarchyResult, BootedDevice } from "../models"; import { ElementUtils } from "../features/utility/ElementUtils"; export class DeepLinkManager { private adbUtils: AdbUtils; private elementUtils: ElementUtils; constructor(device: BootedDevice | null = null) { this.adbUtils = new AdbUtils(device); this.elementUtils = new ElementUtils(); } /** * Set the target device ID * @param deviceId - Device identifier */ setDeviceId(device: BootedDevice): void { this.adbUtils.setDevice(device); } /** * Get deep links for an application by querying the package manager * @param appId - The application package ID * @returns Promise with deep link information */ async getDeepLinks(appId: string): Promise<DeepLinkResult> { try { logger.info(`[DeepLinkManager] Querying deep links for app: ${appId}`); // Use dumpsys package to get detailed package information including intent filters const packageInfoResult = await this.adbUtils.executeCommand( `shell dumpsys package ${appId}` ); // Parse the results const deepLinks = this.parsePackageDumpsysOutput(appId, packageInfoResult.stdout); return { success: true, appId, deepLinks, rawOutput: packageInfoResult.stdout }; } catch (error) { logger.error(`[DeepLinkManager] Failed to get deep links for ${appId}: ${error}`); return { success: false, appId, deepLinks: { schemes: [], hosts: [], intentFilters: [], supportedMimeTypes: [] }, error: error instanceof Error ? error.message : String(error) }; } } /** * Parse deep link results from dumpsys package output * @param appId - The application package ID * @param dumpsysOutput - Output from dumpsys package command * @returns Parsed deep link information */ private parsePackageDumpsysOutput(appId: string, dumpsysOutput: string): DeepLinkInfo { const schemes = new Set<string>(); const hosts = new Set<string>(); const intentFilters: IntentFilter[] = []; const supportedMimeTypes = new Set<string>(); const lines = dumpsysOutput.split("\n"); let inSchemesSection = false; let inIntentFilterSection = false; let currentFilter: Partial<IntentFilter> = {}; for (let i = 0; i < lines.length; i++) { const line = lines[i].trim(); // Detect start of Schemes section if (line === "Schemes:") { inSchemesSection = true; continue; } // Process schemes section if (inSchemesSection) { if (line === "" || line.startsWith("Non-Data Actions:") || line.startsWith("Receiver Resolver Table:")) { inSchemesSection = false; continue; } // Parse scheme entries (format: "scheme:") const schemeMatch = line.match(/^([a-zA-Z][a-zA-Z0-9+.-]*):$/); if (schemeMatch) { const scheme = schemeMatch[1]; schemes.add(scheme); // Look ahead for authority information if (i + 1 < lines.length) { const nextLine = lines[i + 1].trim(); const authorityMatch = nextLine.match(/^([a-fA-F0-9]+)\s+.*filter\s+([a-fA-F0-9]+)$/); if (authorityMatch) { // Look for Authority line in the following lines for (let j = i + 2; j < Math.min(i + 10, lines.length); j++) { const authLine = lines[j].trim(); const hostMatch = authLine.match(/^Authority:\s+"([^"]+)":\s*-?\d+$/); if (hostMatch) { hosts.add(hostMatch[1]); break; } } } } } } // Process intent filter details if (line.includes("Action:") && line.includes("android.intent.action.VIEW")) { inIntentFilterSection = true; currentFilter = { action: "android.intent.action.VIEW", category: [], data: [] }; } if (inIntentFilterSection) { if (line.startsWith("Category:")) { const category = line.replace("Category:", "").trim().replace(/"/g, ""); if (currentFilter.category) { currentFilter.category.push(category); } } if (line.startsWith("Scheme:")) { const scheme = line.replace("Scheme:", "").trim().replace(/"/g, ""); schemes.add(scheme); if (!currentFilter.data) {currentFilter.data = [];} currentFilter.data.push({ scheme }); } if (line.startsWith("Authority:")) { const authorityMatch = line.match(/^Authority:\s+"([^"]+)":\s*-?\d+$/); if (authorityMatch) { const host = authorityMatch[1]; hosts.add(host); if (!currentFilter.data) {currentFilter.data = [];} // Find existing data entry with scheme or create new one const lastDataEntry = currentFilter.data[currentFilter.data.length - 1]; if (lastDataEntry && !lastDataEntry.host) { lastDataEntry.host = host; } else { currentFilter.data.push({ host }); } } } if (line.startsWith("Type:")) { const mimeType = line.replace("Type:", "").trim().replace(/"/g, ""); supportedMimeTypes.add(mimeType); if (!currentFilter.data) {currentFilter.data = [];} currentFilter.data.push({ mimeType }); } // End of current intent filter if (line === "" || (line.includes("filter") && line.includes("Action:"))) { if (currentFilter.action) { intentFilters.push(currentFilter as IntentFilter); currentFilter = {}; inIntentFilterSection = false; } } } } // Add the last filter if we were still processing one if (inIntentFilterSection && currentFilter.action) { intentFilters.push(currentFilter as IntentFilter); } return { schemes: Array.from(schemes), hosts: Array.from(hosts), intentFilters, supportedMimeTypes: Array.from(supportedMimeTypes) }; } /** * Detect system intent chooser dialog in view hierarchy * @param viewHierarchy - Current view hierarchy result * @returns True if intent chooser is detected */ detectIntentChooser(viewHierarchy: ViewHierarchyResult): boolean { try { // If the hierarchy is empty, return false if (!viewHierarchy || !viewHierarchy.hierarchy || !viewHierarchy.hierarchy.node) { return false; } // Look for common intent chooser indicators const textIndicators = [ "Choose an app", "Open with", "Complete action using", "Always", "Just once" ]; const classIndicators = [ "com.android.internal.app.ChooserActivity", "com.android.internal.app.ResolverActivity" ]; const resourceIdIndicators = [ "android:id/button_always", "android:id/button_once", "resolver_list", "chooser_list" ]; // Get root nodes from the view hierarchy const rootNodes = this.elementUtils.extractRootNodes(viewHierarchy); // Check all nodes in the hierarchy for (const rootNode of rootNodes) { let foundIndicator = false; this.elementUtils.traverseNode(rootNode, (node: any) => { if (foundIndicator) {return;} const nodeProperties = this.elementUtils.extractNodeProperties(node); const nodeClass = nodeProperties.class || ""; const nodeText = nodeProperties.text || nodeProperties["content-desc"] || ""; const nodeResourceId = nodeProperties["resource-id"] || ""; // Check for class indicators for (const className of classIndicators) { if (nodeClass.includes(className)) { foundIndicator = true; return; } } // Check for text indicators (exact match) for (const text of textIndicators) { if (nodeText === text) { foundIndicator = true; return; } } // Check for resource ID indicators for (const resourceId of resourceIdIndicators) { if (nodeResourceId.includes(resourceId)) { foundIndicator = true; return; } } }); if (foundIndicator) { return true; } } return false; } catch (error) { logger.warn(`[DeepLinkManager] Error detecting intent chooser: ${error}`); return false; } } /** * Handle system intent chooser dialog automatically * @param viewHierarchy - Current view hierarchy result * @param preference - User preference for handling ("always", "just_once", or "custom") * @param customAppPackage - Optional specific app package to select * @returns Result of intent chooser handling */ async handleIntentChooser( viewHierarchy: ViewHierarchyResult, preference: "always" | "just_once" | "custom" = "just_once", customAppPackage?: string ): Promise<IntentChooserResult> { try { const detected = this.detectIntentChooser(viewHierarchy); if (!detected) { return { success: true, detected: false }; } logger.info(`[DeepLinkManager] Intent chooser detected, preference: ${preference}`); // Parse the view hierarchy to find buttons const rootNodes = this.elementUtils.extractRootNodes(viewHierarchy); let targetElement = null; if (preference === "always") { // Look for "Always" button for (const rootNode of rootNodes) { targetElement = this.findButtonByText(rootNode, ["Always", "ALWAYS"]); if (targetElement) {break;} } } else if (preference === "just_once") { // Look for "Just once" button for (const rootNode of rootNodes) { targetElement = this.findButtonByText(rootNode, ["Just once", "JUST ONCE", "Once"]); if (targetElement) {break;} } } else if (preference === "custom" && customAppPackage) { // Look for specific app in the list for (const rootNode of rootNodes) { targetElement = this.findAppInChooser(rootNode, customAppPackage); if (targetElement) {break;} } } if (targetElement) { // Simulate tap on the target element const center = this.elementUtils.getElementCenter(targetElement); await this.adbUtils.executeCommand(`shell input tap ${center.x} ${center.y}`); logger.info(`[DeepLinkManager] Tapped on intent chooser option at (${center.x}, ${center.y})`); return { success: true, detected: true, action: preference, appSelected: customAppPackage }; } else { return { success: false, detected: true, error: `Could not find target element for preference: ${preference}` }; } } catch (error) { logger.error(`[DeepLinkManager] Failed to handle intent chooser: ${error}`); return { success: false, detected: true, error: error instanceof Error ? error.message : String(error) }; } } /** * Find a button by text content in the view hierarchy * @param node - Root node to search from * @param textOptions - Array of text options to match * @returns Found element or null */ private findButtonByText(node: any, textOptions: string[]): any { let foundElement: any = null; this.elementUtils.traverseNode(node, (currentNode: any) => { if (foundElement) {return;} // Already found const properties = this.elementUtils.extractNodeProperties(currentNode); const text = properties.text || properties["content-desc"] || ""; const className = properties.class || ""; // Check if this is a button-like element with matching text if ((className.includes("Button") || className.includes("TextView")) && textOptions.some(option => text.toLowerCase().includes(option.toLowerCase()))) { foundElement = currentNode; } }); return foundElement; } /** * Find a specific app in the intent chooser list * @param node - Root node to search from * @param appPackage - App package to find * @returns Found element or null */ private findAppInChooser(node: any, appPackage: string): any { let foundElement: any = null; this.elementUtils.traverseNode(node, (currentNode: any) => { if (foundElement) {return;} // Already found const properties = this.elementUtils.extractNodeProperties(currentNode); const resourceId = properties["resource-id"] || ""; const text = properties.text || properties["content-desc"] || ""; // Check if this element references the target app if (resourceId.includes(appPackage) || text.includes(appPackage)) { foundElement = currentNode; } }); return foundElement; } }

Implementation Reference

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