diff --git a/.claude/mcp/android-device-server/.gitignore b/.claude/mcp/android-device-server/.gitignore new file mode 100644 index 0000000000..2f9844be11 --- /dev/null +++ b/.claude/mcp/android-device-server/.gitignore @@ -0,0 +1,37 @@ +# Dependencies +node_modules/ +package-lock.json + +# Build output +build/ +dist/ +*.js +*.js.map +*.d.ts +*.d.ts.map + +# Keep source TypeScript files +!src/**/*.ts + +# Environment variables +.env +.env.local +.env.*.local + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store + +# Logs +*.log + +# Testing +coverage/ + +# Temporary files +tmp/ +temp/ \ No newline at end of file diff --git a/.claude/mcp/android-device-server/package.json b/.claude/mcp/android-device-server/package.json new file mode 100644 index 0000000000..d2fe7ed30e --- /dev/null +++ b/.claude/mcp/android-device-server/package.json @@ -0,0 +1,34 @@ +{ + "name": "@bitwarden/android-device-mcp", + "version": "1.0.0", + "description": "MCP server for Android device interaction via ADB — UI hierarchy capture, element finding with obstruction detection, tap, and navigation", + "type": "module", + "main": "build/index.js", + "bin": { + "android-device-mcp": "./build/index.js" + }, + "scripts": { + "build": "tsc && chmod +x build/index.js", + "watch": "tsc --watch", + "dev": "tsc && node build/index.js", + "inspector": "npx @modelcontextprotocol/inspector build/index.js", + "test": "vitest run", + "test:watch": "vitest" + }, + "keywords": ["mcp", "android", "adb", "model-context-protocol", "ui-testing"], + "author": "Bitwarden", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/sdk": "1.27.1", + "fast-xml-parser": "^4.5.0", + "zod": "3.24.2" + }, + "devDependencies": { + "@types/node": "20.19.35", + "typescript": "5.8.3", + "vitest": "3.1.1" + }, + "engines": { + "node": ">=18.0.0" + } +} \ No newline at end of file diff --git a/.claude/mcp/android-device-server/src/adb/adb.spec.ts b/.claude/mcp/android-device-server/src/adb/adb.spec.ts new file mode 100644 index 0000000000..f1d5061741 --- /dev/null +++ b/.claude/mcp/android-device-server/src/adb/adb.spec.ts @@ -0,0 +1,60 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('node:fs', () => ({ + existsSync: vi.fn(() => false), +})); + +vi.mock('node:child_process', () => ({ + execFileSync: vi.fn(() => { throw new Error('not found'); }), + execFile: vi.fn(), +})); + +import { existsSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { findAdb, _resetCache } from './adb.js'; + +const mockExistsSync = vi.mocked(existsSync); +const mockExecFileSync = vi.mocked(execFileSync); + +describe('findAdb', () => { + beforeEach(() => { + vi.clearAllMocks(); + _resetCache(); + // Default: which fails, nothing on disk + mockExecFileSync.mockImplementation(() => { throw new Error('not found'); }); + mockExistsSync.mockReturnValue(false); + }); + + it('finds adb in PATH via which', () => { + mockExecFileSync.mockReturnValue('/usr/local/bin/adb\n' as any); + expect(findAdb()).toBe('/usr/local/bin/adb'); + }); + + it('finds adb in Android SDK location', () => { + mockExistsSync.mockImplementation((path) => + String(path).includes('Library/Android/sdk'), + ); + expect(findAdb()).toContain('Library/Android/sdk/platform-tools/adb'); + }); + + it('finds adb in /usr/local/bin', () => { + mockExistsSync.mockImplementation((path) => + String(path) === '/usr/local/bin/adb', + ); + expect(findAdb()).toBe('/usr/local/bin/adb'); + }); + + it('throws when adb not found anywhere', () => { + expect(() => findAdb()).toThrow('ADB not found'); + }); + + it('caches the result after first discovery', () => { + mockExistsSync.mockImplementation((path) => + String(path) === '/usr/local/bin/adb', + ); + findAdb(); + findAdb(); + // existsSync only called during first discovery, cached after + expect(mockExistsSync).toHaveBeenCalledTimes(2); // SDK path + /usr/local/bin + }); +}); diff --git a/.claude/mcp/android-device-server/src/adb/adb.ts b/.claude/mcp/android-device-server/src/adb/adb.ts new file mode 100644 index 0000000000..8d6b16e6d7 --- /dev/null +++ b/.claude/mcp/android-device-server/src/adb/adb.ts @@ -0,0 +1,141 @@ +/** + * ADB client wrapper using child_process.execFile for safe command execution. + * Uses execFile (not exec) to prevent shell injection — arguments are passed + * as an array, never interpolated into a shell string. + */ + +import { execFile as execFileCb, execFileSync } from 'node:child_process'; +import { promisify } from 'node:util'; +import { existsSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +const execFile = promisify(execFileCb); + +let cachedAdbPath: string | null = null; + +/** Clear the cached ADB path (for testing). */ +export function _resetCache(): void { + cachedAdbPath = null; +} + +/** + * Discover ADB binary location. + * Checks: PATH → ~/Library/Android/sdk/platform-tools/adb → /usr/local/bin/adb + */ +export function findAdb(): string { + if (cachedAdbPath) return cachedAdbPath; + + // Check PATH via `which` + try { + const result = execFileSync('which', ['adb'], { encoding: 'utf-8' }).trim(); + if (result) { + cachedAdbPath = result; + return result; + } + } catch { + // Not in PATH, try common locations + } + + const candidates = [ + join(homedir(), 'Library', 'Android', 'sdk', 'platform-tools', 'adb'), + '/usr/local/bin/adb', + ]; + + for (const candidate of candidates) { + if (existsSync(candidate)) { + cachedAdbPath = candidate; + return candidate; + } + } + + throw new Error( + 'ADB not found. Install the Android SDK or add platform-tools to PATH.', + ); +} + +/** + * Execute an ADB command and return stdout. + */ +export async function exec(args: string[]): Promise { + const adb = findAdb(); + const { stdout } = await execFile(adb, args, { + maxBuffer: 10 * 1024 * 1024, // 10MB for large dumps + encoding: 'utf-8', + }); + return stdout; +} + +/** + * Execute an ADB shell command. + */ +export async function shell(command: string): Promise { + return exec(['shell', command]); +} + +/** + * Dump UI hierarchy to device, then pull to local path. + */ +export async function dumpHierarchy(outputPath: string): Promise { + await shell('uiautomator dump /sdcard/view.xml'); + await exec(['pull', '/sdcard/view.xml', outputPath]); +} + +/** + * Capture screenshot to device, then pull to local path. + */ +export async function screenshot(outputPath: string): Promise { + await shell('screencap -p /sdcard/screen.png'); + await exec(['pull', '/sdcard/screen.png', outputPath]); +} + +/** + * Tap at screen coordinates. + */ +export async function tap(x: number, y: number): Promise { + await shell(`input tap ${Math.floor(x)} ${Math.floor(y)}`); +} + +/** + * Send a key event. + */ +export async function keyevent(code: number): Promise { + await shell(`input keyevent ${code}`); +} + +/** + * Perform a swipe gesture. + */ +export async function swipe( + x1: number, + y1: number, + x2: number, + y2: number, + durationMs: number, +): Promise { + await shell(`input swipe ${x1} ${y1} ${x2} ${y2} ${durationMs}`); +} + +/** + * Get screen dimensions. + */ +export async function getScreenSize(): Promise<{ width: number; height: number }> { + const output = await shell('wm size'); + const match = output.match(/(\d+)x(\d+)/); + if (!match) throw new Error(`Could not parse screen size from: ${output}`); + return { width: parseInt(match[1], 10), height: parseInt(match[2], 10) }; +} + +/** + * Get raw dumpsys window windows output. + */ +export async function dumpsysWindows(): Promise { + return shell('dumpsys window windows'); +} + +/** + * Wait for a specified duration (seconds). + */ +export function sleep(seconds: number): Promise { + return new Promise(resolve => setTimeout(resolve, seconds * 1000)); +} diff --git a/.claude/mcp/android-device-server/src/geometry/bounds.ts b/.claude/mcp/android-device-server/src/geometry/bounds.ts new file mode 100644 index 0000000000..2343f232f6 --- /dev/null +++ b/.claude/mcp/android-device-server/src/geometry/bounds.ts @@ -0,0 +1,54 @@ +/** + * Geometric primitives for UI element bounds and point operations. + */ + +export interface Point { + x: number; + y: number; +} + +export interface Rect { + left: number; + top: number; + right: number; + bottom: number; +} + +export function center(r: Rect): Point { + return { + x: Math.floor((r.left + r.right) / 2), + y: Math.floor((r.top + r.bottom) / 2), + }; +} + +export function area(r: Rect): number { + const w = r.right - r.left; + const h = r.bottom - r.top; + return w > 0 && h > 0 ? w * h : 0; +} + +export function containsPoint(r: Rect, p: Point): boolean { + return p.x >= r.left && p.x <= r.right && p.y >= r.top && p.y <= r.bottom; +} + +export function overlaps(a: Rect, b: Rect): boolean { + return !(a.left >= b.right || a.right <= b.left || a.top >= b.bottom || a.bottom <= b.top); +} + +export function boundsEqual(a: Rect, b: Rect): boolean { + return a.left === b.left && a.top === b.top && a.right === b.right && a.bottom === b.bottom; +} + +/** + * Parse Android bounds string "[left,top][right,bottom]" into a Rect. + */ +export function parseBounds(bounds: string): Rect | null { + const match = bounds.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), + }; +} diff --git a/.claude/mcp/android-device-server/src/geometry/geometry.spec.ts b/.claude/mcp/android-device-server/src/geometry/geometry.spec.ts new file mode 100644 index 0000000000..614d18126e --- /dev/null +++ b/.claude/mcp/android-device-server/src/geometry/geometry.spec.ts @@ -0,0 +1,334 @@ +import { describe, it, expect } from 'vitest'; +import { + center, + area, + containsPoint, + overlaps, + boundsEqual, + parseBounds, + type Rect, +} from './bounds.js'; +import { largestVisibleStrip } from './visible-region.js'; +import { detectObstruction } from './obstruction.js'; +import type { UiNode } from '../parsers/xml.js'; +import type { WindowInfo } from '../parsers/dumpsys.js'; + +describe('bounds', () => { + const rect: Rect = { left: 100, top: 200, right: 500, bottom: 600 }; + + describe('center', () => { + it('returns the center point of a rect', () => { + expect(center(rect)).toEqual({ x: 300, y: 400 }); + }); + + it('floors fractional centers', () => { + expect(center({ left: 0, top: 0, right: 101, bottom: 101 })).toEqual({ x: 50, y: 50 }); + }); + }); + + describe('area', () => { + it('computes area of a valid rect', () => { + expect(area(rect)).toBe(400 * 400); + }); + + it('returns 0 for zero-width rect', () => { + expect(area({ left: 100, top: 200, right: 100, bottom: 600 })).toBe(0); + }); + + it('returns 0 for inverted rect', () => { + expect(area({ left: 500, top: 200, right: 100, bottom: 600 })).toBe(0); + }); + }); + + describe('containsPoint', () => { + it('returns true for point inside rect', () => { + expect(containsPoint(rect, { x: 300, y: 400 })).toBe(true); + }); + + it('returns true for point on edge', () => { + expect(containsPoint(rect, { x: 100, y: 200 })).toBe(true); + expect(containsPoint(rect, { x: 500, y: 600 })).toBe(true); + }); + + it('returns false for point outside rect', () => { + expect(containsPoint(rect, { x: 50, y: 400 })).toBe(false); + expect(containsPoint(rect, { x: 300, y: 700 })).toBe(false); + }); + }); + + describe('overlaps', () => { + it('returns true for overlapping rects', () => { + expect(overlaps(rect, { left: 400, top: 500, right: 700, bottom: 800 })).toBe(true); + }); + + it('returns false for non-overlapping rects', () => { + expect(overlaps(rect, { left: 600, top: 200, right: 800, bottom: 600 })).toBe(false); + }); + + it('returns false for adjacent rects (touching edges)', () => { + expect(overlaps(rect, { left: 500, top: 200, right: 700, bottom: 600 })).toBe(false); + }); + }); + + describe('boundsEqual', () => { + it('returns true for identical rects', () => { + expect(boundsEqual(rect, { ...rect })).toBe(true); + }); + + it('returns false for different rects', () => { + expect(boundsEqual(rect, { ...rect, right: 501 })).toBe(false); + }); + }); + + describe('parseBounds', () => { + it('parses Android bounds string', () => { + expect(parseBounds('[100,200][500,600]')).toEqual(rect); + }); + + it('parses zero-origin bounds', () => { + expect(parseBounds('[0,0][1080,2400]')).toEqual({ + left: 0, + top: 0, + right: 1080, + bottom: 2400, + }); + }); + + it('parses bounds with negative origin (partially off-screen element)', () => { + expect(parseBounds('[-40,-20][1040,100]')).toEqual({ + left: -40, + top: -20, + right: 1040, + bottom: 100, + }); + }); + + it('returns null for invalid format', () => { + expect(parseBounds('invalid')).toBeNull(); + expect(parseBounds('[100,200]')).toBeNull(); + }); + }); +}); + +describe('visible-region', () => { + // Target element: a list row spanning most of the screen width + const target: Rect = { left: 42, top: 1855, right: 1038, bottom: 2025 }; + + describe('largestVisibleStrip', () => { + it('returns null when fully obscured', () => { + const obstructor: Rect = { left: 0, top: 1800, right: 1080, bottom: 2100 }; + expect(largestVisibleStrip(target, obstructor)).toBeNull(); + }); + + it('finds bottom strip when obstructor covers top portion', () => { + const obstructor: Rect = { left: 0, top: 1800, right: 1080, bottom: 1940 }; + const result = largestVisibleStrip(target, obstructor); + expect(result).not.toBeNull(); + expect(result!.rect.top).toBe(1940); + expect(result!.rect.bottom).toBe(2025); + }); + + it('finds left strip when FAB covers right side', () => { + // FAB in bottom-right corner + const fab: Rect = { left: 891, top: 1875, right: 1038, bottom: 2022 }; + const result = largestVisibleStrip(target, fab); + expect(result).not.toBeNull(); + // Left strip should be largest (full height, left portion) + expect(result!.rect.left).toBe(42); + expect(result!.rect.right).toBe(891); + expect(result!.area).toBeGreaterThan(0); + }); + + it('picks the largest strip among candidates', () => { + // Small obstructor in the center — all 4 strips available + const small: Rect = { left: 400, top: 1900, right: 600, bottom: 1980 }; + const result = largestVisibleStrip(target, small); + expect(result).not.toBeNull(); + // Left strip: (400-42) * (2025-1855) = 358 * 170 = 60860 + // Right strip: (1038-600) * 170 = 438 * 170 = 74460 + // Right strip should win + expect(result!.rect.left).toBe(600); + expect(result!.rect.right).toBe(1038); + }); + + it('returns center point of the visible strip', () => { + const fab: Rect = { left: 891, top: 1875, right: 1038, bottom: 2022 }; + const result = largestVisibleStrip(target, fab); + expect(result).not.toBeNull(); + expect(result!.center.x).toBe(Math.floor((42 + 891) / 2)); + expect(result!.center.y).toBe(Math.floor((1855 + 2025) / 2)); + }); + }); +}); + +describe('obstruction detection', () => { + // Helper to create a minimal UiNode + function makeNode(overrides: Partial = {}): UiNode { + return { + text: '', contentDesc: '', resourceId: '', className: '', + packageName: '', bounds: null, clickable: false, focused: false, + enabled: true, selected: false, drawingOrder: 0, children: [], + ...overrides, + }; + } + + const archiveRow = makeNode({ + text: 'Archive', + bounds: { left: 42, top: 1855, right: 1038, bottom: 2025 }, + clickable: true, + }); + + const fab = makeNode({ + contentDesc: 'Add Item', + bounds: { left: 891, top: 1875, right: 1038, bottom: 2022 }, + clickable: true, + }); + + // Hierarchy: root contains archiveRow and fab (fab is later = higher z-order) + const hierarchy = makeNode({ + bounds: { left: 0, top: 0, right: 1080, bottom: 2400 }, + children: [archiveRow, fab], + }); + + const noOverlayWindows: WindowInfo[] = []; + + describe('clear path', () => { + it('returns not obstructed when target is the topmost clickable', () => { + // Tap center of archive row — only archiveRow contains this point, no FAB + const result = detectObstruction({ + hierarchy, + windows: noOverlayWindows, + targetElement: archiveRow, + tapPoint: { x: 200, y: 1940 }, + searchText: 'Archive', + }); + expect(result.obstructed).toBe(false); + }); + }); + + describe('FAB obstruction', () => { + it('detects FAB overlapping the target center', () => { + // Tap at a point where both archive row and FAB overlap — FAB is later in tree + const result = detectObstruction({ + hierarchy, + windows: noOverlayWindows, + targetElement: archiveRow, + tapPoint: { x: 965, y: 1948 }, + searchText: 'Archive', + }); + expect(result.obstructed).toBe(true); + if (result.obstructed) { + expect(result.obstructor).toContain('Add Item'); + expect(result.adjustedPoint).not.toBeNull(); + expect(result.fullyObscured).toBe(false); + // Adjusted point should be in the left strip (away from FAB) + expect(result.adjustedPoint!.x).toBeLessThan(891); + } + }); + }); + + describe('system overlay', () => { + it('detects TalkBack FloatingMenu overlay at tap point', () => { + const talkbackWindows: WindowInfo[] = [ + { + name: 'FloatingMenu', + type: 'NAVIGATION_BAR_PANEL', + hasSurface: true, + touchableRegion: { left: 891, top: 1875, right: 1038, bottom: 2022 }, + }, + ]; + + const result = detectObstruction({ + hierarchy, + windows: talkbackWindows, + targetElement: archiveRow, + tapPoint: { x: 965, y: 1948 }, + searchText: 'Archive', + }); + expect(result.obstructed).toBe(true); + if (result.obstructed) { + expect(result.obstructor).toContain('FloatingMenu'); + expect(result.adjustedPoint).not.toBeNull(); + } + }); + + it('system overlay takes precedence over in-app elements', () => { + // Both a system overlay and FAB at the same point — system overlay detected first + const talkbackWindows: WindowInfo[] = [ + { + name: 'FloatingMenu', + type: 'NAVIGATION_BAR_PANEL', + hasSurface: true, + touchableRegion: { left: 891, top: 1875, right: 1038, bottom: 2022 }, + }, + ]; + + const result = detectObstruction({ + hierarchy, + windows: talkbackWindows, + targetElement: archiveRow, + tapPoint: { x: 965, y: 1948 }, + searchText: 'Archive', + }); + expect(result.obstructed).toBe(true); + if (result.obstructed) { + expect(result.obstructor).toContain('system_overlay'); + } + }); + }); + + describe('fully obscured', () => { + it('reports fully obscured when obstructor covers entire target', () => { + const fullScreenOverlay: WindowInfo[] = [ + { + name: 'SystemDialog', + type: 'SYSTEM_ALERT', + hasSurface: true, + touchableRegion: { left: 0, top: 0, right: 1080, bottom: 2400 }, + }, + ]; + + const result = detectObstruction({ + hierarchy, + windows: fullScreenOverlay, + targetElement: archiveRow, + tapPoint: { x: 540, y: 1940 }, + searchText: 'Archive', + }); + expect(result.obstructed).toBe(true); + if (result.obstructed) { + expect(result.fullyObscured).toBe(true); + expect(result.adjustedPoint).toBeNull(); + } + }); + }); + + describe('Compose parent wrapper', () => { + it('treats identical bounds as parent wrapper, not obstruction', () => { + // Compose pattern: clickable parent has same bounds as text child + const textChild = makeNode({ + contentDesc: 'Download now', + bounds: { left: 84, top: 553, right: 996, bottom: 679 }, + clickable: false, + }); + const clickableParent = makeNode({ + bounds: { left: 84, top: 553, right: 996, bottom: 679 }, + clickable: true, + children: [textChild], + }); + const tree = makeNode({ + bounds: { left: 0, top: 0, right: 1080, bottom: 2400 }, + children: [clickableParent], + }); + + const result = detectObstruction({ + hierarchy: tree, + windows: noOverlayWindows, + targetElement: textChild, + tapPoint: { x: 540, y: 616 }, + searchText: 'Download now', + }); + expect(result.obstructed).toBe(false); + }); + }); +}); diff --git a/.claude/mcp/android-device-server/src/geometry/obstruction.ts b/.claude/mcp/android-device-server/src/geometry/obstruction.ts new file mode 100644 index 0000000000..330b73616b --- /dev/null +++ b/.claude/mcp/android-device-server/src/geometry/obstruction.ts @@ -0,0 +1,127 @@ +/** + * Two-layer obstruction detection for UI elements. + * + * Layer 1: System overlay windows (TalkBack, PiP, accessibility services) + * detected via parsed `dumpsys window windows` output. + * Layer 2: In-app elements (FABs, dialogs, bottom sheets) detected via + * the UIAutomator XML hierarchy — topmost clickable at tap point. + * + * When obstruction is found, computes an alternative tap point using the + * largest visible strip of the target element not covered by the obstructor. + */ + +import { type Point, type Rect, center, boundsEqual } from './bounds.js'; +import { largestVisibleStrip, type VisibleStrip } from './visible-region.js'; +import { type UiNode, findTopmostClickableAt } from '../parsers/xml.js'; +import { type WindowInfo, findOverlayAtPoint } from '../parsers/dumpsys.js'; + +export type ObstructionResult = + | { obstructed: false } + | { + obstructed: true; + obstructor: string; + obstructorBounds: Rect; + adjustedPoint: Point | null; + visibleRegion: VisibleStrip | null; + fullyObscured: boolean; + }; + +export interface DetectObstructionParams { + hierarchy: UiNode; + windows: WindowInfo[]; + targetElement: UiNode; + tapPoint: Point; + searchText: string; +} + +/** + * Detect if the tap point is obstructed by a system overlay or in-app element. + */ +export function detectObstruction(params: DetectObstructionParams): ObstructionResult { + const { hierarchy, windows, targetElement, tapPoint, searchText } = params; + + // Layer 1: System overlays (TalkBack, PiP, accessibility services) + const overlay = findOverlayAtPoint(windows, tapPoint); + if (overlay) { + return buildResult( + `system_overlay window=${overlay.name} type=${overlay.type}`, + overlay.touchableRegion!, + targetElement, + ); + } + + // Layer 2: In-app elements (FABs, dialogs, bottom sheets) + const topmost = findTopmostClickableAt(hierarchy, tapPoint); + if (topmost && topmost.bounds) { + // Check if topmost IS the target (no obstruction) + if (isTargetMatch(topmost, targetElement, searchText)) { + return { obstructed: false }; + } + + return buildResult( + formatElementId(topmost), + topmost.bounds, + targetElement, + ); + } + + return { obstructed: false }; +} + +/** + * Check if the topmost clickable element matches the target. + * + * Match criteria: + * - Search text appears in topmost's text or contentDesc + * - Bounds are identical (Compose parent wrapper pattern) + */ +function isTargetMatch(topmost: UiNode, target: UiNode, searchText: string): boolean { + const lower = searchText.toLowerCase(); + + // Text/content-desc match + if (topmost.text.toLowerCase().includes(lower)) return true; + if (topmost.contentDesc.toLowerCase().includes(lower)) return true; + + // Bounds equality (Compose parent wrapper) + if (target.bounds && topmost.bounds && boundsEqual(target.bounds, topmost.bounds)) { + return true; + } + + return false; +} + +function buildResult( + obstructorId: string, + obstructorBounds: Rect, + target: UiNode, +): ObstructionResult { + if (!target.bounds) { + return { + obstructed: true, + obstructor: obstructorId, + obstructorBounds, + adjustedPoint: null, + visibleRegion: null, + fullyObscured: true, + }; + } + + const strip = largestVisibleStrip(target.bounds, obstructorBounds); + + return { + obstructed: true, + obstructor: obstructorId, + obstructorBounds, + adjustedPoint: strip?.center ?? null, + visibleRegion: strip ?? null, + fullyObscured: strip === null, + }; +} + +function formatElementId(node: UiNode): string { + if (node.text) return `text="${node.text}"`; + if (node.contentDesc) return `desc="${node.contentDesc}"`; + if (node.resourceId) return `id="${node.resourceId}"`; + if (node.bounds) return `bounds=[${node.bounds.left},${node.bounds.top}][${node.bounds.right},${node.bounds.bottom}]`; + return 'unknown'; +} diff --git a/.claude/mcp/android-device-server/src/geometry/visible-region.ts b/.claude/mcp/android-device-server/src/geometry/visible-region.ts new file mode 100644 index 0000000000..7d7b79525a --- /dev/null +++ b/.claude/mcp/android-device-server/src/geometry/visible-region.ts @@ -0,0 +1,91 @@ +/** + * Visible region computation for partially obstructed UI elements. + * + * When a target element is partially covered by an obstructor (FAB, PiP, dialog), + * this module finds the largest unobstructed rectangular strip and returns its + * center as an alternative tap point. + */ + +import { type Rect, type Point, area, center } from './bounds.js'; + +export interface VisibleStrip { + rect: Rect; + center: Point; + area: number; +} + +/** + * Find the largest visible rectangular strip of the target not covered by the obstructor. + * + * Evaluates 4 candidate strips: + * - Top: above the obstructor, full target width + * - Bottom: below the obstructor, full target width + * - Left: left of the obstructor, full target height + * - Right: right of the obstructor, full target height + * + * Returns the strip with the largest area, or null if fully obscured. + */ +export function largestVisibleStrip(target: Rect, obstructor: Rect): VisibleStrip | null { + const candidates: Rect[] = []; + + // Top strip: above obstructor, full target width + if (obstructor.top > target.top) { + candidates.push({ + left: target.left, + top: target.top, + right: target.right, + bottom: obstructor.top, + }); + } + + // Bottom strip: below obstructor, full target width + if (obstructor.bottom < target.bottom) { + candidates.push({ + left: target.left, + top: obstructor.bottom, + right: target.right, + bottom: target.bottom, + }); + } + + // Left strip: left of obstructor, full target height + if (obstructor.left > target.left) { + candidates.push({ + left: target.left, + top: target.top, + right: obstructor.left, + bottom: target.bottom, + }); + } + + // Right strip: right of obstructor, full target height + if (obstructor.right < target.right) { + candidates.push({ + left: obstructor.right, + top: target.top, + right: target.right, + bottom: target.bottom, + }); + } + + if (candidates.length === 0) return null; + + let best: Rect = candidates[0]; + let bestArea = area(candidates[0]); + + for (let i = 1; i < candidates.length; i++) { + const a = area(candidates[i]); + if (a > bestArea) { + best = candidates[i]; + bestArea = a; + } + } + + if (bestArea <= 0) return null; + + return { + rect: best, + center: center(best), + area: bestArea, + }; +} diff --git a/.claude/mcp/android-device-server/src/index.ts b/.claude/mcp/android-device-server/src/index.ts new file mode 100644 index 0000000000..14fd083719 --- /dev/null +++ b/.claude/mcp/android-device-server/src/index.ts @@ -0,0 +1,64 @@ +#!/usr/bin/env node + +/** + * Android Device MCP Server + * MCP server for Android device interaction via ADB — UI hierarchy capture, + * element finding with obstruction detection, tap, and navigation. + */ + +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { + CallToolRequestSchema, + ListToolsRequestSchema, +} from '@modelcontextprotocol/sdk/types.js'; +import type { ToolDefinition } from './utils/validation.js'; +import capture from './tools/capture.js'; +import findElement from './tools/find-element.js'; +import tapAt from './tools/tap-at.js'; +import tapElement from './tools/tap-element.js'; +import navigate from './tools/navigate.js'; +import inputText from './tools/input-text.js'; + +const tools: ToolDefinition[] = [capture, findElement, tapAt, tapElement, navigate, inputText]; + +async function main() { + const server = new Server( + { name: 'android-device-mcp', version: '1.0.0' }, + { capabilities: { tools: {} } }, + ); + + server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: tools.map(t => ({ + name: t.name, + description: t.description, + inputSchema: t.inputSchema, + })), + })); + + server.setRequestHandler(CallToolRequestSchema, async (request) => { + const { name, arguments: args } = request.params; + const tool = tools.find(t => t.name === name); + + if (!tool) { + throw new Error(`Unknown tool: ${name}`); + } + + try { + const result = await tool.handler(args || {}); + return { content: [{ type: 'text', text: result }] }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`Tool error (${name}):`, message); + return { content: [{ type: 'text', text: `Error: ${message}` }], isError: true }; + } + }); + + const transport = new StdioServerTransport(); + await server.connect(transport); +} + +main().catch((error) => { + console.error('Fatal error:', error); + process.exit(1); +}); diff --git a/.claude/mcp/android-device-server/src/parsers/__fixtures__/dumpsys-windows.txt b/.claude/mcp/android-device-server/src/parsers/__fixtures__/dumpsys-windows.txt new file mode 100644 index 0000000000..e08eb4780d --- /dev/null +++ b/.claude/mcp/android-device-server/src/parsers/__fixtures__/dumpsys-windows.txt @@ -0,0 +1,630 @@ +WINDOW MANAGER WINDOWS (dumpsys window windows) + Window #0 Window{ba7e323 u0 ScreenDecorOverlayBottom}: + mDisplayId=0 mSession=Session{1e8a728 2463:u0a10210} mClient=android.os.BinderProxy@b7880dd + mOwnerUid=10210 showForAllUsers=true package=com.android.systemui appop=NONE + mAttrs={(0,0)(fillxwrap) gr=BOTTOM CENTER_VERTICAL sim={adjust=pan} layoutInDisplayCutoutMode=always ty=NAVIGATION_BAR_PANEL fmt=TRANSLUCENT + fl=NOT_FOCUSABLE NOT_TOUCHABLE NOT_TOUCH_MODAL LAYOUT_IN_SCREEN FLAG_SLIPPERY + pfl=SHOW_FOR_ALL_USERS NO_MOVE_ANIMATION IS_ROUNDED_CORNERS_OVERLAY COLOR_SPACE_AGNOSTIC FIT_INSETS_CONTROLLED TRUSTED_OVERLAY + vsysui=LAYOUT_STABLE + bhv=DEFAULT + frameRateBoostOnTouch=true + dvrrWindowFrameRateHint=true} + Requested w=1080 h=74 mLayoutSeq=18196 + mBaseLayer=251000 mSubLayer=0 mToken=WindowToken{1806452 type=2024 android.os.BinderProxy@b7880dd} + mViewVisibility=0x0 mHaveFrame=true mObscured=false + mGivenContentInsets=[0,0][0,0] mGivenVisibleInsets=[0,0][0,0] + mTouchableInsets=3 mGivenInsetsPending=false + touchable region=SkRegion() + mFullConfiguration={1.0 310mcc280mnc [en_US,ar_EG] ldltr sw411dp w411dp h914dp 420dpi nrml long compactNeeded hdr widecg port night finger -keyb/v/h -nav/h winConfig={ mBounds=Rect(0, 0 - 1080, 2400) mAppBounds=Rect(0, 0 - 1080, 2400) mMaxBounds=Rect(0, 0 - 1080, 2400) mDisplayRotation=ROTATION_0 mWindowingMode=fullscreen mActivityType=undefined mAlwaysOnTop=undefined mRotation=ROTATION_0} s.3034 fontWeightAdjustment=0} + mLastReportedConfiguration={1.0 310mcc280mnc [en_US,ar_EG] ldltr sw411dp w411dp h914dp 420dpi nrml long compactNeeded hdr widecg port night finger -keyb/v/h -nav/h winConfig={ mBounds=Rect(0, 0 - 1080, 2400) mAppBounds=Rect(0, 0 - 1080, 2400) mMaxBounds=Rect(0, 0 - 1080, 2400) mDisplayRotation=ROTATION_0 mWindowingMode=fullscreen mActivityType=undefined mAlwaysOnTop=undefined mRotation=ROTATION_0} s.3034 fontWeightAdjustment=0} + mHasSurface=true isReadyForDisplay()=true mWindowRemovalAllowed=false + Frames: parent=[0,0][1080,2400] display=[0,0][1080,2400] frame=[0,2326][1080,2400] last=[0,2326][1080,2400] insetsChanged=false + surface=[0,0][0,0] + WindowStateAnimator{10dcf18 ScreenDecorOverlayBottom}: + mSurface=Surface(name=ScreenDecorOverlayBottom#71)/@0xc8aad71 + Surface: shown=true mDrawState=HAS_DRAWN mLastHidden=false + mEnterAnimationPending=false + isOnScreen=true + isVisible=true + keepClearAreas: restricted=[], unrestricted=[] + mPrepareSyncSeqId=0 + mBufferSeqId=0 + Window #1 Window{7f4a38f u0 ScreenDecorOverlay}: + mDisplayId=0 mSession=Session{1e8a728 2463:u0a10210} mClient=android.os.BinderProxy@ad30ea2 + mOwnerUid=10210 showForAllUsers=true package=com.android.systemui appop=NONE + mAttrs={(0,0)(fillxwrap) gr=TOP CENTER_VERTICAL sim={adjust=pan} layoutInDisplayCutoutMode=always ty=NAVIGATION_BAR_PANEL fmt=TRANSLUCENT + fl=NOT_FOCUSABLE NOT_TOUCHABLE NOT_TOUCH_MODAL LAYOUT_IN_SCREEN FLAG_SLIPPERY + pfl=SHOW_FOR_ALL_USERS NO_MOVE_ANIMATION IS_ROUNDED_CORNERS_OVERLAY COLOR_SPACE_AGNOSTIC FIT_INSETS_CONTROLLED TRUSTED_OVERLAY + vsysui=LAYOUT_STABLE + bhv=DEFAULT + frameRateBoostOnTouch=true + dvrrWindowFrameRateHint=true} + Requested w=1080 h=128 mLayoutSeq=18196 + mBaseLayer=251000 mSubLayer=0 mToken=WindowToken{2167869 type=2024 android.os.BinderProxy@ad30ea2} + mViewVisibility=0x0 mHaveFrame=true mObscured=false + mGivenContentInsets=[0,0][0,0] mGivenVisibleInsets=[0,0][0,0] + mTouchableInsets=3 mGivenInsetsPending=false + touchable region=SkRegion((492,0,610,128)) + mFullConfiguration={1.0 310mcc280mnc [en_US,ar_EG] ldltr sw411dp w411dp h914dp 420dpi nrml long compactNeeded hdr widecg port night finger -keyb/v/h -nav/h winConfig={ mBounds=Rect(0, 0 - 1080, 2400) mAppBounds=Rect(0, 0 - 1080, 2400) mMaxBounds=Rect(0, 0 - 1080, 2400) mDisplayRotation=ROTATION_0 mWindowingMode=fullscreen mActivityType=undefined mAlwaysOnTop=undefined mRotation=ROTATION_0} s.3034 fontWeightAdjustment=0} + mLastReportedConfiguration={1.0 310mcc280mnc [en_US,ar_EG] ldltr sw411dp w411dp h914dp 420dpi nrml long compactNeeded hdr widecg port night finger -keyb/v/h -nav/h winConfig={ mBounds=Rect(0, 0 - 1080, 2400) mAppBounds=Rect(0, 0 - 1080, 2400) mMaxBounds=Rect(0, 0 - 1080, 2400) mDisplayRotation=ROTATION_0 mWindowingMode=fullscreen mActivityType=undefined mAlwaysOnTop=undefined mRotation=ROTATION_0} s.3034 fontWeightAdjustment=0} + mHasSurface=true isReadyForDisplay()=true mWindowRemovalAllowed=false + Frames: parent=[0,0][1080,2400] display=[0,0][1080,2400] frame=[0,0][1080,128] last=[0,0][1080,128] insetsChanged=false + surface=[0,0][0,0] + WindowStateAnimator{e4a7756 ScreenDecorOverlay}: + mSurface=Surface(name=ScreenDecorOverlay#70)/@0x89533d7 + Surface: shown=true mDrawState=HAS_DRAWN mLastHidden=false + mEnterAnimationPending=false + isOnScreen=true + isVisible=true + keepClearAreas: restricted=[], unrestricted=[] + mPrepareSyncSeqId=0 + mBufferSeqId=0 + Window #2 Window{cc49e92 u0 FloatingMenu}: + mDisplayId=0 mSession=Session{1e8a728 2463:u0a10210} mClient=android.os.BinderProxy@cbefcf4 + mOwnerUid=10210 showForAllUsers=true package=com.android.systemui appop=NONE + mAttrs={(0,0)(fillxfill) sim={adjust=resize} layoutInDisplayCutoutMode=always ty=NAVIGATION_BAR_PANEL fmt=TRANSLUCENT wanim=0x1030003 receive insets ignoring z-order + fl=NOT_FOCUSABLE HARDWARE_ACCELERATED + pfl=SHOW_FOR_ALL_USERS UNRESTRICTED_GESTURE_EXCLUSION EXCLUDE_FROM_SCREEN_MAGNIFICATION FIT_INSETS_CONTROLLED + bhv=DEFAULT + frameRateBoostOnTouch=true + dvrrWindowFrameRateHint=true} + Requested w=1080 h=2400 mLayoutSeq=18196 + mBaseLayer=251000 mSubLayer=0 mToken=WindowToken{71c3c1d type=2024 android.os.BinderProxy@cbefcf4} + mViewVisibility=0x0 mHaveFrame=true mObscured=false + mGivenContentInsets=[0,0][0,0] mGivenVisibleInsets=[0,0][0,0] + mTouchableInsets=3 mGivenInsetsPending=false + touchable region=SkRegion((953,297,1080,424)) + mFullConfiguration={1.0 310mcc280mnc [en_US,ar_EG] ldltr sw411dp w411dp h914dp 420dpi nrml long compactNeeded hdr widecg port night finger -keyb/v/h -nav/h winConfig={ mBounds=Rect(0, 0 - 1080, 2400) mAppBounds=Rect(0, 0 - 1080, 2400) mMaxBounds=Rect(0, 0 - 1080, 2400) mDisplayRotation=ROTATION_0 mWindowingMode=fullscreen mActivityType=undefined mAlwaysOnTop=undefined mRotation=ROTATION_0} s.3034 fontWeightAdjustment=0} + mLastReportedConfiguration={1.0 310mcc280mnc [en_US,ar_EG] ldltr sw411dp w411dp h914dp 420dpi nrml long compactNeeded hdr widecg port night finger -keyb/v/h -nav/h winConfig={ mBounds=Rect(0, 0 - 1080, 2400) mAppBounds=Rect(0, 0 - 1080, 2400) mMaxBounds=Rect(0, 0 - 1080, 2400) mDisplayRotation=ROTATION_0 mWindowingMode=fullscreen mActivityType=undefined mAlwaysOnTop=undefined mRotation=ROTATION_0} s.3034 fontWeightAdjustment=0} + mHasSurface=true isReadyForDisplay()=true mWindowRemovalAllowed=false + Frames: parent=[0,0][1080,2400] display=[0,0][1080,2400] frame=[0,0][1080,2400] last=[0,0][1080,2400] insetsChanged=false + surface=[0,0][0,0] + WindowStateAnimator{c9cc4c4 FloatingMenu}: + mAnimationIsEntrance=true mSurface=Surface(name=FloatingMenu#25467)/@0x64bafad + Surface: shown=true mDrawState=HAS_DRAWN mLastHidden=false + mEnterAnimationPending=false + isOnScreen=true + isVisible=true + keepClearAreas: restricted=[], unrestricted=[] + mPrepareSyncSeqId=0 + mBufferSeqId=0 + Window #3 Window{5cac0da u0 Taskbar}: + mDisplayId=0 mSession=Session{41a0116 2805:u0a10196} mClient=android.os.BinderProxy@b45f3fc + mOwnerUid=10196 showForAllUsers=true package=com.google.android.apps.nexuslauncher appop=NONE + mAttrs={(0,0)(fillx126) gr=BOTTOM CENTER_VERTICAL sim={adjust=nothing} layoutInDisplayCutoutMode=always ty=NAVIGATION_BAR fmt=TRANSLUCENT receive insets ignoring z-order + fl=NOT_FOCUSABLE NOT_TOUCH_MODAL WATCH_OUTSIDE_TOUCH FLAG_SLIPPERY + pfl=NO_MOVE_ANIMATION + bhv=DEFAULT + providedInsets: + InsetsFrameProvider: {id=#d3210001, index=0, type=navigationBars, source=FRAME, flags=[], insetsSize=Insets{left=0, top=0, right=0, bottom=126}} + InsetsFrameProvider: {id=#d3210006, index=0, type=tappableElement, source=FRAME, flags=[], insetsSize=Insets{left=0, top=0, right=0, bottom=126}, insetsSizeOverrides=[TypedInsetsSize: {windowType=VOICE_INTERACTION, insetsSize=Insets{left=0, top=0, right=0, bottom=126}}]} + InsetsFrameProvider: {id=#d3210005, index=0, type=mandatorySystemGestures, source=FRAME, flags=[], insetsSize=Insets{left=0, top=0, right=0, bottom=126}} + InsetsFrameProvider: {id=#d3210004, index=0, type=systemGestures, source=DISPLAY, flags=[], insetsSize=Insets{left=0, top=0, right=0, bottom=0}} + InsetsFrameProvider: {id=#d3210024, index=1, type=systemGestures, source=DISPLAY, flags=[], insetsSize=Insets{left=0, top=0, right=0, bottom=0}} + frameRateBoostOnTouch=true + dvrrWindowFrameRateHint=true + paramsForRotation: + ROTATION_0={(0,0)(fillx126) gr=BOTTOM CENTER_VERTICAL sim={adjust=nothing} layoutInDisplayCutoutMode=always ty=NAVIGATION_BAR fmt=TRANSLUCENT receive insets ignoring z-order + fl=NOT_FOCUSABLE NOT_TOUCH_MODAL WATCH_OUTSIDE_TOUCH FLAG_SLIPPERY + pfl=NO_MOVE_ANIMATION + bhv=DEFAULT + providedInsets: + InsetsFrameProvider: {id=#3fa90001, index=0, type=navigationBars, source=FRAME, flags=[], insetsSize=Insets{left=0, top=0, right=0, bottom=126}} + InsetsFrameProvider: {id=#3fa90006, index=0, type=tappableElement, source=FRAME, flags=[], insetsSize=Insets{left=0, top=0, right=0, bottom=126}, insetsSizeOverrides=[TypedInsetsSize: {windowType=VOICE_INTERACTION, insetsSize=Insets{left=0, top=0, right=0, bottom=126}}]} + InsetsFrameProvider: {id=#3fa90005, index=0, type=mandatorySystemGestures, source=FRAME, flags=[], insetsSize=Insets{left=0, top=0, right=0, bottom=126}} + InsetsFrameProvider: {id=#3fa90004, index=0, type=systemGestures, source=DISPLAY, flags=[], insetsSize=Insets{left=0, top=0, right=0, bottom=0}} + InsetsFrameProvider: {id=#3fa90024, index=1, type=systemGestures, source=DISPLAY, flags=[], insetsSize=Insets{left=0, top=0, right=0, bottom=0}} + frameRateBoostOnTouch=true + dvrrWindowFrameRateHint=true} + ROTATION_90={(0,0)(126xfill) gr=END CENTER_HORIZONTAL sim={adjust=nothing} layoutInDisplayCutoutMode=always ty=NAVIGATION_BAR fmt=TRANSLUCENT receive insets ignoring z-order + fl=NOT_FOCUSABLE NOT_TOUCH_MODAL WATCH_OUTSIDE_TOUCH FLAG_SLIPPERY + pfl=NO_MOVE_ANIMATION + bhv=DEFAULT + providedInsets: + InsetsFrameProvider: {id=#3fa90001, index=0, type=navigationBars, source=FRAME, flags=[], insetsSize=Insets{left=0, top=0, right=126, bottom=0}} + InsetsFrameProvider: {id=#3fa90006, index=0, type=tappableElement, source=FRAME, flags=[], insetsSize=Insets{left=0, top=0, right=126, bottom=0}, insetsSizeOverrides=[TypedInsetsSize: {windowType=VOICE_INTERACTION, insetsSize=Insets{left=0, top=0, right=126, bottom=0}}]} + InsetsFrameProvider: {id=#3fa90005, index=0, type=mandatorySystemGestures, source=FRAME, flags=[], insetsSize=Insets{left=0, top=0, right=126, bottom=0}} + InsetsFrameProvider: {id=#3fa90004, index=0, type=systemGestures, source=DISPLAY, flags=[], insetsSize=Insets{left=0, top=0, right=0, bottom=0}} + InsetsFrameProvider: {id=#3fa90024, index=1, type=systemGestures, source=DISPLAY, flags=[], insetsSize=Insets{left=0, top=0, right=0, bottom=0}} + frameRateBoostOnTouch=true + dvrrWindowFrameRateHint=true} + ROTATION_180={(0,0)(fillx126) gr=BOTTOM CENTER_VERTICAL sim={adjust=nothing} layoutInDisplayCutoutMode=always ty=NAVIGATION_BAR fmt=TRANSLUCENT receive insets ignoring z-order + fl=NOT_FOCUSABLE NOT_TOUCH_MODAL WATCH_OUTSIDE_TOUCH FLAG_SLIPPERY + pfl=NO_MOVE_ANIMATION + bhv=DEFAULT + providedInsets: + InsetsFrameProvider: {id=#3fa90001, index=0, type=navigationBars, source=FRAME, flags=[], insetsSize=Insets{left=0, top=0, right=0, bottom=128}} + InsetsFrameProvider: {id=#3fa90006, index=0, type=tappableElement, source=FRAME, flags=[], insetsSize=Insets{left=0, top=0, right=0, bottom=126}, insetsSizeOverrides=[TypedInsetsSize: {windowType=VOICE_INTERACTION, insetsSize=Insets{left=0, top=0, right=0, bottom=126}}]} + InsetsFrameProvider: {id=#3fa90005, index=0, type=mandatorySystemGestures, source=FRAME, flags=[], insetsSize=Insets{left=0, top=0, right=0, bottom=128}} + InsetsFrameProvider: {id=#3fa90004, index=0, type=systemGestures, source=DISPLAY, flags=[], insetsSize=Insets{left=0, top=0, right=0, bottom=0}} + InsetsFrameProvider: {id=#3fa90024, index=1, type=systemGestures, source=DISPLAY, flags=[], insetsSize=Insets{left=0, top=0, right=0, bottom=0}} + frameRateBoostOnTouch=true + dvrrWindowFrameRateHint=true} + ROTATION_270={(0,0)(126xfill) gr=START CENTER_HORIZONTAL sim={adjust=nothing} layoutInDisplayCutoutMode=always ty=NAVIGATION_BAR fmt=TRANSLUCENT receive insets ignoring z-order + fl=NOT_FOCUSABLE NOT_TOUCH_MODAL WATCH_OUTSIDE_TOUCH FLAG_SLIPPERY + pfl=NO_MOVE_ANIMATION + bhv=DEFAULT + providedInsets: + InsetsFrameProvider: {id=#3fa90001, index=0, type=navigationBars, source=FRAME, flags=[], insetsSize=Insets{left=126, top=0, right=0, bottom=0}} + InsetsFrameProvider: {id=#3fa90006, index=0, type=tappableElement, source=FRAME, flags=[], insetsSize=Insets{left=126, top=0, right=0, bottom=0}, insetsSizeOverrides=[TypedInsetsSize: {windowType=VOICE_INTERACTION, insetsSize=Insets{left=126, top=0, right=0, bottom=0}}]} + InsetsFrameProvider: {id=#3fa90005, index=0, type=mandatorySystemGestures, source=FRAME, flags=[], insetsSize=Insets{left=126, top=0, right=0, bottom=0}} + InsetsFrameProvider: {id=#3fa90004, index=0, type=systemGestures, source=DISPLAY, flags=[], insetsSize=Insets{left=0, top=0, right=0, bottom=0}} + InsetsFrameProvider: {id=#3fa90024, index=1, type=systemGestures, source=DISPLAY, flags=[], insetsSize=Insets{left=0, top=0, right=0, bottom=0}} + frameRateBoostOnTouch=true + dvrrWindowFrameRateHint=true}} + Requested w=1080 h=126 mLayoutSeq=18196 + mBaseLayer=241000 mSubLayer=0 mToken=WindowToken{9fc53e8 type=2019 android.os.BinderProxy@a5e4338} + mViewVisibility=0x0 mHaveFrame=true mObscured=false + mGivenContentInsets=[0,0][0,0] mGivenVisibleInsets=[0,0][0,0] + mFullConfiguration={1.0 310mcc280mnc [en_US,ar_EG] ldltr sw411dp w411dp h914dp 420dpi nrml long compactNeeded hdr widecg port night finger -keyb/v/h -nav/h winConfig={ mBounds=Rect(0, 0 - 1080, 2400) mAppBounds=Rect(0, 0 - 1080, 2400) mMaxBounds=Rect(0, 0 - 1080, 2400) mDisplayRotation=ROTATION_0 mWindowingMode=fullscreen mActivityType=undefined mAlwaysOnTop=undefined mRotation=ROTATION_0} s.3034 fontWeightAdjustment=0} + mLastReportedConfiguration={1.0 310mcc280mnc [en_US,ar_EG] ldltr sw411dp w411dp h914dp 420dpi nrml long compactNeeded hdr widecg port night finger -keyb/v/h -nav/h winConfig={ mBounds=Rect(0, 0 - 1080, 2400) mAppBounds=Rect(0, 0 - 1080, 2400) mMaxBounds=Rect(0, 0 - 1080, 2400) mDisplayRotation=ROTATION_0 mWindowingMode=fullscreen mActivityType=undefined mAlwaysOnTop=undefined mRotation=ROTATION_0} s.3034 fontWeightAdjustment=0} + mHasSurface=true isReadyForDisplay()=true mWindowRemovalAllowed=false + Frames: parent=[0,0][1080,2400] display=[0,0][1080,2400] frame=[0,2274][1080,2400] last=[0,2274][1080,2400] insetsChanged=false + surface=[0,0][0,0] + ContainerAnimator: + mLeash=Surface(name=Surface(name=5cac0da Taskbar#73)/@0x536c694 - animation-leash of insets_animation#25499)/@0xa8eb2e2 mAnimationType=insets_animation + Animation: com.android.server.wm.InsetsSourceProvider$ControlAdapter@60f3673 + ControlAdapter mCapturedLeash=Surface(name=Surface(name=5cac0da Taskbar#73)/@0x536c694 - animation-leash of insets_animation#25499)/@0xa8eb2e2 + WindowStateAnimator{7036930 Taskbar}: + mSurface=Surface(name=Taskbar#75)/@0x1a099a9 + Surface: shown=true mDrawState=HAS_DRAWN mLastHidden=false + mEnterAnimationPending=false + isOnScreen=true + isVisible=true + keepClearAreas: restricted=[], unrestricted=[] + mPrepareSyncSeqId=0 + mBufferSeqId=0 + Window #4 Window{b5c1512 u0 NotificationShade}: + mDisplayId=0 mSession=Session{1e8a728 2463:u0a10210} mClient=android.os.BinderProxy@11aef74 + mOwnerUid=10210 showForAllUsers=true package=com.android.systemui appop=NONE + mAttrs={(0,0)(fillxfill) gr=TOP CENTER_VERTICAL sim={adjust=pan} layoutInDisplayCutoutMode=always ty=NOTIFICATION_SHADE fmt=TRANSLUCENT + fl=NOT_FOCUSABLE TOUCHABLE_WHEN_WAKING WATCH_OUTSIDE_TOUCH HARDWARE_ACCELERATED DRAWS_SYSTEM_BAR_BACKGROUNDS + pfl=OPTIMIZE_MEASURE COLOR_SPACE_AGNOSTIC FIT_INSETS_CONTROLLED + bhv=SHOW_TRANSIENT_BARS_BY_SWIPE + frameRateBoostOnTouch=true + dvrrWindowFrameRateHint=true} + Requested w=1080 h=2400 mLayoutSeq=18146 + mBaseLayer=171000 mSubLayer=0 mToken=WindowToken{b50b90c type=2040 android.os.BinderProxy@8bb9b47} + mViewVisibility=0x4 mHaveFrame=true mObscured=false + mGivenContentInsets=[0,0][0,0] mGivenVisibleInsets=[0,0][0,0] + mTouchableInsets=3 mGivenInsetsPending=false + touchable region=SkRegion((0,0,1080,128)(492,128,610,160)) + mFullConfiguration={1.0 310mcc280mnc [en_US,ar_EG] ldltr sw411dp w411dp h914dp 420dpi nrml long compactNeeded hdr widecg port night finger -keyb/v/h -nav/h winConfig={ mBounds=Rect(0, 0 - 1080, 2400) mAppBounds=Rect(0, 0 - 1080, 2400) mMaxBounds=Rect(0, 0 - 1080, 2400) mDisplayRotation=ROTATION_0 mWindowingMode=fullscreen mActivityType=undefined mAlwaysOnTop=undefined mRotation=ROTATION_0} s.3034 fontWeightAdjustment=0} + mLastReportedConfiguration={1.0 310mcc280mnc [en_US,ar_EG] ldltr sw411dp w411dp h914dp 420dpi nrml long compactNeeded hdr widecg port night finger -keyb/v/h -nav/h winConfig={ mBounds=Rect(0, 0 - 1080, 2400) mAppBounds=Rect(0, 0 - 1080, 2400) mMaxBounds=Rect(0, 0 - 1080, 2400) mDisplayRotation=ROTATION_0 mWindowingMode=fullscreen mActivityType=undefined mAlwaysOnTop=undefined mRotation=ROTATION_0} s.3034 fontWeightAdjustment=0} + mHasSurface=false isReadyForDisplay()=false mWindowRemovalAllowed=false + Frames: parent=[0,0][1080,2400] display=[0,0][1080,2400] frame=[0,0][1080,2400] last=[0,0][1080,2400] insetsChanged=false + surface=[0,0][0,0] + WindowStateAnimator{1e91b2e NotificationShade}: + mDrawState=NO_SURFACE mLastHidden=false + mEnterAnimationPending=false + isOnScreen=false + isVisible=false + keepClearAreas: restricted=[], unrestricted=[] + mPrepareSyncSeqId=0 + mBufferSeqId=0 + Window #5 Window{92afd17 u0 StatusBar}: + mDisplayId=0 mSession=Session{1e8a728 2463:u0a10210} mClient=android.os.BinderProxy@a484b1 + mOwnerUid=10210 showForAllUsers=true package=com.android.systemui appop=NONE + mAttrs={(0,0)(fillx128) gr=TOP CENTER_VERTICAL sim={adjust=pan} layoutInDisplayCutoutMode=always ty=STATUS_BAR fmt=TRANSLUCENT + fl=NOT_FOCUSABLE HARDWARE_ACCELERATED DRAWS_SYSTEM_BAR_BACKGROUNDS + pfl=COLOR_SPACE_AGNOSTIC FIT_INSETS_CONTROLLED + bhv=DEFAULT + providedInsets: + InsetsFrameProvider: {id=#25730000, index=0, type=statusBars, source=FRAME, flags=[], insetsSize=Insets{left=0, top=128, right=0, bottom=0}} + InsetsFrameProvider: {id=#25730006, index=0, type=tappableElement, source=FRAME, flags=[], insetsSize=Insets{left=0, top=128, right=0, bottom=0}} + InsetsFrameProvider: {id=#25730005, index=0, type=mandatorySystemGestures, source=FRAME, flags=[], mMinimalInsetsSizeInDisplayCutoutSafe=Insets{left=0, top=32, right=0, bottom=0}} + frameRateBoostOnTouch=true + dvrrWindowFrameRateHint=true + paramsForRotation: + ROTATION_0={(0,0)(fillx128) gr=TOP CENTER_VERTICAL layoutInDisplayCutoutMode=always ty=STATUS_BAR fmt=TRANSLUCENT + fl=NOT_FOCUSABLE DRAWS_SYSTEM_BAR_BACKGROUNDS + pfl=COLOR_SPACE_AGNOSTIC FIT_INSETS_CONTROLLED + bhv=DEFAULT + providedInsets: + InsetsFrameProvider: {id=#25730000, index=0, type=statusBars, source=FRAME, flags=[], insetsSize=Insets{left=0, top=128, right=0, bottom=0}} + InsetsFrameProvider: {id=#25730006, index=0, type=tappableElement, source=FRAME, flags=[], insetsSize=Insets{left=0, top=128, right=0, bottom=0}} + InsetsFrameProvider: {id=#25730005, index=0, type=mandatorySystemGestures, source=FRAME, flags=[], insetsSize=Insets{left=0, top=128, right=0, bottom=0}, mMinimalInsetsSizeInDisplayCutoutSafe=Insets{left=0, top=32, right=0, bottom=0}} + frameRateBoostOnTouch=true + dvrrWindowFrameRateHint=true} + ROTATION_90={(0,0)(fillx74) gr=TOP CENTER_VERTICAL layoutInDisplayCutoutMode=always ty=STATUS_BAR fmt=TRANSLUCENT + fl=NOT_FOCUSABLE DRAWS_SYSTEM_BAR_BACKGROUNDS + pfl=COLOR_SPACE_AGNOSTIC FIT_INSETS_CONTROLLED + bhv=DEFAULT + providedInsets: + InsetsFrameProvider: {id=#25730000, index=0, type=statusBars, source=FRAME, flags=[], insetsSize=Insets{left=0, top=74, right=0, bottom=0}} + InsetsFrameProvider: {id=#25730006, index=0, type=tappableElement, source=FRAME, flags=[], insetsSize=Insets{left=0, top=74, right=0, bottom=0}} + InsetsFrameProvider: {id=#25730005, index=0, type=mandatorySystemGestures, source=FRAME, flags=[], insetsSize=Insets{left=0, top=74, right=0, bottom=0}, mMinimalInsetsSizeInDisplayCutoutSafe=Insets{left=0, top=32, right=0, bottom=0}} + frameRateBoostOnTouch=true + dvrrWindowFrameRateHint=true} + ROTATION_180={(0,0)(fillx74) gr=TOP CENTER_VERTICAL layoutInDisplayCutoutMode=always ty=STATUS_BAR fmt=TRANSLUCENT + fl=NOT_FOCUSABLE DRAWS_SYSTEM_BAR_BACKGROUNDS + pfl=COLOR_SPACE_AGNOSTIC FIT_INSETS_CONTROLLED + bhv=DEFAULT + providedInsets: + InsetsFrameProvider: {id=#25730000, index=0, type=statusBars, source=FRAME, flags=[], insetsSize=Insets{left=0, top=74, right=0, bottom=0}} + InsetsFrameProvider: {id=#25730006, index=0, type=tappableElement, source=FRAME, flags=[], insetsSize=Insets{left=0, top=74, right=0, bottom=0}} + InsetsFrameProvider: {id=#25730005, index=0, type=mandatorySystemGestures, source=FRAME, flags=[], insetsSize=Insets{left=0, top=74, right=0, bottom=0}, mMinimalInsetsSizeInDisplayCutoutSafe=Insets{left=0, top=32, right=0, bottom=0}} + frameRateBoostOnTouch=true + dvrrWindowFrameRateHint=true} + ROTATION_270={(0,0)(fillx74) gr=TOP CENTER_VERTICAL layoutInDisplayCutoutMode=always ty=STATUS_BAR fmt=TRANSLUCENT + fl=NOT_FOCUSABLE DRAWS_SYSTEM_BAR_BACKGROUNDS + pfl=COLOR_SPACE_AGNOSTIC FIT_INSETS_CONTROLLED + bhv=DEFAULT + providedInsets: + InsetsFrameProvider: {id=#25730000, index=0, type=statusBars, source=FRAME, flags=[], insetsSize=Insets{left=0, top=74, right=0, bottom=0}} + InsetsFrameProvider: {id=#25730006, index=0, type=tappableElement, source=FRAME, flags=[], insetsSize=Insets{left=0, top=74, right=0, bottom=0}} + InsetsFrameProvider: {id=#25730005, index=0, type=mandatorySystemGestures, source=FRAME, flags=[], insetsSize=Insets{left=0, top=74, right=0, bottom=0}, mMinimalInsetsSizeInDisplayCutoutSafe=Insets{left=0, top=32, right=0, bottom=0}} + frameRateBoostOnTouch=true + dvrrWindowFrameRateHint=true}} + Requested w=1080 h=128 mLayoutSeq=18196 + mBaseLayer=151000 mSubLayer=0 mToken=WindowToken{8609d96 type=2000 android.os.BinderProxy@5177b58} + mViewVisibility=0x0 mHaveFrame=true mObscured=false + mGivenContentInsets=[0,0][0,0] mGivenVisibleInsets=[0,0][0,0] + mTouchableInsets=3 mGivenInsetsPending=false + touchable region=SkRegion((0,0,1080,128)) + mFullConfiguration={1.0 310mcc280mnc [en_US,ar_EG] ldltr sw411dp w411dp h914dp 420dpi nrml long compactNeeded hdr widecg port night finger -keyb/v/h -nav/h winConfig={ mBounds=Rect(0, 0 - 1080, 2400) mAppBounds=Rect(0, 0 - 1080, 2400) mMaxBounds=Rect(0, 0 - 1080, 2400) mDisplayRotation=ROTATION_0 mWindowingMode=fullscreen mActivityType=undefined mAlwaysOnTop=undefined mRotation=ROTATION_0} s.3034 fontWeightAdjustment=0} + mLastReportedConfiguration={1.0 310mcc280mnc [en_US,ar_EG] ldltr sw411dp w411dp h914dp 420dpi nrml long compactNeeded hdr widecg port night finger -keyb/v/h -nav/h winConfig={ mBounds=Rect(0, 0 - 1080, 2400) mAppBounds=Rect(0, 0 - 1080, 2400) mMaxBounds=Rect(0, 0 - 1080, 2400) mDisplayRotation=ROTATION_0 mWindowingMode=fullscreen mActivityType=undefined mAlwaysOnTop=undefined mRotation=ROTATION_0} s.3034 fontWeightAdjustment=0} + mHasSurface=true isReadyForDisplay()=true mWindowRemovalAllowed=false + Frames: parent=[0,0][1080,2400] display=[0,0][1080,2400] frame=[0,0][1080,128] last=[0,0][1080,128] insetsChanged=false + surface=[0,0][0,0] + ContainerAnimator: + mLeash=Surface(name=Surface(name=92afd17 StatusBar#78)/@0x47a6386 - animation-leash of insets_animation#25498)/@0xa1cc6cf mAnimationType=insets_animation + Animation: com.android.server.wm.InsetsSourceProvider$ControlAdapter@5ede85c + ControlAdapter mCapturedLeash=Surface(name=Surface(name=92afd17 StatusBar#78)/@0x47a6386 - animation-leash of insets_animation#25498)/@0xa1cc6cf + WindowStateAnimator{6402765 StatusBar}: + mSurface=Surface(name=StatusBar#83)/@0x26fbc3a + Surface: shown=true mDrawState=HAS_DRAWN mLastHidden=false + mEnterAnimationPending=false + isOnScreen=true + isVisible=true + keepClearAreas: restricted=[], unrestricted=[] + mPrepareSyncSeqId=0 + mBufferSeqId=0 + Window #6 Window{175a4d2 u0 ShellDropTarget}: + mDisplayId=0 mSession=Session{1e8a728 2463:u0a10210} mClient=android.os.BinderProxy@32704a0 + mOwnerUid=10210 showForAllUsers=true package=com.android.systemui appop=SYSTEM_ALERT_WINDOW + mAttrs={(0,0)(fillxfill) sim={adjust=pan} layoutInDisplayCutoutMode=always ty=APPLICATION_OVERLAY fmt=TRANSLUCENT + fl=NOT_FOCUSABLE HARDWARE_ACCELERATED + pfl=SHOW_FOR_ALL_USERS NO_MOVE_ANIMATION FIT_INSETS_CONTROLLED INTERCEPT_GLOBAL_DRAG_AND_DROP + bhv=DEFAULT + frameRateBoostOnTouch=true + dvrrWindowFrameRateHint=true} + Requested w=1080 h=2400 mLayoutSeq=5 + mBaseLayer=111000 mSubLayer=0 mToken=WindowToken{ec5e859 type=2038 android.os.BinderProxy@620a66c} + mViewVisibility=0x4 mHaveFrame=true mObscured=false + mGivenContentInsets=[0,0][0,0] mGivenVisibleInsets=[0,0][0,0] + mFullConfiguration={1.0 310mcc280mnc [en_US,ar_EG] ldltr sw411dp w411dp h914dp 420dpi nrml long compactNeeded hdr widecg port night finger -keyb/v/h -nav/h winConfig={ mBounds=Rect(0, 0 - 1080, 2400) mAppBounds=Rect(0, 0 - 1080, 2400) mMaxBounds=Rect(0, 0 - 1080, 2400) mDisplayRotation=ROTATION_0 mWindowingMode=fullscreen mActivityType=undefined mAlwaysOnTop=undefined mRotation=ROTATION_0} s.3034 fontWeightAdjustment=0} + mLastReportedConfiguration={0.0 ?mcc0mnc ?localeList ?layoutDir ?swdp ?wdp ?hdp ?density ?lsize ?long ?round ?ldr ?wideColorGamut ?orien ?uimode ?night ?touch ?keyb/?/? ?nav/? winConfig={ mBounds=Rect(0, 0 - 0, 0) mAppBounds=null mMaxBounds=Rect(0, 0 - 0, 0) mDisplayRotation=undefined mWindowingMode=undefined mActivityType=undefined mAlwaysOnTop=undefined mRotation=undefined} ?fontWeightAdjustment} + mHasSurface=false isReadyForDisplay()=false mWindowRemovalAllowed=false + Frames: parent=[0,0][1080,2400] display=[0,0][1080,2400] frame=[0,0][1080,2400] last=[0,0][1080,2400] insetsChanged=false + surface=[0,0][0,0] + WindowStateAnimator{3c900eb ShellDropTarget}: + mDrawState=NO_SURFACE mLastHidden=false + mEnterAnimationPending=false + mShownAlpha=0.0 mAlpha=1.0 mLastAlpha=0.0 + isOnScreen=false + isVisible=false + keepClearAreas: restricted=[], unrestricted=[] + mPrepareSyncSeqId=0 + mBufferSeqId=0 + Window #7 Window{d056a34 u0 InputMethod}: + mDisplayId=0 mSession=Session{ed481a5 10035:u0a10168} mClient=android.os.BinderProxy@a88e346 + mOwnerUid=10168 showForAllUsers=false package=com.google.android.inputmethod.latin appop=NONE + mAttrs={(0,0)(fillxfill) gr=BOTTOM CENTER_VERTICAL sim={adjust=pan} ty=INPUT_METHOD fmt=TRANSPARENT wanim=0x1030056 receive insets ignoring z-order + fl=NOT_FOCUSABLE LAYOUT_IN_SCREEN SPLIT_TOUCH HARDWARE_ACCELERATED DRAWS_SYSTEM_BAR_BACKGROUNDS + pfl=EDGE_TO_EDGE_ENFORCED FIT_INSETS_CONTROLLED + bhv=DEFAULT + fitTypes=statusBars navigationBars + fitSides=LEFT TOP RIGHT + dvrrWindowFrameRateHint=true} + Requested w=1080 h=2272 mLayoutSeq=18191 + mIsImWindow=true mIsWallpaper=false mIsFloatingLayer=true + mBaseLayer=131000 mSubLayer=0 mToken=WindowToken{4aa0ed3 type=2011 android.os.Binder@fa630c2} + mViewVisibility=0x8 mHaveFrame=true mObscured=false + mGivenContentInsets=[0,2272][0,0] mGivenVisibleInsets=[0,2272][0,0] + mTouchableInsets=3 mGivenInsetsPending=false + touchable region=SkRegion() + mFullConfiguration={1.0 310mcc280mnc [en_US,ar_EG] ldltr sw411dp w411dp h914dp 420dpi nrml long compactNeeded hdr widecg port night finger -keyb/v/h -nav/h winConfig={ mBounds=Rect(0, 0 - 1080, 2400) mAppBounds=Rect(0, 0 - 1080, 2400) mMaxBounds=Rect(0, 0 - 1080, 2400) mDisplayRotation=ROTATION_0 mWindowingMode=fullscreen mActivityType=undefined mAlwaysOnTop=undefined mRotation=ROTATION_0} s.3034 fontWeightAdjustment=0} + mLastReportedConfiguration={1.0 310mcc280mnc [en_US,ar_EG] ldltr sw411dp w411dp h914dp 420dpi nrml long compactNeeded hdr widecg port night finger -keyb/v/h -nav/h winConfig={ mBounds=Rect(0, 0 - 1080, 2400) mAppBounds=Rect(0, 0 - 1080, 2400) mMaxBounds=Rect(0, 0 - 1080, 2400) mDisplayRotation=ROTATION_0 mWindowingMode=fullscreen mActivityType=undefined mAlwaysOnTop=undefined mRotation=ROTATION_0} s.3034 fontWeightAdjustment=0} + mHasSurface=false isReadyForDisplay()=false mWindowRemovalAllowed=false + Frames: parent=[0,128][1080,2400] display=[0,128][1080,2400] frame=[0,128][1080,2400] last=[0,128][1080,2400] insetsChanged=false + surface=[0,0][0,0] + ContainerAnimator: + mLeash=Surface(name=Surface(name=d056a34 InputMethod#20167)/@0x9dfedd2 - animation-leash of insets_animation#25500)/@0xb9f2e48 mAnimationType=insets_animation + Animation: com.android.server.wm.InsetsSourceProvider$ControlAdapter@690d4e1 + ControlAdapter mCapturedLeash=Surface(name=Surface(name=d056a34 InputMethod#20167)/@0x9dfedd2 - animation-leash of insets_animation#25500)/@0xb9f2e48 + WindowStateAnimator{38d6206 InputMethod}: + mDrawState=NO_SURFACE mLastHidden=false + mEnterAnimationPending=false + isOnScreen=false + isVisible=false + keepClearAreas: restricted=[], unrestricted=[] + mPrepareSyncSeqId=0 + mBufferSeqId=0 + Window #8 Window{37bdea2 u0 com.x8bit.bitwarden.dev/com.x8bit.bitwarden.MainActivity}: + mDisplayId=0 taskId=1857 mSession=Session{e78c8d8 7233:u0a10379} mClient=android.os.BinderProxy@c7326d + mOwnerUid=10379 showForAllUsers=false package=com.x8bit.bitwarden.dev appop=NONE + mAttrs={(0,0)(fillxfill) sim={adjust=resize} layoutInDisplayCutoutMode=always ty=BASE_APPLICATION fmt=TRANSPARENT wanim=0x103030d + fl=LAYOUT_IN_SCREEN LAYOUT_INSET_DECOR SPLIT_TOUCH DRAWS_SYSTEM_BAR_BACKGROUNDS + pfl=NO_MOVE_ANIMATION EDGE_TO_EDGE_ENFORCED FORCE_DRAW_STATUS_BAR_BACKGROUND FIT_INSETS_CONTROLLED + bhv=DEFAULT + fitSides= + frameRateBoostOnTouch=true + dvrrWindowFrameRateHint=true} + Requested w=1080 h=2400 mLayoutSeq=18196 + mBaseLayer=21000 mSubLayer=0 mToken=ActivityRecord{49392223 u0 com.x8bit.bitwarden.dev/com.x8bit.bitwarden.MainActivity t1857} + mActivityRecord=ActivityRecord{49392223 u0 com.x8bit.bitwarden.dev/com.x8bit.bitwarden.MainActivity t1857} + drawnStateEvaluated=true mightAffectAllDrawn=true + mViewVisibility=0x0 mHaveFrame=true mObscured=false + mGivenContentInsets=[0,0][0,0] mGivenVisibleInsets=[0,0][0,0] + mFullConfiguration={1.0 310mcc280mnc [en_US,ar_EG] ldltr sw411dp w411dp h914dp 420dpi nrml long compactNeeded hdr widecg port night finger -keyb/v/h -nav/h winConfig={ mBounds=Rect(0, 0 - 1080, 2400) mAppBounds=Rect(0, 0 - 1080, 2400) mMaxBounds=Rect(0, 0 - 1080, 2400) mDisplayRotation=ROTATION_0 mWindowingMode=fullscreen mActivityType=standard mAlwaysOnTop=undefined mRotation=ROTATION_0} s.2 fontWeightAdjustment=0} + mLastReportedConfiguration={1.0 310mcc280mnc [en_US,ar_EG] ldltr sw411dp w411dp h914dp 420dpi nrml long compactNeeded hdr widecg port night finger -keyb/v/h -nav/h winConfig={ mBounds=Rect(0, 0 - 1080, 2400) mAppBounds=Rect(0, 0 - 1080, 2400) mMaxBounds=Rect(0, 0 - 1080, 2400) mDisplayRotation=ROTATION_0 mWindowingMode=fullscreen mActivityType=standard mAlwaysOnTop=undefined mRotation=ROTATION_0} s.2 fontWeightAdjustment=0} + mLastReportedActivityWindowInfo=ActivityWindowInfo{isEmbedded=false, taskBounds=Rect(0, 0 - 1080, 2400), taskFragmentBounds=Rect(0, 0 - 1080, 2400)} + mHasSurface=true isReadyForDisplay()=true mWindowRemovalAllowed=false + Frames: parent=[0,0][1080,2400] display=[0,0][1080,2400] frame=[0,0][1080,2400] last=[0,0][1080,2400] insetsChanged=false + surface=[0,0][0,0] + WindowStateAnimator{db4c0c7 com.x8bit.bitwarden.dev/com.x8bit.bitwarden.MainActivity}: + mSurface=Surface(name=com.x8bit.bitwarden.dev/com.x8bit.bitwarden.MainActivity#25485)/@0x99ce6f4 + Surface: shown=true mDrawState=HAS_DRAWN mLastHidden=false + mEnterAnimationPending=false + isOnScreen=true + isVisible=true + keepClearAreas: restricted=[], unrestricted=[] + mPrepareSyncSeqId=0 + mBufferSeqId=0 + Window #9 Window{cb57263 u0 com.google.android.apps.nexuslauncher/com.google.android.apps.nexuslauncher.NexusLauncherActivity}: + mDisplayId=0 taskId=5 mSession=Session{41a0116 2805:u0a10196} mClient=android.os.BinderProxy@399a892 + mOwnerUid=10196 showForAllUsers=false package=com.google.android.apps.nexuslauncher appop=NONE + mAttrs={(0,0)(fillxfill) sim={adjust=nothing} layoutInDisplayCutoutMode=always ty=BASE_APPLICATION fmt=TRANSPARENT wanim=0x1030301 + fl=LAYOUT_IN_SCREEN LAYOUT_INSET_DECOR SHOW_WALLPAPER SPLIT_TOUCH HARDWARE_ACCELERATED DRAWS_SYSTEM_BAR_BACKGROUNDS + pfl=NO_MOVE_ANIMATION OPTIMIZE_MEASURE EDGE_TO_EDGE_ENFORCED FORCE_DRAW_STATUS_BAR_BACKGROUND FIT_INSETS_CONTROLLED + vsysui=LAYOUT_STABLE LAYOUT_HIDE_NAVIGATION LAYOUT_FULLSCREEN + bhv=DEFAULT + fitSides= + frameRateBoostOnTouch=true + dvrrWindowFrameRateHint=true} + Requested w=1080 h=2400 mLayoutSeq=18155 + mBaseLayer=21000 mSubLayer=0 mToken=ActivityRecord{62739071 u0 com.google.android.apps.nexuslauncher/.NexusLauncherActivity t5} + mActivityRecord=ActivityRecord{62739071 u0 com.google.android.apps.nexuslauncher/.NexusLauncherActivity t5} + drawnStateEvaluated=true mightAffectAllDrawn=true + mViewVisibility=0x8 mHaveFrame=true mObscured=false + mGivenContentInsets=[0,0][0,0] mGivenVisibleInsets=[0,0][0,0] + mFullConfiguration={1.0 310mcc280mnc [en_US,ar_EG] ldltr sw411dp w411dp h914dp 420dpi nrml long compactNeeded hdr widecg port night finger -keyb/v/h -nav/h winConfig={ mBounds=Rect(0, 0 - 1080, 2400) mAppBounds=Rect(0, 0 - 1080, 2400) mMaxBounds=Rect(0, 0 - 1080, 2400) mDisplayRotation=ROTATION_0 mWindowingMode=fullscreen mActivityType=home mAlwaysOnTop=undefined mRotation=ROTATION_0} s.24 fontWeightAdjustment=0} + mLastReportedConfiguration={1.0 310mcc280mnc [en_US,ar_EG] ldltr sw411dp w411dp h914dp 420dpi nrml long compactNeeded hdr widecg port night finger -keyb/v/h -nav/h winConfig={ mBounds=Rect(0, 0 - 1080, 2400) mAppBounds=Rect(0, 0 - 1080, 2400) mMaxBounds=Rect(0, 0 - 1080, 2400) mDisplayRotation=ROTATION_0 mWindowingMode=fullscreen mActivityType=home mAlwaysOnTop=undefined mRotation=ROTATION_0} s.24 fontWeightAdjustment=0} + mLastReportedActivityWindowInfo=ActivityWindowInfo{isEmbedded=false, taskBounds=Rect(0, 0 - 1080, 2400), taskFragmentBounds=Rect(0, 0 - 1080, 2400)} + mHasSurface=false isReadyForDisplay()=false mWindowRemovalAllowed=false + Frames: parent=[0,0][1080,2400] display=[0,0][1080,2400] frame=[0,0][1080,2400] last=[0,0][1080,2400] insetsChanged=false + surface=[0,0][0,0] + WindowStateAnimator{ae4de1d com.google.android.apps.nexuslauncher/com.google.android.apps.nexuslauncher.NexusLauncherActivity}: + mDrawState=NO_SURFACE mLastHidden=true + mEnterAnimationPending=false + mWallpaperX=0.0 mWallpaperY=0.5 + mWallpaperXStep=0.33333334 mWallpaperYStep=1.0 + mWallpaperZoomOut=0.32999983 + isOnScreen=false + isVisible=false + keepClearAreas: restricted=[], unrestricted=[] + mPrepareSyncSeqId=0 + mBufferSeqId=0 + Window #10 Window{67f1aa4 u0 com.bitwarden.authenticator/com.bitwarden.authenticator.MainActivity}: + mDisplayId=0 taskId=1864 mSession=Session{47e596a 7150:u0a10327} mClient=android.os.BinderProxy@295b537 + mOwnerUid=10327 showForAllUsers=false package=com.bitwarden.authenticator appop=NONE + mAttrs={(0,0)(fillxfill) sim={adjust=resize forwardNavigation} layoutInDisplayCutoutMode=always ty=BASE_APPLICATION fmt=TRANSPARENT wanim=0x103030d + fl=LAYOUT_IN_SCREEN LAYOUT_INSET_DECOR SPLIT_TOUCH HARDWARE_ACCELERATED DRAWS_SYSTEM_BAR_BACKGROUNDS + pfl=NO_MOVE_ANIMATION EDGE_TO_EDGE_ENFORCED FORCE_DRAW_STATUS_BAR_BACKGROUND FIT_INSETS_CONTROLLED + bhv=DEFAULT + fitSides= + frameRateBoostOnTouch=true + dvrrWindowFrameRateHint=true} + Requested w=1080 h=2400 mLayoutSeq=18152 + mBaseLayer=21000 mSubLayer=0 mToken=ActivityRecord{27882677 u0 com.bitwarden.authenticator/.MainActivity t1864} + mActivityRecord=ActivityRecord{27882677 u0 com.bitwarden.authenticator/.MainActivity t1864} + drawnStateEvaluated=true mightAffectAllDrawn=true + mViewVisibility=0x8 mHaveFrame=true mObscured=false + mGivenContentInsets=[0,0][0,0] mGivenVisibleInsets=[0,0][0,0] + mFullConfiguration={1.0 310mcc280mnc [en_US,ar_EG] ldltr sw411dp w411dp h914dp 420dpi nrml long compactNeeded hdr widecg port night finger -keyb/v/h -nav/h winConfig={ mBounds=Rect(0, 0 - 1080, 2400) mAppBounds=Rect(0, 0 - 1080, 2400) mMaxBounds=Rect(0, 0 - 1080, 2400) mDisplayRotation=ROTATION_0 mWindowingMode=fullscreen mActivityType=standard mAlwaysOnTop=undefined mRotation=ROTATION_0} s.2 fontWeightAdjustment=0} + mLastReportedConfiguration={1.0 310mcc280mnc [en_US,ar_EG] ldltr sw411dp w411dp h914dp 420dpi nrml long compactNeeded hdr widecg port night finger -keyb/v/h -nav/h winConfig={ mBounds=Rect(0, 0 - 1080, 2400) mAppBounds=Rect(0, 0 - 1080, 2400) mMaxBounds=Rect(0, 0 - 1080, 2400) mDisplayRotation=ROTATION_0 mWindowingMode=fullscreen mActivityType=standard mAlwaysOnTop=undefined mRotation=ROTATION_0} s.2 fontWeightAdjustment=0} + mLastReportedActivityWindowInfo=ActivityWindowInfo{isEmbedded=false, taskBounds=Rect(0, 0 - 1080, 2400), taskFragmentBounds=Rect(0, 0 - 1080, 2400)} + mHasSurface=false isReadyForDisplay()=false mWindowRemovalAllowed=false + Frames: parent=[0,0][1080,2400] display=[0,0][1080,2400] frame=[0,0][1080,2400] last=[0,0][1080,2400] insetsChanged=false + surface=[0,0][0,0] + WindowStateAnimator{68d9892 com.bitwarden.authenticator/com.bitwarden.authenticator.MainActivity}: + mDrawState=NO_SURFACE mLastHidden=true + mEnterAnimationPending=false + isOnScreen=false + isVisible=false + keepClearAreas: restricted=[], unrestricted=[] + mPrepareSyncSeqId=0 + mBufferSeqId=0 + Window #11 Window{369ab3f u0 com.android.chrome/com.google.android.apps.chrome.Main}: + mDisplayId=0 taskId=1863 mSession=Session{732339d 12069:u0a10152} mClient=android.os.BinderProxy@7e20b5e + mOwnerUid=10152 showForAllUsers=false package=com.android.chrome appop=NONE + mAttrs={(0,0)(fillxfill) sim={state=always_hidden adjust=resize} layoutInDisplayCutoutMode=always ty=BASE_APPLICATION fmt=TRANSLUCENT wanim=0x103030d sysuil=true + fl=LAYOUT_IN_SCREEN LAYOUT_INSET_DECOR SPLIT_TOUCH HARDWARE_ACCELERATED DRAWS_SYSTEM_BAR_BACKGROUNDS + pfl=NO_MOVE_ANIMATION EDGE_TO_EDGE_ENFORCED FORCE_DRAW_STATUS_BAR_BACKGROUND FIT_INSETS_CONTROLLED + bhv=DEFAULT + fitSides= + frameRateBoostOnTouch=true + dvrrWindowFrameRateHint=true} + Requested w=1080 h=2400 mLayoutSeq=18046 + mBaseLayer=21000 mSubLayer=0 mToken=ActivityRecord{30085890 u0 com.android.chrome/com.google.android.apps.chrome.Main t1863} + mActivityRecord=ActivityRecord{30085890 u0 com.android.chrome/com.google.android.apps.chrome.Main t1863} + drawnStateEvaluated=true mightAffectAllDrawn=true + mViewVisibility=0x8 mHaveFrame=true mObscured=false + mGivenContentInsets=[0,0][0,0] mGivenVisibleInsets=[0,0][0,0] + mFullConfiguration={1.0 310mcc280mnc [en_US,ar_EG] ldltr sw411dp w411dp h914dp 420dpi nrml long compactNeeded hdr widecg port night finger -keyb/v/h -nav/h winConfig={ mBounds=Rect(0, 0 - 1080, 2400) mAppBounds=Rect(0, 0 - 1080, 2400) mMaxBounds=Rect(0, 0 - 1080, 2400) mDisplayRotation=ROTATION_0 mWindowingMode=fullscreen mActivityType=standard mAlwaysOnTop=undefined mRotation=ROTATION_0} s.2 fontWeightAdjustment=0} + mLastReportedConfiguration={1.0 310mcc280mnc [en_US,ar_EG] ldltr sw411dp w411dp h914dp 420dpi nrml long compactNeeded hdr widecg port night finger -keyb/v/h -nav/h winConfig={ mBounds=Rect(0, 0 - 1080, 2400) mAppBounds=Rect(0, 0 - 1080, 2400) mMaxBounds=Rect(0, 0 - 1080, 2400) mDisplayRotation=ROTATION_0 mWindowingMode=fullscreen mActivityType=standard mAlwaysOnTop=undefined mRotation=ROTATION_0} s.2 fontWeightAdjustment=0} + mLastReportedActivityWindowInfo=ActivityWindowInfo{isEmbedded=false, taskBounds=Rect(0, 0 - 1080, 2400), taskFragmentBounds=Rect(0, 0 - 1080, 2400)} + mHasSurface=false isReadyForDisplay()=false mWindowRemovalAllowed=false + Frames: parent=[0,0][1080,2400] display=[0,0][1080,2400] frame=[0,0][1080,2400] last=[0,0][1080,2400] insetsChanged=false + surface=[0,0][0,0] + WindowStateAnimator{1b2a263 com.android.chrome/com.google.android.apps.chrome.Main}: + mDrawState=NO_SURFACE mLastHidden=true + mEnterAnimationPending=false + isOnScreen=false + isVisible=false + keepClearAreas: restricted=[], unrestricted=[] + mPrepareSyncSeqId=0 + mBufferSeqId=0 + Window #12 Window{94a5865 u0 com.google.android.apps.weather/com.google.android.apps.weather.home.HomeActivity}: + mDisplayId=0 taskId=1860 mSession=Session{5b870df 8279:u0a10259} mClient=android.os.BinderProxy@cee3d5c + mOwnerUid=10259 showForAllUsers=false package=com.google.android.apps.weather appop=NONE + mAttrs={(0,0)(fillxfill) sim={adjust=resize} layoutInDisplayCutoutMode=always ty=BASE_APPLICATION wanim=0x103030d + fl=LAYOUT_IN_SCREEN LAYOUT_INSET_DECOR SPLIT_TOUCH HARDWARE_ACCELERATED DRAWS_SYSTEM_BAR_BACKGROUNDS + pfl=NO_MOVE_ANIMATION EDGE_TO_EDGE_ENFORCED FORCE_DRAW_STATUS_BAR_BACKGROUND FIT_INSETS_CONTROLLED + bhv=DEFAULT + fitSides= + frameRateBoostOnTouch=true + dvrrWindowFrameRateHint=true} + Requested w=1080 h=2400 mLayoutSeq=17742 + mBaseLayer=21000 mSubLayer=0 mToken=ActivityRecord{191055369 u0 com.google.android.apps.weather/.home.HomeActivity t1860} + mActivityRecord=ActivityRecord{191055369 u0 com.google.android.apps.weather/.home.HomeActivity t1860} + drawnStateEvaluated=true mightAffectAllDrawn=true + mViewVisibility=0x8 mHaveFrame=true mObscured=false + mGivenContentInsets=[0,0][0,0] mGivenVisibleInsets=[0,0][0,0] + mFullConfiguration={1.0 310mcc280mnc [en_US,ar_EG] ldltr sw411dp w411dp h914dp 420dpi nrml long compactNeeded hdr widecg port night finger -keyb/v/h -nav/h winConfig={ mBounds=Rect(0, 0 - 1080, 2400) mAppBounds=Rect(0, 0 - 1080, 2400) mMaxBounds=Rect(0, 0 - 1080, 2400) mDisplayRotation=ROTATION_0 mWindowingMode=fullscreen mActivityType=standard mAlwaysOnTop=undefined mRotation=ROTATION_0} s.2 fontWeightAdjustment=0} + mLastReportedConfiguration={1.0 310mcc280mnc [en_US,ar_EG] ldltr sw411dp w411dp h914dp 420dpi nrml long compactNeeded hdr widecg port night finger -keyb/v/h -nav/h winConfig={ mBounds=Rect(0, 0 - 1080, 2400) mAppBounds=Rect(0, 0 - 1080, 2400) mMaxBounds=Rect(0, 0 - 1080, 2400) mDisplayRotation=ROTATION_0 mWindowingMode=fullscreen mActivityType=standard mAlwaysOnTop=undefined mRotation=ROTATION_0} s.2 fontWeightAdjustment=0} + mLastReportedActivityWindowInfo=ActivityWindowInfo{isEmbedded=false, taskBounds=Rect(0, 0 - 1080, 2400), taskFragmentBounds=Rect(0, 0 - 1080, 2400)} + mHasSurface=false isReadyForDisplay()=false mWindowRemovalAllowed=false + Frames: parent=[0,0][1080,2400] display=[0,0][1080,2400] frame=[0,0][1080,2400] last=[0,0][1080,2400] insetsChanged=false + surface=[0,0][0,0] + WindowStateAnimator{21b7e60 com.google.android.apps.weather/com.google.android.apps.weather.home.HomeActivity}: + mDrawState=NO_SURFACE mLastHidden=true + mEnterAnimationPending=false + isOnScreen=false + isVisible=false + keepClearAreas: restricted=[], unrestricted=[] + mPrepareSyncSeqId=0 + mBufferSeqId=0 + Window #13 Window{2783d3c u0 com.android.settings/com.android.settings.homepage.SettingsHomepageActivity}: + mDisplayId=0 taskId=1858 mSession=Session{3c7c9ff 2864:1000} mClient=android.os.BinderProxy@e79e32f + mOwnerUid=1000 showForAllUsers=false package=com.android.settings appop=NONE + mAttrs={(0,0)(fillxfill) sim={adjust=pan} layoutInDisplayCutoutMode=always ty=BASE_APPLICATION wanim=0x1030301 + fl=LAYOUT_IN_SCREEN LAYOUT_INSET_DECOR SPLIT_TOUCH HARDWARE_ACCELERATED DRAWS_SYSTEM_BAR_BACKGROUNDS + pfl=NO_MOVE_ANIMATION EDGE_TO_EDGE_ENFORCED FORCE_DRAW_STATUS_BAR_BACKGROUND FIT_INSETS_CONTROLLED + bhv=DEFAULT + fitSides= + frameRateBoostOnTouch=true + dvrrWindowFrameRateHint=true} + Requested w=1080 h=2400 mLayoutSeq=17559 + mBaseLayer=21000 mSubLayer=0 mToken=ActivityRecord{13824966 u0 com.android.settings/.homepage.SettingsHomepageActivity t1858} + mActivityRecord=ActivityRecord{13824966 u0 com.android.settings/.homepage.SettingsHomepageActivity t1858} + drawnStateEvaluated=true mightAffectAllDrawn=true + mViewVisibility=0x8 mHaveFrame=true mObscured=false + mGivenContentInsets=[0,0][0,0] mGivenVisibleInsets=[0,0][0,0] + mFullConfiguration={1.0 310mcc280mnc [en_US,ar_EG] ldltr sw411dp w411dp h914dp 420dpi nrml long compactNeeded hdr widecg port night finger -keyb/v/h -nav/h winConfig={ mBounds=Rect(0, 0 - 1080, 2400) mAppBounds=Rect(0, 0 - 1080, 2400) mMaxBounds=Rect(0, 0 - 1080, 2400) mDisplayRotation=ROTATION_0 mWindowingMode=fullscreen mActivityType=standard mAlwaysOnTop=undefined mRotation=ROTATION_0} s.1 fontWeightAdjustment=0} + mLastReportedConfiguration={1.0 310mcc280mnc [en_US,ar_EG] ldltr sw411dp w411dp h914dp 420dpi nrml long compactNeeded hdr widecg port night finger -keyb/v/h -nav/h winConfig={ mBounds=Rect(0, 0 - 1080, 2400) mAppBounds=Rect(0, 0 - 1080, 2400) mMaxBounds=Rect(0, 0 - 1080, 2400) mDisplayRotation=ROTATION_0 mWindowingMode=fullscreen mActivityType=standard mAlwaysOnTop=undefined mRotation=ROTATION_0} s.1 fontWeightAdjustment=0} + mLastReportedActivityWindowInfo=ActivityWindowInfo{isEmbedded=false, taskBounds=Rect(0, 0 - 1080, 2400), taskFragmentBounds=Rect(0, 0 - 1080, 2400)} + mHasSurface=false isReadyForDisplay()=false mWindowRemovalAllowed=false + Frames: parent=[0,0][1080,2400] display=[0,0][1080,2400] frame=[0,0][1080,2400] last=[0,0][1080,2400] insetsChanged=false + surface=[0,0][0,0] + WindowStateAnimator{4763f19 com.android.settings/com.android.settings.homepage.SettingsHomepageActivity}: + mDrawState=NO_SURFACE mLastHidden=true + mEnterAnimationPending=false + isOnScreen=false + isVisible=false + keepClearAreas: restricted=[], unrestricted=[] + mPrepareSyncSeqId=0 + mBufferSeqId=0 + Window #14 Window{dae7553 u0 com.google.android.youtube/com.google.android.youtube.app.honeycomb.Shell$HomeActivity}: + mDisplayId=0 taskId=1861 mSession=Session{a444b7 32472:u0a10162} mClient=android.os.BinderProxy@afa2142 + mOwnerUid=10162 showForAllUsers=false package=com.google.android.youtube appop=NONE + mAttrs={(0,0)(fillxfill) sim={adjust=resize} layoutInDisplayCutoutMode=always ty=BASE_APPLICATION fmt=TRANSLUCENT wanim=0x103030d sysuil=true + fl=LAYOUT_IN_SCREEN LAYOUT_INSET_DECOR SPLIT_TOUCH HARDWARE_ACCELERATED DRAWS_SYSTEM_BAR_BACKGROUNDS + pfl=NO_MOVE_ANIMATION EDGE_TO_EDGE_ENFORCED FORCE_DRAW_STATUS_BAR_BACKGROUND FIT_INSETS_CONTROLLED + vsysui=LAYOUT_STABLE LAYOUT_HIDE_NAVIGATION LAYOUT_FULLSCREEN IMMERSIVE_STICKY + bhv=SHOW_TRANSIENT_BARS_BY_SWIPE + fitSides= + frameRateBoostOnTouch=true + dvrrWindowFrameRateHint=true} + Requested w=598 h=336 mLayoutSeq=18064 + mBaseLayer=21000 mSubLayer=0 mToken=ActivityRecord{212995835 u0 com.google.android.youtube/.app.honeycomb.Shell$HomeActivity t1861} + mActivityRecord=ActivityRecord{212995835 u0 com.google.android.youtube/.app.honeycomb.Shell$HomeActivity t1861} + drawnStateEvaluated=true mightAffectAllDrawn=true + mViewVisibility=0x8 mHaveFrame=true mObscured=false + mGivenContentInsets=[0,0][0,0] mGivenVisibleInsets=[0,0][0,0] + mFullConfiguration={1.0 310mcc280mnc [en_US,ar_EG] ldltr sw411dp w411dp h914dp 420dpi nrml long compactNeeded hdr widecg port night finger -keyb/v/h -nav/h winConfig={ mBounds=Rect(0, 0 - 1080, 2400) mAppBounds=Rect(0, 0 - 1080, 2400) mMaxBounds=Rect(0, 0 - 1080, 2400) mDisplayRotation=ROTATION_0 mWindowingMode=fullscreen mActivityType=standard mAlwaysOnTop=undefined mRotation=ROTATION_0} s.7 fontWeightAdjustment=0} + mLastReportedConfiguration={1.0 310mcc280mnc [en_US,ar_EG] ldltr sw128dp w228dp h128dp 420dpi smll hdr widecg land night finger -keyb/v/h -nav/h winConfig={ mBounds=Rect(440, 202 - 1038, 538) mAppBounds=Rect(440, 202 - 1038, 538) mMaxBounds=Rect(0, 0 - 1080, 2400) mDisplayRotation=ROTATION_0 mWindowingMode=pinned mActivityType=standard mAlwaysOnTop=undefined mRotation=ROTATION_0} s.6 fontWeightAdjustment=0} + mLastReportedActivityWindowInfo=ActivityWindowInfo{isEmbedded=false, taskBounds=Rect(440, 202 - 1038, 538), taskFragmentBounds=Rect(440, 202 - 1038, 538)} + mHasSurface=false isReadyForDisplay()=false mWindowRemovalAllowed=false + Frames: parent=[440,202][1038,538] display=[440,202][1038,538] frame=[440,202][1038,538] last=[440,202][1038,538] insetsChanged=false + surface=[0,0][0,0] + WindowStateAnimator{3e8abde com.google.android.youtube/com.google.android.youtube.app.honeycomb.Shell$HomeActivity}: + mDrawState=NO_SURFACE mLastHidden=true + mEnterAnimationPending=false + isOnScreen=false + isVisible=false + keepClearAreas: restricted=[], unrestricted=[] + mPrepareSyncSeqId=0 + mBufferSeqId=0 + Window #15 Window{ee012ae u0 com.android.systemui.wallpapers.ImageWallpaper}: + mDisplayId=0 mSession=Session{1e8a728 2463:u0a10210} mClient=android.os.BinderProxy@fc2bf29 + mOwnerUid=10210 showForAllUsers=true package=com.android.systemui appop=NONE + mAttrs={(0,0)(1080x2400) gr=TOP START CENTER layoutInDisplayCutoutMode=always ty=WALLPAPER fmt=RGBX_8888 wanim=0x103031d + fl=NOT_FOCUSABLE NOT_TOUCHABLE LAYOUT_IN_SCREEN LAYOUT_NO_LIMITS SCALED LAYOUT_INSET_DECOR + pfl=WANTS_OFFSET_NOTIFICATIONS SHOW_FOR_ALL_USERS + bhv=DEFAULT + frameRateBoostOnTouch=true + dvrrWindowFrameRateHint=true} + Requested w=1080 h=2400 mLayoutSeq=18162 + mIsImWindow=false mIsWallpaper=true mIsFloatingLayer=true + mBaseLayer=11000 mSubLayer=0 mToken=WallpaperWindowToken{f41295f showWhenLocked=true} + mViewVisibility=0x0 mHaveFrame=true mObscured=false + mGivenContentInsets=[0,0][0,0] mGivenVisibleInsets=[0,0][0,0] + mFullConfiguration={1.0 310mcc280mnc [en_US,ar_EG] ldltr sw411dp w411dp h914dp 420dpi nrml long compactNeeded hdr widecg port night finger -keyb/v/h -nav/h winConfig={ mBounds=Rect(0, 0 - 1080, 2400) mAppBounds=Rect(0, 0 - 1080, 2400) mMaxBounds=Rect(0, 0 - 1080, 2400) mDisplayRotation=ROTATION_0 mWindowingMode=fullscreen mActivityType=undefined mAlwaysOnTop=undefined mRotation=ROTATION_0} s.3034 fontWeightAdjustment=0} + mLastReportedConfiguration={1.0 310mcc280mnc [en_US,ar_EG] ldltr sw411dp w411dp h914dp 420dpi nrml long compactNeeded hdr widecg port night finger -keyb/v/h -nav/h winConfig={ mBounds=Rect(0, 0 - 1080, 2400) mAppBounds=Rect(0, 0 - 1080, 2400) mMaxBounds=Rect(0, 0 - 1080, 2400) mDisplayRotation=ROTATION_0 mWindowingMode=fullscreen mActivityType=undefined mAlwaysOnTop=undefined mRotation=ROTATION_0} s.3034 fontWeightAdjustment=0} + mHasSurface=true isReadyForDisplay()=false mWindowRemovalAllowed=false + Frames: parent=[0,0][1080,2400] display=[-100000,-100000][100000,100000] frame=[0,0][1080,2400] last=[0,0][1080,2400] insetsChanged=false + surface=[0,0][0,0] + WindowStateAnimator{a2301bf com.android.systemui.wallpapers.ImageWallpaper}: + mSurface=Surface(name=com.android.systemui.wallpapers.ImageWallpaper#63)/@0x42a208c + Surface: shown=false mDrawState=HAS_DRAWN mLastHidden=true + mEnterAnimationPending=false + mWallpaperX=0.0 mWallpaperY=0.5 + mWallpaperXStep=0.33333334 mWallpaperYStep=1.0 + mWallpaperZoomOut=0.32999983 + isOnScreen=false + isVisible=false + keepClearAreas: restricted=[], unrestricted=[] + mPrepareSyncSeqId=0 + mBufferSeqId=0 + + mGlobalConfiguration={1.0 310mcc280mnc [en_US,ar_EG] ldltr sw411dp w411dp h914dp 420dpi nrml long compactNeeded hdr widecg port night finger -keyb/v/h -nav/h winConfig={ mBounds=Rect(0, 0 - 1080, 2400) mAppBounds=Rect(0, 0 - 1080, 2400) mMaxBounds=Rect(0, 0 - 1080, 2400) mDisplayRotation=ROTATION_0 mWindowingMode=fullscreen mActivityType=undefined mAlwaysOnTop=undefined mRotation=ROTATION_0} s.3034 fontWeightAdjustment=0} + mHasPermanentDpad=false + mTopFocusedDisplayId=0 + Minimum task size of display#0 220 + Minimum task size of display#589 220 + mBlurEnabled=true + mDisableSecureWindows=false + mHighResSnapshotScale=0.8 + mSnapshotEnabled=true + SnapshotCache Task + Entry token=1864 + topApp=ActivityRecord{27882677 u0 com.bitwarden.authenticator/.MainActivity t1864} + snapshot=TaskSnapshot{ mId=1775056698479 mCaptureTime=2306525709626021 mTopActivityComponent=com.bitwarden.authenticator/.MainActivity mSnapshot=android.hardware.HardwareBuffer@422b3d5 (864x1920) mColorSpace=sRGB IEC61966-2.1 (id=0, model=RGB) mOrientation=1 mRotation=0 mTaskSize=Point(1080, 2400) mContentInsets=[0,128][0,126] mLetterboxInsets=[0,0][0,0] mIsLowResolution=false mIsRealSnapshot=true mWindowingMode=1 mAppearance=0 mIsTranslucent=true mHasImeSurface=false mInternalReferences=2 mWriteToParcelCount=0 mUiMode=21 mDensityDpi=420 + Entry token=1863 + topApp=ActivityRecord{30085890 u0 com.android.chrome/com.google.android.apps.chrome.Main t1863} + snapshot=TaskSnapshot{ mId=1774987059375 mCaptureTime=2236884117126274 mTopActivityComponent=com.android.chrome/org.chromium.chrome.browser.ChromeTabbedActivity mSnapshot=android.hardware.HardwareBuffer@14fa7ea (864x1920) mColorSpace=sRGB IEC61966-2.1 (id=0, model=RGB) mOrientation=1 mRotation=0 mTaskSize=Point(1080, 2400) mContentInsets=[0,128][0,126] mLetterboxInsets=[0,0][0,0] mIsLowResolution=false mIsRealSnapshot=true mWindowingMode=1 mAppearance=0 mIsTranslucent=true mHasImeSurface=false mInternalReferences=2 mWriteToParcelCount=0 mUiMode=21 mDensityDpi=420 + Entry token=1861 + topApp=ActivityRecord{212995835 u0 com.google.android.youtube/.app.honeycomb.Shell$HomeActivity t1861} + snapshot=TaskSnapshot{ mId=1774986206569 mCaptureTime=2236031308978926 mTopActivityComponent=com.google.android.youtube/com.google.android.apps.youtube.app.watchwhile.MainActivity mSnapshot=android.hardware.HardwareBuffer@d0ffadb (864x1920) mColorSpace=sRGB IEC61966-2.1 (id=0, model=RGB) mOrientation=1 mRotation=0 mTaskSize=Point(1080, 2400) mContentInsets=[0,128][0,126] mLetterboxInsets=[0,0][0,0] mIsLowResolution=false mIsRealSnapshot=true mWindowingMode=1 mAppearance=0 mIsTranslucent=true mHasImeSurface=false mInternalReferences=2 mWriteToParcelCount=0 mUiMode=21 mDensityDpi=420 + Entry token=1860 + topApp=ActivityRecord{191055369 u0 com.google.android.apps.weather/.home.HomeActivity t1860} + snapshot=TaskSnapshot{ mId=1774985600020 mCaptureTime=2235424763889762 mTopActivityComponent=com.google.android.apps.weather/.home.HomeActivity mSnapshot=android.hardware.HardwareBuffer@e9eb978 (864x1920) mColorSpace=sRGB IEC61966-2.1 (id=0, model=RGB) mOrientation=1 mRotation=0 mTaskSize=Point(1080, 2400) mContentInsets=[0,128][0,126] mLetterboxInsets=[0,0][0,0] mIsLowResolution=false mIsRealSnapshot=true mWindowingMode=1 mAppearance=0 mIsTranslucent=false mHasImeSurface=false mInternalReferences=2 mWriteToParcelCount=0 mUiMode=21 mDensityDpi=420 + Entry token=1858 + topApp=ActivityRecord{13824966 u0 com.android.settings/.homepage.SettingsHomepageActivity t1858} + snapshot=TaskSnapshot{ mId=1774980521946 mCaptureTime=2230346684630203 mTopActivityComponent=com.android.settings/.homepage.SettingsHomepageActivity mSnapshot=android.hardware.HardwareBuffer@e7b851 (864x1920) mColorSpace=sRGB IEC61966-2.1 (id=0, model=RGB) mOrientation=1 mRotation=0 mTaskSize=Point(1080, 2400) mContentInsets=[0,128][0,126] mLetterboxInsets=[0,0][0,0] mIsLowResolution=false mIsRealSnapshot=true mWindowingMode=1 mAppearance=0 mIsTranslucent=false mHasImeSurface=false mInternalReferences=2 mWriteToParcelCount=0 mUiMode=21 mDensityDpi=420 + mHighResSnapshotScale=0.6 + mSnapshotEnabled=true + SnapshotCache Activity + UserSavedFile userId=0 + mInputMethodWindow=Window{d056a34 u0 InputMethod} + mTraversalScheduled=false + mSystemBooted=true mDisplayEnabled=true + mTransactionSequence=47123 + mRotation=0 + mLastOrientation=-1 + mWaitingForConfig=false + mWindowsInsetsChanged=0 + mDisplayRotationWatchers: [ 2000->0 10196->0 10210->0] + Animation settings: disabled=false window=1.0 transition=1.0 animator=1.0 diff --git a/.claude/mcp/android-device-server/src/parsers/__fixtures__/view.xml b/.claude/mcp/android-device-server/src/parsers/__fixtures__/view.xml new file mode 100644 index 0000000000..26b1df9173 --- /dev/null +++ b/.claude/mcp/android-device-server/src/parsers/__fixtures__/view.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/.claude/mcp/android-device-server/src/parsers/dumpsys.spec.ts b/.claude/mcp/android-device-server/src/parsers/dumpsys.spec.ts new file mode 100644 index 0000000000..62f2146f45 --- /dev/null +++ b/.claude/mcp/android-device-server/src/parsers/dumpsys.spec.ts @@ -0,0 +1,122 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parseDumpsysWindows, findOverlayAtPoint } from './dumpsys.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const fixtureOutput = readFileSync( + join(__dirname, '__fixtures__', 'dumpsys-windows.txt'), + 'utf-8', +); + +describe('parseDumpsysWindows', () => { + it('parses all windows from real dumpsys output', () => { + const windows = parseDumpsysWindows(fixtureOutput); + expect(windows.length).toBeGreaterThan(5); + }); + + it('extracts window names', () => { + const windows = parseDumpsysWindows(fixtureOutput); + const names = windows.map(w => w.name); + expect(names).toContain('FloatingMenu'); + expect(names).toContain('StatusBar'); + }); + + it('extracts window types from mAttrs line', () => { + const windows = parseDumpsysWindows(fixtureOutput); + const floatingMenu = windows.find(w => w.name === 'FloatingMenu'); + expect(floatingMenu).toBeDefined(); + expect(floatingMenu!.type).toBe('NAVIGATION_BAR_PANEL'); + }); + + it('does not match ty= in ROTATION_ lines or mViewVisibility', () => { + const windows = parseDumpsysWindows(fixtureOutput); + // Taskbar has ROTATION_ lines with ty= — should only capture the mAttrs ty= + const taskbar = windows.find(w => w.name === 'Taskbar'); + expect(taskbar).toBeDefined(); + expect(taskbar!.type).toBe('NAVIGATION_BAR'); + }); + + it('extracts surface visibility', () => { + const windows = parseDumpsysWindows(fixtureOutput); + const floatingMenu = windows.find(w => w.name === 'FloatingMenu'); + expect(floatingMenu!.hasSurface).toBe(true); + + const notificationShade = windows.find(w => w.name === 'NotificationShade'); + expect(notificationShade!.hasSurface).toBe(false); + }); + + it('extracts touchable region with coordinates', () => { + const windows = parseDumpsysWindows(fixtureOutput); + const floatingMenu = windows.find(w => w.name === 'FloatingMenu'); + expect(floatingMenu!.touchableRegion).not.toBeNull(); + expect(floatingMenu!.touchableRegion!.left).toBeGreaterThanOrEqual(0); + expect(floatingMenu!.touchableRegion!.right).toBeLessThanOrEqual(1080); + }); + + it('handles empty SkRegion() as null touchable region', () => { + const windows = parseDumpsysWindows(fixtureOutput); + const screenDecorBottom = windows.find(w => w.name === 'ScreenDecorOverlayBottom'); + expect(screenDecorBottom).toBeDefined(); + expect(screenDecorBottom!.touchableRegion).toBeNull(); + }); + + it('parses app window as BASE_APPLICATION type', () => { + const windows = parseDumpsysWindows(fixtureOutput); + const appWindow = windows.find(w => w.name.includes('bitwarden')); + expect(appWindow).toBeDefined(); + expect(appWindow!.type).toBe('BASE_APPLICATION'); + }); +}); + +describe('findOverlayAtPoint', () => { + it('finds FloatingMenu overlay at its touchable region', () => { + const windows = parseDumpsysWindows(fixtureOutput); + const floatingMenu = windows.find(w => w.name === 'FloatingMenu'); + expect(floatingMenu?.touchableRegion).not.toBeNull(); + + const region = floatingMenu!.touchableRegion!; + const center = { + x: Math.floor((region.left + region.right) / 2), + y: Math.floor((region.top + region.bottom) / 2), + }; + + const overlay = findOverlayAtPoint(windows, center); + // Should find some overlay at this point (FloatingMenu or ScreenDecorOverlay) + expect(overlay).not.toBeNull(); + expect(overlay!.type).not.toBe('BASE_APPLICATION'); + }); + + it('returns null for point with no overlays', () => { + const windows = parseDumpsysWindows(fixtureOutput); + // Point in the middle of the screen — unlikely to have overlay touchable regions + const overlay = findOverlayAtPoint(windows, { x: 540, y: 1000 }); + expect(overlay).toBeNull(); + }); + + it('excludes BASE_APPLICATION windows', () => { + const windows = parseDumpsysWindows(fixtureOutput); + // The app window covers the whole screen but should never be returned + const overlay = findOverlayAtPoint(windows, { x: 540, y: 1200 }); + if (overlay) { + expect(overlay.type).not.toBe('BASE_APPLICATION'); + } + }); + + it('excludes windows without visible surface', () => { + const windows = parseDumpsysWindows(fixtureOutput); + // NotificationShade has touchable region but mHasSurface=false + const shadeRegion = windows.find(w => w.name === 'NotificationShade')?.touchableRegion; + if (shadeRegion) { + const overlay = findOverlayAtPoint(windows, { + x: Math.floor((shadeRegion.left + shadeRegion.right) / 2), + y: Math.floor((shadeRegion.top + shadeRegion.bottom) / 2), + }); + // Should not return NotificationShade since its surface is not visible + if (overlay) { + expect(overlay.name).not.toBe('NotificationShade'); + } + } + }); +}); diff --git a/.claude/mcp/android-device-server/src/parsers/dumpsys.ts b/.claude/mcp/android-device-server/src/parsers/dumpsys.ts new file mode 100644 index 0000000000..e6f9d66aa7 --- /dev/null +++ b/.claude/mcp/android-device-server/src/parsers/dumpsys.ts @@ -0,0 +1,105 @@ +/** + * Structured parser for `adb shell dumpsys window windows` output. + * + * Extracts window name, type, surface visibility, and touchable region + * from the multi-line per-window blocks. Replaces the fragile awk + * state machine from the shell scripts. + */ + +import { type Rect, type Point, containsPoint } from '../geometry/bounds.js'; + +export interface WindowInfo { + name: string; + type: string; + hasSurface: boolean; + touchableRegion: Rect | null; +} + +/** + * Parse `dumpsys window windows` output into structured window objects. + */ +export function parseDumpsysWindows(output: string): WindowInfo[] { + const windows: WindowInfo[] = []; + let current: Partial | null = null; + + for (const line of output.split('\n')) { + // New window block: " Window #N Window{hash u0 NAME}:" + const windowMatch = line.match(/Window #\d+ Window\{[0-9a-f]+ \S+ (.+)\}:/); + if (windowMatch) { + if (current?.name) { + windows.push(finalizeWindow(current)); + } + current = { name: windowMatch[1], type: '', hasSurface: false, touchableRegion: null }; + continue; + } + + if (!current) continue; + + // Window type: " ty=TYPE " (leading space to avoid matching mViewVisibility=0x0) + // Only match on the mAttrs line, not ROTATION_ lines + if (!current.type && line.includes('mAttrs=') && line.includes(' ty=')) { + const tyMatch = line.match(/ ty=(\S+)/); + if (tyMatch) { + current.type = tyMatch[1]; + } + } + + // Surface visibility + if (line.includes('mHasSurface=true')) { + current.hasSurface = true; + } + + // Touchable region: SkRegion((l,t,r,b)) or SkRegion((l,t,r,b)(l2,t2,r2,b2)) + // We take the first rect if multiple. Empty SkRegion() means no touchable area. + if (line.includes('touchable region=SkRegion(')) { + const regionMatch = line.match(/SkRegion\(\((\d+),(\d+),(\d+),(\d+)\)/); + if (regionMatch) { + current.touchableRegion = { + left: parseInt(regionMatch[1], 10), + top: parseInt(regionMatch[2], 10), + right: parseInt(regionMatch[3], 10), + bottom: parseInt(regionMatch[4], 10), + }; + } + // SkRegion() with no coords = no touchable area, leave as null + } + } + + // Don't forget the last window + if (current?.name) { + windows.push(finalizeWindow(current)); + } + + return windows; +} + +function finalizeWindow(partial: Partial): WindowInfo { + return { + name: partial.name ?? '', + type: partial.type ?? '', + hasSurface: partial.hasSurface ?? false, + touchableRegion: partial.touchableRegion ?? null, + }; +} + +/** + * Find the first overlay window whose touchable region contains the given point. + * + * Filters out BASE_APPLICATION windows (the app itself) and windows without + * a visible surface or touchable region. Only windows that actually intercept + * taps are considered. + */ +export function findOverlayAtPoint(windows: WindowInfo[], point: Point): WindowInfo | null { + for (const win of windows) { + if ( + win.hasSurface && + win.type !== 'BASE_APPLICATION' && + win.type !== '' && + win.touchableRegion && + containsPoint(win.touchableRegion, point) + ) { + return win; + } + } + return null; +} diff --git a/.claude/mcp/android-device-server/src/parsers/xml.spec.ts b/.claude/mcp/android-device-server/src/parsers/xml.spec.ts new file mode 100644 index 0000000000..83a2cda13a --- /dev/null +++ b/.claude/mcp/android-device-server/src/parsers/xml.spec.ts @@ -0,0 +1,120 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parseHierarchy, findElementByText, findTopmostClickableAt } from './xml.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const fixtureXml = readFileSync(join(__dirname, '__fixtures__', 'view.xml'), 'utf-8'); + +describe('parseHierarchy', () => { + it('parses real UIAutomator XML into a node tree', () => { + const root = parseHierarchy(fixtureXml); + expect(root.className).toBe('android.widget.FrameLayout'); + expect(root.packageName).toBe('com.x8bit.bitwarden.dev'); + expect(root.bounds).toEqual({ left: 0, top: 0, right: 1080, bottom: 2400 }); + }); + + it('preserves the full tree depth with children', () => { + const root = parseHierarchy(fixtureXml); + expect(root.children.length).toBeGreaterThan(0); + // Should have deeply nested children + let depth = 0; + let node = root; + while (node.children.length > 0) { + node = node.children[0]; + depth++; + } + expect(depth).toBeGreaterThan(5); + }); + + it('parses boolean attributes correctly', () => { + const root = parseHierarchy(fixtureXml); + // Root FrameLayout is not clickable + expect(root.clickable).toBe(false); + expect(root.enabled).toBe(true); + }); + + it('throws on invalid XML', () => { + expect(() => parseHierarchy('')).toThrow(); + }); + + it('throws on XML without hierarchy root', () => { + expect(() => parseHierarchy('')).toThrow('missing '); + }); +}); + +describe('findElementByText', () => { + it('finds element by text attribute', () => { + const root = parseHierarchy(fixtureXml); + const el = findElementByText(root, 'Login'); + expect(el).not.toBeNull(); + expect(el!.text).toBe('Login'); + }); + + it('finds element by content-desc', () => { + const root = parseHierarchy(fixtureXml); + const el = findElementByText(root, 'Add Item'); + expect(el).not.toBeNull(); + expect(el!.contentDesc).toBe('Add Item'); + }); + + it('is case-insensitive', () => { + const root = parseHierarchy(fixtureXml); + const el = findElementByText(root, 'login'); + expect(el).not.toBeNull(); + expect(el!.text).toBe('Login'); + }); + + it('returns null for non-existent text', () => { + const root = parseHierarchy(fixtureXml); + expect(findElementByText(root, 'NONEXISTENT_TEXT_12345')).toBeNull(); + }); + + it('returns element with parsed bounds', () => { + const root = parseHierarchy(fixtureXml); + const el = findElementByText(root, 'Settings'); + expect(el).not.toBeNull(); + expect(el!.bounds).not.toBeNull(); + expect(el!.bounds!.left).toBeGreaterThanOrEqual(0); + expect(el!.bounds!.right).toBeLessThanOrEqual(1080); + }); +}); + +describe('findTopmostClickableAt', () => { + it('finds the topmost clickable element at a point', () => { + const root = parseHierarchy(fixtureXml); + // Point in the center of the screen — should find something clickable + const el = findTopmostClickableAt(root, { x: 540, y: 1200 }); + // May or may not find something depending on layout, but shouldn't crash + if (el) { + expect(el.clickable).toBe(true); + expect(el.bounds).not.toBeNull(); + } + }); + + it('returns null for a point with no clickable elements', () => { + const root = parseHierarchy(fixtureXml); + // Point in the status bar area — unlikely to have clickable app elements + const el = findTopmostClickableAt(root, { x: 540, y: 50 }); + // Could be null or a system element — just verify no crash + expect(el === null || el.clickable === true).toBe(true); + }); + + it('returns the LAST clickable in document order (highest z-order)', () => { + const root = parseHierarchy(fixtureXml); + // Find the "Add Item" FAB element to get its center + const fab = findElementByText(root, 'Add Item'); + if (fab?.bounds) { + const fabCenter = { + x: Math.floor((fab.bounds.left + fab.bounds.right) / 2), + y: Math.floor((fab.bounds.top + fab.bounds.bottom) / 2), + }; + const topmost = findTopmostClickableAt(root, fabCenter); + expect(topmost).not.toBeNull(); + // The topmost clickable at the FAB's center should be the FAB itself + // or its clickable parent (bounds should overlap) + expect(topmost!.bounds).not.toBeNull(); + } + }); +}); diff --git a/.claude/mcp/android-device-server/src/parsers/xml.ts b/.claude/mcp/android-device-server/src/parsers/xml.ts new file mode 100644 index 0000000000..b999c6b859 --- /dev/null +++ b/.claude/mcp/android-device-server/src/parsers/xml.ts @@ -0,0 +1,121 @@ +/** + * UIAutomator XML hierarchy parser. + * + * Converts Android's single-line UIAutomator XML dump into a typed, traversable + * node tree. Replaces the fragile grep/awk approach from the shell scripts. + */ + +import { XMLParser } from 'fast-xml-parser'; +import { type Rect, type Point, parseBounds, containsPoint } from '../geometry/bounds.js'; + +export interface UiNode { + text: string; + contentDesc: string; + resourceId: string; + className: string; + packageName: string; + bounds: Rect | null; + clickable: boolean; + focused: boolean; + enabled: boolean; + selected: boolean; + drawingOrder: number; + children: UiNode[]; +} + +const parser = new XMLParser({ + ignoreAttributes: false, + attributeNamePrefix: '', + // Ensure 'node' is always an array even when there's only one child + isArray: (name) => name === 'node', +}); + +/** + * Parse a UIAutomator XML dump into a typed node tree. + */ +export function parseHierarchy(xml: string): UiNode { + const parsed = parser.parse(xml); + const hierarchy = parsed?.hierarchy; + if (!hierarchy) { + throw new Error('Invalid UIAutomator XML: missing root'); + } + + const rootNodes = hierarchy.node; + if (!rootNodes || !Array.isArray(rootNodes) || rootNodes.length === 0) { + throw new Error('Invalid UIAutomator XML: no nodes found'); + } + + return convertNode(rootNodes[0]); +} + +function convertNode(raw: any): UiNode { + const children: UiNode[] = []; + if (raw.node) { + const childNodes = Array.isArray(raw.node) ? raw.node : [raw.node]; + for (const child of childNodes) { + children.push(convertNode(child)); + } + } + + return { + text: raw.text ?? '', + contentDesc: raw['content-desc'] ?? '', + resourceId: raw['resource-id'] ?? '', + className: raw.class ?? '', + packageName: raw.package ?? '', + bounds: parseBounds(raw.bounds ?? ''), + clickable: raw.clickable === 'true', + focused: raw.focused === 'true', + enabled: raw.enabled === 'true', + selected: raw.selected === 'true', + drawingOrder: parseInt(raw['drawing-order'] ?? '0', 10), + children, + }; +} + +/** + * Find the first element matching search text in text or content-desc. + * Searches depth-first. + */ +export function findElementByText(root: UiNode, searchText: string): UiNode | null { + const lower = searchText.toLowerCase(); + + function search(node: UiNode): UiNode | null { + if ( + node.text.toLowerCase().includes(lower) || + node.contentDesc.toLowerCase().includes(lower) + ) { + return node; + } + for (const child of node.children) { + const found = search(child); + if (found) return found; + } + return null; + } + + return search(root); +} + +/** + * Find the topmost clickable element at a given point. + * + * In UIAutomator's depth-first XML, the LAST clickable element whose bounds + * contain the point is the one that receives the tap (highest z-order at that + * point). This traverses the full tree and returns the last match. + */ +export function findTopmostClickableAt(root: UiNode, point: Point): UiNode | null { + let result: UiNode | null = null; + + function traverse(node: UiNode): void { + if (node.clickable && node.bounds && containsPoint(node.bounds, point)) { + result = node; + } + for (const child of node.children) { + traverse(child); + } + } + + traverse(root); + return result; +} diff --git a/.claude/mcp/android-device-server/src/tools/capture.ts b/.claude/mcp/android-device-server/src/tools/capture.ts new file mode 100644 index 0000000000..bebf011e5a --- /dev/null +++ b/.claude/mcp/android-device-server/src/tools/capture.ts @@ -0,0 +1,52 @@ +/** + * Capture tool — dump UI hierarchy XML and/or screenshot from the connected device. + */ + +import { z } from 'zod'; +import type { ToolDefinition } from '../utils/validation.js'; +import { validateInput } from '../utils/validation.js'; +import * as adb from '../adb/adb.js'; +import { resolve } from 'node:path'; + +const CaptureSchema = z.object({ + xml: z.boolean().optional().default(true), + screenshot: z.boolean().optional().default(true), +}); + +const capture: ToolDefinition = { + name: 'capture', + description: + 'Capture current Android device state. Dumps UI hierarchy XML and/or takes a screenshot. ' + + 'Files are saved to the current working directory as view.xml and screen.png.', + inputSchema: { + type: 'object', + properties: { + xml: { type: 'boolean', description: 'Capture UI hierarchy XML (default: true)' }, + screenshot: { type: 'boolean', description: 'Capture screenshot (default: true)' }, + }, + }, + async handler(input: unknown): Promise { + const { xml, screenshot } = validateInput(CaptureSchema, input); + const results: string[] = []; + + if (xml) { + const xmlPath = resolve('view.xml'); + await adb.dumpHierarchy(xmlPath); + results.push(`UI hierarchy saved to: ${xmlPath}`); + } + + if (screenshot) { + const pngPath = resolve('screen.png'); + await adb.screenshot(pngPath); + results.push(`Screenshot saved to: ${pngPath}`); + } + + if (results.length === 0) { + return 'Nothing captured. Set xml and/or screenshot to true.'; + } + + return results.join('\n'); + }, +}; + +export default capture; diff --git a/.claude/mcp/android-device-server/src/tools/find-element-pipeline.ts b/.claude/mcp/android-device-server/src/tools/find-element-pipeline.ts new file mode 100644 index 0000000000..2e357fb586 --- /dev/null +++ b/.claude/mcp/android-device-server/src/tools/find-element-pipeline.ts @@ -0,0 +1,65 @@ +/** + * Shared pipeline for finding a UI element with obstruction detection. + * Used by both find_element and tap_element tools. + */ + +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import * as adb from '../adb/adb.js'; +import { type Point, center } from '../geometry/bounds.js'; +import { detectObstruction, type ObstructionResult } from '../geometry/obstruction.js'; +import { parseHierarchy, findElementByText, type UiNode } from '../parsers/xml.js'; +import { parseDumpsysWindows } from '../parsers/dumpsys.js'; + +export interface FindElementResult { + target: UiNode; + tapPoint: Point; + effectivePoint: Point; + obstruction: ObstructionResult; +} + +/** + * Dump hierarchy, find element by text, run obstruction detection. + * Returns null with an error message if element not found. + */ +export async function findElementWithObstruction( + text: string, +): Promise<{ result: FindElementResult } | { error: string }> { + const xmlPath = resolve('view.xml'); + await adb.dumpHierarchy(xmlPath); + const xml = readFileSync(xmlPath, 'utf-8'); + const hierarchy = parseHierarchy(xml); + + const target = findElementByText(hierarchy, text); + if (!target) { + return { error: `Element not found: "${text}"\n\nNo element with matching text or content-desc was found in the UI hierarchy.` }; + } + + if (!target.bounds) { + return { error: `Element found but has no bounds: "${text}"` }; + } + + const tapPoint = center(target.bounds); + + let dumpsysOutput: string; + try { + dumpsysOutput = await adb.dumpsysWindows(); + } catch { + dumpsysOutput = ''; + } + const windows = parseDumpsysWindows(dumpsysOutput); + + const obstruction = detectObstruction({ + hierarchy, + windows, + targetElement: target, + tapPoint, + searchText: text, + }); + + const effectivePoint = obstruction.obstructed && obstruction.adjustedPoint + ? obstruction.adjustedPoint + : tapPoint; + + return { result: { target, tapPoint, effectivePoint, obstruction } }; +} diff --git a/.claude/mcp/android-device-server/src/tools/find-element.ts b/.claude/mcp/android-device-server/src/tools/find-element.ts new file mode 100644 index 0000000000..c47d74217a --- /dev/null +++ b/.claude/mcp/android-device-server/src/tools/find-element.ts @@ -0,0 +1,77 @@ +/** + * Find element tool — locate a UI element by text/content-desc with obstruction detection. + */ + +import { z } from 'zod'; +import type { ToolDefinition } from '../utils/validation.js'; +import { validateInput } from '../utils/validation.js'; +import { findElementWithObstruction } from './find-element-pipeline.js'; + +const FindElementSchema = z.object({ + text: z.string().min(1), +}); + +const findElement: ToolDefinition = { + name: 'find_element', + description: + 'Find a UI element by text or content-desc and return tap coordinates. ' + + 'Includes two-layer obstruction detection: system overlays (TalkBack, PiP) via dumpsys, ' + + 'and in-app elements (FABs, dialogs) via the UI hierarchy. When obstructed, returns ' + + 'adjusted coordinates targeting the largest visible region of the element.', + inputSchema: { + type: 'object', + properties: { + text: { type: 'string', description: 'Text or content-desc to search for' }, + }, + required: ['text'], + }, + async handler(input: unknown): Promise { + const { text } = validateInput(FindElementSchema, input); + + const outcome = await findElementWithObstruction(text); + if ('error' in outcome) return outcome.error; + + const { target, tapPoint, effectivePoint, obstruction } = outcome.result; + const lines: string[] = []; + + if (!obstruction.obstructed) { + lines.push(`Element found: "${target.text || target.contentDesc}"`); + lines.push(`Coordinates: (${effectivePoint.x}, ${effectivePoint.y})`); + lines.push('Status: CLEAR'); + } else { + lines.push(`Element found: "${target.text || target.contentDesc}"`); + lines.push(`Status: OBSTRUCTED by ${obstruction.obstructor}`); + if (obstruction.fullyObscured) { + lines.push(`Coordinates: (${effectivePoint.x}, ${effectivePoint.y}) — FULLY OBSCURED, original center used`); + } else { + lines.push(`Adjusted coordinates: (${effectivePoint.x}, ${effectivePoint.y}) — center of largest visible strip`); + } + } + + const result = { + found: true, + text: target.text, + contentDesc: target.contentDesc, + resourceId: target.resourceId, + bounds: target.bounds, + center: tapPoint, + effectivePoint, + obstructed: obstruction.obstructed, + ...(obstruction.obstructed ? { + obstructor: obstruction.obstructor, + obstructorBounds: obstruction.obstructorBounds, + fullyObscured: obstruction.fullyObscured, + visibleRegion: obstruction.visibleRegion?.rect ?? null, + } : {}), + }; + + lines.push(''); + lines.push('```json'); + lines.push(JSON.stringify(result, null, 2)); + lines.push('```'); + + return lines.join('\n'); + }, +}; + +export default findElement; diff --git a/.claude/mcp/android-device-server/src/tools/input-text.ts b/.claude/mcp/android-device-server/src/tools/input-text.ts new file mode 100644 index 0000000000..276908778b --- /dev/null +++ b/.claude/mcp/android-device-server/src/tools/input-text.ts @@ -0,0 +1,70 @@ +/** + * Input text tool — type text into the focused field, with optional clearing. + */ + +import { z } from 'zod'; +import type { ToolDefinition } from '../utils/validation.js'; +import { validateInput } from '../utils/validation.js'; +import * as adb from '../adb/adb.js'; + +const KEYCODE_MOVE_END = 123; +const KEYCODE_DEL = 67; + +const InputTextSchema = z.object({ + text: z.string().min(1), + clear: z.boolean().default(false), +}); + +/** + * Clear the currently focused text field by moving to the end and + * sending enough delete key events to remove all characters. + * Uses a generous count to ensure complete clearing. + */ +async function clearField(): Promise { + await adb.keyevent(KEYCODE_MOVE_END); + // Send 50 deletes — more than enough for any reasonable field length. + // ADB processes them almost instantly and extras on an empty field are no-ops. + const deletes = Array(50).fill(String(KEYCODE_DEL)).join(' '); + await adb.shell(`input keyevent ${deletes}`); +} + +const inputText: ToolDefinition = { + name: 'input_text', + description: + 'Type text into the currently focused input field. Optionally clear existing content first. ' + + 'The field must already be focused (tap it first if needed).', + inputSchema: { + type: 'object', + properties: { + text: { type: 'string', description: 'Text to type into the focused field' }, + clear: { + type: 'boolean', + description: 'Clear existing field content before typing (default: false)', + }, + }, + required: ['text'], + }, + async handler(input: unknown): Promise { + const { text, clear } = validateInput(InputTextSchema, input); + + if (clear) { + await clearField(); + } + + // Escape characters that the Android shell interprets inside double quotes: + // " $ ` \ are all special in sh double-quoted strings. + const escaped = text + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"') + .replace(/\$/g, '\\$') + .replace(/`/g, '\\`'); + await adb.shell(`input text "${escaped}"`); + + const lines: string[] = []; + if (clear) lines.push('Cleared existing content'); + lines.push(`Typed: "${text}"`); + return lines.join('\n'); + }, +}; + +export default inputText; \ No newline at end of file diff --git a/.claude/mcp/android-device-server/src/tools/navigate.ts b/.claude/mcp/android-device-server/src/tools/navigate.ts new file mode 100644 index 0000000000..f234ad4a08 --- /dev/null +++ b/.claude/mcp/android-device-server/src/tools/navigate.ts @@ -0,0 +1,62 @@ +/** + * Navigate tool — perform common navigation actions on the device. + */ + +import { z } from 'zod'; +import { resolve } from 'node:path'; +import type { ToolDefinition } from '../utils/validation.js'; +import { validateInput } from '../utils/validation.js'; +import * as adb from '../adb/adb.js'; + +const NavigateSchema = z.object({ + action: z.enum(['home', 'back', 'app-drawer']), + waitSeconds: z.number().min(0).default(1), +}); + +const navigate: ToolDefinition = { + name: 'navigate', + description: + 'Perform a navigation action on the Android device: go home, press back, or open the app drawer. ' + + 'Captures a screenshot after the action completes.', + inputSchema: { + type: 'object', + properties: { + action: { + type: 'string', + enum: ['home', 'back', 'app-drawer'], + description: 'Navigation action to perform', + }, + waitSeconds: { type: 'number', description: 'Seconds to wait after action before capture (default: 1)' }, + }, + required: ['action'], + }, + async handler(input: unknown): Promise { + const { action, waitSeconds } = validateInput(NavigateSchema, input); + + switch (action) { + case 'home': + await adb.keyevent(3); + break; + case 'back': + await adb.keyevent(4); + break; + case 'app-drawer': { + const screen = await adb.getScreenSize(); + const cx = Math.floor(screen.width / 2); + const fromY = Math.floor(screen.height * 0.93); + const toY = Math.floor(screen.height * 0.17); + await adb.swipe(cx, fromY, cx, toY, 1000); + break; + } + } + + await adb.sleep(waitSeconds ?? 1); + + const pngPath = resolve('screen.png'); + await adb.screenshot(pngPath); + + return `Navigated: ${action}\nScreenshot saved to: ${pngPath}`; + }, +}; + +export default navigate; diff --git a/.claude/mcp/android-device-server/src/tools/tap-at.ts b/.claude/mcp/android-device-server/src/tools/tap-at.ts new file mode 100644 index 0000000000..ea8fd1fce6 --- /dev/null +++ b/.claude/mcp/android-device-server/src/tools/tap-at.ts @@ -0,0 +1,44 @@ +/** + * Tap at coordinates tool — tap a specific screen location, wait, and capture screenshot. + */ + +import { z } from 'zod'; +import { resolve } from 'node:path'; +import type { ToolDefinition } from '../utils/validation.js'; +import { validateInput } from '../utils/validation.js'; +import * as adb from '../adb/adb.js'; + +const TapAtSchema = z.object({ + x: z.number().int().nonnegative(), + y: z.number().int().nonnegative(), + waitSeconds: z.number().min(0).default(2), +}); + +const tapAt: ToolDefinition = { + name: 'tap_at', + description: + 'Tap at specific screen coordinates, wait for the UI to settle, and capture a screenshot. ' + + 'Returns the path to the captured screenshot.', + inputSchema: { + type: 'object', + properties: { + x: { type: 'number', description: 'X coordinate to tap' }, + y: { type: 'number', description: 'Y coordinate to tap' }, + waitSeconds: { type: 'number', description: 'Seconds to wait after tap before capture (default: 2)' }, + }, + required: ['x', 'y'], + }, + async handler(input: unknown): Promise { + const { x, y, waitSeconds } = validateInput(TapAtSchema, input); + + await adb.tap(x, y); + await adb.sleep(waitSeconds ?? 2); + + const pngPath = resolve('screen.png'); + await adb.screenshot(pngPath); + + return `Tapped at (${x}, ${y}), waited ${waitSeconds}s\nScreenshot saved to: ${pngPath}`; + }, +}; + +export default tapAt; diff --git a/.claude/mcp/android-device-server/src/tools/tap-element.ts b/.claude/mcp/android-device-server/src/tools/tap-element.ts new file mode 100644 index 0000000000..1a61f80631 --- /dev/null +++ b/.claude/mcp/android-device-server/src/tools/tap-element.ts @@ -0,0 +1,65 @@ +/** + * Tap element tool — find an element by text, tap it, and capture screenshot. + * Uses the shared find-element pipeline for obstruction detection. + */ + +import { z } from 'zod'; +import { resolve } from 'node:path'; +import type { ToolDefinition } from '../utils/validation.js'; +import { validateInput } from '../utils/validation.js'; +import * as adb from '../adb/adb.js'; +import { findElementWithObstruction } from './find-element-pipeline.js'; + +const TapElementSchema = z.object({ + text: z.string().min(1), + waitSeconds: z.number().min(0).default(2), +}); + +const tapElement: ToolDefinition = { + name: 'tap_element', + description: + 'Find a UI element by text or content-desc, tap it, and capture a screenshot. ' + + 'Automatically detects obstructions and adjusts tap coordinates to the largest visible region. ' + + 'Returns element info, tap coordinates, obstruction status, and screenshot path.', + inputSchema: { + type: 'object', + properties: { + text: { type: 'string', description: 'Text or content-desc of the element to tap' }, + waitSeconds: { type: 'number', description: 'Seconds to wait after tap before capture (default: 2)' }, + }, + required: ['text'], + }, + async handler(input: unknown): Promise { + const { text, waitSeconds } = validateInput(TapElementSchema, input); + + const outcome = await findElementWithObstruction(text); + if ('error' in outcome) return `Error: ${outcome.error}`; + + const { target, effectivePoint, obstruction } = outcome.result; + const lines: string[] = []; + + lines.push(`Element found: "${target.text || target.contentDesc}"`); + + if (obstruction.obstructed) { + lines.push(`WARNING: Obstructed by ${obstruction.obstructor}`); + if (obstruction.fullyObscured) { + lines.push('FULLY OBSCURED — tapping original center as best effort'); + } else { + lines.push(`Using adjusted coordinates: (${effectivePoint.x}, ${effectivePoint.y})`); + } + } + + await adb.tap(effectivePoint.x, effectivePoint.y); + await adb.sleep(waitSeconds ?? 2); + + const pngPath = resolve('screen.png'); + await adb.screenshot(pngPath); + + lines.push(`Tapped at (${effectivePoint.x}, ${effectivePoint.y})`); + lines.push(`Screenshot saved to: ${pngPath}`); + + return lines.join('\n'); + }, +}; + +export default tapElement; diff --git a/.claude/mcp/android-device-server/src/utils/validation.ts b/.claude/mcp/android-device-server/src/utils/validation.ts new file mode 100644 index 0000000000..69abd9f63c --- /dev/null +++ b/.claude/mcp/android-device-server/src/utils/validation.ts @@ -0,0 +1,32 @@ +/** + * Input validation and tool definition types. + */ + +import { z } from 'zod'; + +/** + * Shape of a tool module's default export. + * Each tool file exports a ToolDefinition with metadata and a handler function. + */ +export interface ToolDefinition { + name: string; + description: string; + inputSchema: any; + handler: (input: any) => Promise; +} + +/** + * Validate input against a Zod schema. + * @throws {Error} with formatted validation messages on failure + */ +export function validateInput(schema: z.ZodSchema, input: unknown): T { + try { + return schema.parse(input); + } catch (error) { + if (error instanceof z.ZodError) { + const messages = error.errors.map(e => `${e.path.join('.')}: ${e.message}`); + throw new Error(`Validation failed: ${messages.join(', ')}`); + } + throw error; + } +} diff --git a/.claude/mcp/android-device-server/tsconfig.json b/.claude/mcp/android-device-server/tsconfig.json new file mode 100644 index 0000000000..7a055c682c --- /dev/null +++ b/.claude/mcp/android-device-server/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "outDir": "./build", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "build", "src/**/*.spec.ts"] +} \ No newline at end of file diff --git a/.claude/skills/interacting-with-android-device/SKILL.md b/.claude/skills/interacting-with-android-device/SKILL.md new file mode 100644 index 0000000000..ad127160fb --- /dev/null +++ b/.claude/skills/interacting-with-android-device/SKILL.md @@ -0,0 +1,134 @@ +--- +name: interacting-with-android-device +description: Instructions for capturing UI state, comparing with mocks, and interacting with an Android device using MCP tools backed by ADB. +allowed-tools: mcp__android-device__capture, mcp__android-device__find_element, mcp__android-device__tap_at, mcp__android-device__tap_element, mcp__android-device__navigate, mcp__android-device__input_text, Bash(adb:*), Bash(sleep:*), Bash(./gradlew install*:*), Read, Glob +--- + +# Interacting with Android Device + +## Quick Start: MCP Tools + +The `android-device` MCP server provides 6 tools for device interaction. These replace the previous shell scripts with proper XML parsing, structured dumpsys parsing, and native obstruction detection. + +**Available tools:** +- `capture` — Capture UI hierarchy XML and/or screenshot. Params: `{ xml?: boolean, screenshot?: boolean }`. Default: both. +- `find_element` — Find element by `text` or `content-desc`, return coordinates with **obstruction detection**. Params: `{ text: string }`. Returns JSON with coordinates, bounds, and obstruction status. +- `tap_at` — Tap at specific coordinates, wait, capture screenshot. Params: `{ x, y, waitSeconds? }`. +- `tap_element` — Find, tap, and capture in one call (recommended). Params: `{ text, waitSeconds? }`. Auto-adjusts coordinates when obstructed. +- `navigate` — Navigation actions: home, back, app-drawer. Params: `{ action, waitSeconds? }`. Captures screenshot after action. +- `input_text` — Type text into the focused field. Params: `{ text, clear? }`. Set `clear: true` to erase existing content first. + +**Use these MCP tools instead of raw ADB commands** to save tokens, get structured results, and benefit from automatic obstruction detection. + +## 1. Capturing Current State +To understand what is currently on the device, use the `capture` tool: +* It saves `view.xml` (UI hierarchy) and `screen.png` (screenshot) to the working directory +* Read `view.xml` to find coordinates (`bounds`) and properties (like `text` or `resource-id`) of UI elements +* Use `screen.png` for visual verification against design mocks + +## 2. Interacting with the Device + +### Using MCP Tools (Recommended) + +* **Find and tap an element by text** — use `tap_element`: + This finds the element, detects obstructions, taps (with adjusted coordinates if needed), and captures a screenshot — all in one call. + +* **Tap at specific coordinates** — use `tap_at`: + When you already have coordinates from `find_element` or manual inspection. + +* **Navigate (home, back, app-drawer)** — use `navigate`: + Performs the action and captures a screenshot. + +* **Find element without tapping** — use `find_element`: + Returns coordinates and full element info. Useful when you need to inspect before acting. + +* **Type text into a field** — use `input_text`: + Types text into the currently focused field. Set `clear: true` to erase existing content first. Tap the field before calling this if it isn't already focused. + +### Raw ADB Commands (When MCP Tools Aren't Sufficient) +* **Key Events**: + * Back: `adb shell input keyevent 4` + * Home: `adb shell input keyevent 3` + * Enter: `adb shell input keyevent 66` +* **Scrolling/Swiping**: Use `adb shell input swipe ` where: + * `(x1, y1)` = starting point + * `(x2, y2)` = ending point + * `duration_ms` = duration in milliseconds (1000ms is typical; adjust for speed/distance) + * **Note**: For expanding containers/drawers, use large distances (e.g., 2400->300 for a 2992px tall screen) + +## 3. Obstruction Detection + +The `find_element` and `tap_element` tools automatically detect when another element would intercept the tap. This catches: +* **System overlays** (Layer 1): TalkBack floating menu, PiP windows, accessibility services — detected via `dumpsys window windows` touchable regions +* **In-app elements** (Layer 2): FABs, dialogs, bottom sheets, snackbars — detected by finding the topmost clickable element at the tap point in the UI hierarchy + +When obstruction is detected: +* Coordinates are **auto-adjusted** to the center of the largest unobstructed strip (top/bottom/left/right of the obstructor) +* The response includes the obstructor identity, bounds, and visible region info +* If fully obscured (no visible region), the original center is returned as best-effort +* **Compose parent wrapper** pattern (identical bounds) is recognized as non-obstruction + +## 4. Verification Workflow +Follow these steps for a complete UI test: +1. **Build and Install**: Ensure the latest version of the app is running: `./gradlew installDebug`. +2. **Inspect**: Use `capture` to dump the UI hierarchy and take a screenshot. +3. **Compare**: Check the current UI against any mock image files in the project. +4. **Interact**: Use `tap_element` to tap a UI element by text. The tool handles coordinate calculation and obstruction detection automatically. +5. **Verify**: Use `capture` again to confirm the UI has updated as expected (e.g., a new screen is shown, or a success message appeared). + +## 5. Examples + +### Example: Navigate to Settings and Check for Updates +``` +# Go to home screen +navigate({ action: "home" }) + +# Open app drawer +navigate({ action: "app-drawer" }) + +# Find and tap through settings +tap_element({ text: "Settings", waitSeconds: 2 }) +tap_element({ text: "System", waitSeconds: 2 }) +tap_element({ text: "Software updates", waitSeconds: 2 }) +tap_element({ text: "Check for update", waitSeconds: 5 }) +``` + +### Example: Swiping +For swipe gestures not covered by the navigate tool, use raw ADB: +```bash +adb shell input swipe 672 2800 672 500 1000 && sleep 1 && adb shell screencap -p /sdcard/screen.png && adb pull /sdcard/screen.png . +``` + +## 6. Best Practices + +### Coordinate Calculation +* Prefer `find_element` or `tap_element` over manual coordinate calculation — they handle bounds parsing, center computation, and obstruction detection automatically +* When multiple instances of an element exist (e.g., in prediction row and full list), check the `find_element` response to verify you're targeting the correct one + +### Navigation and State Evaluation +* **Verify after each interaction**: Don't assume an action succeeded — use `capture` after interactions to confirm the UI changed as expected +* **Check both visual and structural state**: Use screenshot for visual verification, XML dump for structural confirmation (element presence, text content, state changes) +* **Identify navigation failures early**: If a tap opened the wrong screen, use `navigate({ action: "back" })` to recover immediately + +### Interaction Patterns +* **Scrolling before interaction**: When looking for an element, check if it's visible on screen first. If not, scroll using swipe gestures to reveal it +* **Use consistent scroll direction**: For vertical scrolling in lists/settings, use downward swipes (higher Y -> lower Y) to scroll down +* **Handle app crashes gracefully**: Don't retry the same action — use back button and try an alternative approach +* **Check Accessibility**: Use the `content-desc` and `text` properties in the UI hierarchy to ensure the UI is accessible for screen readers + +## 7. Troubleshooting + +### Device Not Connected +If tools report ADB errors: +* Check USB connection or emulator status +* Enable USB debugging on the device (Settings > Developer Options > USB Debugging) +* Accept the RSA key prompt on the device if asked +* Restart the device or disconnect/reconnect the USB cable +* Run `adb devices` to verify the device is visible + +### MCP Server Not Available +If tools are not listed in `/mcp`: +* Ensure Node.js 18+ is installed +* The server auto-builds on first use via `.mcp.json` at the project root +* Check `.claude/mcp/android-device-server/` exists with `package.json` +* Try manual build: `cd .claude/mcp/android-device-server && npm install && npm run build` diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000000..48aef09a3d --- /dev/null +++ b/.mcp.json @@ -0,0 +1,9 @@ +{ + "mcpServers": { + "android-device": { + "type": "stdio", + "command": "bash", + "args": ["-c", "cd .claude/mcp/android-device-server && npm install --silent >/dev/null 2>&1 && npm run build >/dev/null 2>&1 && exec node build/index.js"] + } + } +}