/** * NativeDialogInterceptor — Intercepts native Alert and Permission dialogs * so AI agents can interact with them. * * Native Alerts and Permission dialogs exist outside the React fiber tree, * making them invisible to the introspector. This interceptor bridges the gap: * * - **Alert.alert()**: Registers alert buttons as tappable elements. * When an agent is connected, the native dialog is suppressed (can't be * dismissed programmatically). When no agent is connected, the native * dialog is shown normally. * * - **PermissionsAndroid.request()**: Registers "Grant" and "Deny" as * tappable elements. When an agent is connected, the native permission * dialog is suppressed and the promise resolves when the agent taps a * button. When no agent is connected, the native dialog is shown normally. * * - **react-native-permissions** (optional): If installed, patches `request()` * and `requestMultiple()` to intercept iOS/Android permission dialogs * (push notifications, location, camera, etc.). */ import { Alert, PermissionsAndroid, Platform, type AlertButton, } from 'react-native'; import { ElementRegistry } from './ElementRegistry'; import type { ElementHandlers } from './types'; import { logDebug } from './logger'; const ALERT_TIMEOUT_MS = 30_000; // Auto-cleanup after 30s const PERMISSION_TIMEOUT_MS = 60_000; // Auto-cleanup after 60s // ─── Shared State ──────────────────────────────────────────────────────────── /** Callback to check if an agent is currently connected */ let checkAgentConnected: () => boolean = () => false; /** Original methods before patching */ let originalAlert: typeof Alert.alert | null = null; let originalPermissionRequest: typeof PermissionsAndroid.request | null = null; /** react-native-permissions originals (optional library) */ let rnPermissionsModule: any = null; let originalRNPRequest: ((...args: any[]) => Promise) | null = null; let originalRNPRequestMultiple: ((...args: any[]) => Promise) | null = null; /** IDs of currently registered alert/permission elements */ let activeDialogIds: string[] = []; /** Timeout handle for auto-cleanup */ let cleanupTimer: ReturnType | null = null; // ─── Helpers ───────────────────────────────────────────────────────────────── /** * Remove all currently registered dialog elements from the registry. */ function clearDialogElements(): void { for (const id of activeDialogIds) { ElementRegistry.unregister(id); } activeDialogIds = []; if (cleanupTimer) { clearTimeout(cleanupTimer); cleanupTimer = null; } } /** * Sanitize text to create a valid element ID. */ function toId(text: string): string { return text .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-|-$/g, ''); } /** * Register a button element for a dialog and start cleanup timer. */ function registerDialogButton( elementId: string, label: string, hint: string, onPress: () => void, timeoutMs: number ): void { const handlers: ElementHandlers = { onPress: () => { onPress(); clearDialogElements(); }, }; ElementRegistry.register( elementId, { type: 'button', label, state: { disabled: false, selected: false }, position: { x: 0, y: 0, width: 0, height: 0 }, children: [], actions: ['tap'], accessibilityRole: 'button', accessibilityHint: hint, }, { current: null }, handlers ); activeDialogIds.push(elementId); // Reset auto-cleanup timer if (cleanupTimer) clearTimeout(cleanupTimer); cleanupTimer = setTimeout(() => { logDebug('Dialog elements auto-cleanup (timeout)'); clearDialogElements(); }, timeoutMs); } // ─── Alert Interception ────────────────────────────────────────────────────── function patchAlert(): void { if (originalAlert) return; originalAlert = Alert.alert.bind(Alert); Alert.alert = ( title?: string, message?: string, buttons?: AlertButton[], options?: any ) => { clearDialogElements(); const effectiveButtons = buttons ?? [{ text: 'OK' }]; logDebug( `Alert intercepted: "${title}" with ${effectiveButtons.length} button(s)` ); // If no agent connected, show the real native alert if (!checkAgentConnected()) { originalAlert!(title, message, buttons, options); } // Register buttons as tappable elements regardless // (agent may connect while dialog is showing) for (const btn of effectiveButtons) { const buttonText = btn.text ?? 'OK'; const alertTitle = title ?? 'alert'; const elementId = `alert-${toId(alertTitle)}-${toId(buttonText)}`; registerDialogButton( elementId, buttonText, `Alert button: ${buttonText}`, () => btn.onPress?.(), ALERT_TIMEOUT_MS ); } }; } function unpatchAlert(): void { if (!originalAlert) return; Alert.alert = originalAlert; originalAlert = null; } // ─── Permission Interception (Android only) ────────────────────────────────── function patchPermissions(): void { if (Platform.OS !== 'android') return; if (originalPermissionRequest) return; originalPermissionRequest = PermissionsAndroid.request.bind(PermissionsAndroid); PermissionsAndroid.request = ( permission: (typeof PermissionsAndroid.PERMISSIONS)[keyof typeof PermissionsAndroid.PERMISSIONS], rationale?: any ): Promise => { // If no agent connected, use the real native permission dialog if (!checkAgentConnected()) { return originalPermissionRequest!(permission, rationale); } logDebug(`Permission intercepted: ${permission}`); // Suppress the native dialog and let the agent decide return new Promise((resolve) => { clearDialogElements(); const permName = permission.split('.').pop() ?? permission; const baseId = `permission-${toId(permName)}`; registerDialogButton( `${baseId}-grant`, 'Grant', `Grant ${permName} permission`, () => resolve(PermissionsAndroid.RESULTS.GRANTED), PERMISSION_TIMEOUT_MS ); registerDialogButton( `${baseId}-deny`, 'Deny', `Deny ${permName} permission`, () => resolve(PermissionsAndroid.RESULTS.DENIED), PERMISSION_TIMEOUT_MS ); }); }; } function unpatchPermissions(): void { if (Platform.OS !== 'android') return; if (!originalPermissionRequest) return; PermissionsAndroid.request = originalPermissionRequest; originalPermissionRequest = null; } // ─── react-native-permissions Interception (iOS + Android) ────────────────── /** * Extract a human-readable permission name from a permission string. * e.g. 'ios.permission.CAMERA' → 'camera' * 'android.permission.ACCESS_FINE_LOCATION' → 'access-fine-location' */ function permissionToName(permission: string): string { const parts = permission.split('.'); const last = parts[parts.length - 1] ?? permission; return toId(last); } /** * Register Grant/Deny/Block buttons for a permission request. * Returns a promise that resolves when the agent taps a button. */ function registerPermissionButtons( permName: string, resolve: (result: string) => void ): void { const baseId = `permission-${permName}`; registerDialogButton( `${baseId}-grant`, 'Allow', `Allow ${permName} permission`, () => resolve('granted'), PERMISSION_TIMEOUT_MS ); registerDialogButton( `${baseId}-deny`, "Don't Allow", `Deny ${permName} permission`, () => resolve('denied'), PERMISSION_TIMEOUT_MS ); registerDialogButton( `${baseId}-block`, 'Never Ask Again', `Block ${permName} permission permanently`, () => resolve('blocked'), PERMISSION_TIMEOUT_MS ); } function patchRNPermissions(): void { try { rnPermissionsModule = require('react-native-permissions'); } catch { // react-native-permissions not installed — skip return; } if (!rnPermissionsModule?.request) return; // Patch request() originalRNPRequest = rnPermissionsModule.request; rnPermissionsModule.request = (permission: string, ...rest: any[]) => { if (!checkAgentConnected()) { return originalRNPRequest!(permission, ...rest); } const permName = permissionToName(permission); logDebug(`Permission intercepted (react-native-permissions): ${permName}`); return new Promise((resolve) => { clearDialogElements(); registerPermissionButtons(permName, resolve); }); }; // Patch requestMultiple() if (rnPermissionsModule.requestMultiple) { originalRNPRequestMultiple = rnPermissionsModule.requestMultiple; rnPermissionsModule.requestMultiple = (permissions: string[]) => { if (!checkAgentConnected()) { return originalRNPRequestMultiple!(permissions); } logDebug( `Multiple permissions intercepted: ${permissions .map(permissionToName) .join(', ')}` ); // Process permissions sequentially — register buttons for the first, // then move to the next when the agent responds const results: Record = {}; const processNext = (index: number): Promise> => { if (index >= permissions.length) { return Promise.resolve(results); } const permission = permissions[index]!; const permName = permissionToName(permission); return new Promise((resolve) => { clearDialogElements(); registerPermissionButtons(permName, resolve); }).then((result) => { results[permission] = result; return processNext(index + 1); }); }; return processNext(0); }; } logDebug('react-native-permissions interceptor installed'); } function unpatchRNPermissions(): void { if (!rnPermissionsModule) return; if (originalRNPRequest) { rnPermissionsModule.request = originalRNPRequest; originalRNPRequest = null; } if (originalRNPRequestMultiple) { rnPermissionsModule.requestMultiple = originalRNPRequestMultiple; originalRNPRequestMultiple = null; } rnPermissionsModule = null; } // ─── Public API ────────────────────────────────────────────────────────────── /** * Install the native dialog interceptor. * * @param isConnected Callback that returns true when an agent is connected. * When connected, native dialogs are suppressed. When not, they show normally. */ export function installNativeDialogInterceptor( isConnected: () => boolean ): void { checkAgentConnected = isConnected; patchAlert(); patchPermissions(); patchRNPermissions(); logDebug('Native dialog interceptor installed'); } /** * Remove the interceptor and restore original native methods. */ export function uninstallNativeDialogInterceptor(): void { clearDialogElements(); unpatchAlert(); unpatchPermissions(); unpatchRNPermissions(); checkAgentConnected = () => false; logDebug('Native dialog interceptor uninstalled'); }