Skip to main content
Glama
accessibilityServiceManager.ts13.5 kB
import { AdbUtils } from "./android-cmdline-tools/adb"; import { logger } from "./logger"; import * as fs from "fs/promises"; import * as path from "path"; import { exec } from "child_process"; import { promisify } from "util"; import { LaunchApp } from "../features/action/LaunchApp"; import { TapOnElement } from "../features/action/TapOnElement"; import { PressButton } from "../features/action/PressButton"; import { TerminateApp } from "../features/action/TerminateApp"; import { BootedDevice } from "../models"; const execAsync = promisify(exec); export class AccessibilityServiceManager { private readonly device: BootedDevice; private adb: AdbUtils; public static readonly PACKAGE = "com.zillow.automobile.accessibilityservice"; public static readonly ACTIVITY = "com.zillow.automobile.accessibilityservice.MainActivity"; private static readonly APK_URL = "https://github.com/zillow/auto-mobile/releases/download/0.0.5/accessibility-service-debug.apk"; // Static cache for service availability private cachedAvailability: { isAvailable: boolean; timestamp: number } | null = null; private static readonly AVAILABILITY_CACHE_TTL = 60 * 60 * 1000; // 1 hour // Static caches for individual status checks private cachedInstallation: { isInstalled: boolean; timestamp: number } | null = null; private cachedEnabled: { isEnabled: boolean; timestamp: number } | null = null; private static readonly STATUS_CACHE_TTL = 30 * 60 * 1000; // 30 minutes private attemptedAutomatedSetup: boolean = false; private static instances: Map<string, AccessibilityServiceManager> = new Map(); private constructor(device: BootedDevice, adb: AdbUtils) { // home should either be process.env.HOME or bash resolution of home for current user const homeDir = process.env.HOME || require("os").homedir(); if (!homeDir) { throw new Error("Home directory for current user not found"); } this.device = device; this.adb = adb || new AdbUtils(this.device); } public static getInstance(device: BootedDevice, adb: AdbUtils | null = null): AccessibilityServiceManager { if (!AccessibilityServiceManager.instances.has(device.deviceId)) { AccessibilityServiceManager.instances.set(device.deviceId, new AccessibilityServiceManager( device, adb || new AdbUtils(device) )); } return AccessibilityServiceManager.instances.get(device.deviceId)!; } /** * Reset all instances (for testing) */ public static resetInstances(): void { AccessibilityServiceManager.instances.clear(); } /** * Clear the cached availability status */ public clearAvailabilityCache(): void { this.cachedAvailability = null; this.cachedInstallation = null; this.cachedEnabled = null; logger.info("[ACCESSIBILITY_SERVICE] Cleared all availability caches"); } /** * Check if Accessibility Service is installed on the device */ async isInstalled(): Promise<boolean> { // Check cache first if (this.cachedInstallation && this.cachedInstallation.isInstalled) { const cacheAge = Date.now() - this.cachedInstallation.timestamp; if (cacheAge < AccessibilityServiceManager.STATUS_CACHE_TTL) { logger.info(`[ACCESSIBILITY_SERVICE] Using cached installation status (age: ${cacheAge}ms): ${this.cachedInstallation.isInstalled ? "installed" : "not installed"}`); return this.cachedInstallation.isInstalled; } else { this.cachedInstallation = null; } } try { logger.info("[ACCESSIBILITY_SERVICE] Checking if accessibility service is installed"); const result = await this.adb.executeCommand(`shell pm list packages | grep ${AccessibilityServiceManager.PACKAGE}`, undefined, undefined, true); const isInstalled = result.stdout.includes(AccessibilityServiceManager.PACKAGE); // Cache the result this.cachedInstallation = { isInstalled, timestamp: Date.now() }; logger.info(`[ACCESSIBILITY_SERVICE] Service installation status: ${isInstalled ? "installed" : "not installed"} (cached for ${AccessibilityServiceManager.STATUS_CACHE_TTL / 1000 / 60} minutes)`); return isInstalled; } catch (error) { logger.warn(`[ACCESSIBILITY_SERVICE] Error checking installation status: ${error}`); return false; } } /** * Check if Accessibility Service is enabled as an input method */ async isEnabled(): Promise<boolean> { // Check cache first if (this.cachedEnabled && this.cachedEnabled.isEnabled) { const cacheAge = Date.now() - this.cachedEnabled.timestamp; if (cacheAge < AccessibilityServiceManager.STATUS_CACHE_TTL) { logger.info(`[ACCESSIBILITY_SERVICE] Using cached enabled status (age: ${cacheAge}ms): ${this.cachedEnabled.isEnabled ? "enabled" : "disabled"}`); return this.cachedEnabled.isEnabled; } else { this.cachedEnabled = null; } } try { logger.info("[ACCESSIBILITY_SERVICE] Checking if accessibility service is enabled"); const result = await this.adb.executeCommand("shell settings get secure enabled_accessibility_services"); const isEnabled = result.stdout.includes(AccessibilityServiceManager.PACKAGE); // Cache the result this.cachedEnabled = { isEnabled, timestamp: Date.now() }; logger.info(`[ACCESSIBILITY_SERVICE] Service enabled status: ${isEnabled ? "enabled" : "disabled"} (cached for ${AccessibilityServiceManager.STATUS_CACHE_TTL / 1000 / 60} minutes)`); return isEnabled; } catch (error) { logger.warn(`[ACCESSIBILITY_SERVICE] Error checking enabled status: ${error}`); return false; } } /** * Check if the accessibility service is both installed and enabled * @returns Promise<boolean> - True if available for use, false otherwise */ async isAvailable(): Promise<boolean> { const startTime = Date.now(); // Check cache first if (this.cachedAvailability && this.cachedAvailability.isAvailable) { const cacheAge = Date.now() - this.cachedAvailability.timestamp; if (cacheAge < AccessibilityServiceManager.AVAILABILITY_CACHE_TTL) { logger.info(`[ACCESSIBILITY_SERVICE] Using cached overall availability (age: ${cacheAge}ms): ${this.cachedAvailability.isAvailable}`); return this.cachedAvailability.isAvailable; } else { this.cachedAvailability = null; } } logger.info(`[ACCESSIBILITY_SERVICE] Checking availability (no cached result available)`); try { // Check installation and enabled status in parallel for better performance const [installed, enabled] = await Promise.all([ this.isInstalled(), this.isEnabled() ]); const available = installed && enabled; const duration = Date.now() - startTime; // Cache the result this.cachedAvailability = { isAvailable: available, timestamp: Date.now() }; logger.info(`[ACCESSIBILITY_SERVICE] Availability check completed in ${duration}ms - Available: ${available} (cached for ${AccessibilityServiceManager.AVAILABILITY_CACHE_TTL / 1000 / 60} minutes)`); return available; } catch (error) { const duration = Date.now() - startTime; logger.warn(`[ACCESSIBILITY_SERVICE] Availability check failed after ${duration}ms: ${error}`); // Clear cache on error this.cachedAvailability = null; return false; } } /** * Download APK */ async downloadApk(): Promise<string> { const tempDir = "/tmp/auto-mobile/"; const apkPath = path.join(tempDir, `accessibility-service.apk`); await fs.mkdir(tempDir, { recursive: true }); try { logger.info("Downloading APK", { url: AccessibilityServiceManager.APK_URL, destination: apkPath }); // Use curl to download the APK const { stderr } = await execAsync(`curl -L -o "${apkPath}" "${AccessibilityServiceManager.APK_URL}"`); if (stderr && !stderr.includes("100")) { logger.warn("Download may have failed", { stderr }); } // Verify the file exists and has reasonable size (should be > 10KB) const stats = await fs.stat(apkPath); if (stats.size < 10000) { throw new Error(`Downloaded APK is too small (${stats.size} bytes), likely invalid`); } // Perform checksum verification const { stdout: sha256sum } = await execAsync(`sha256sum "${apkPath}"`); const actualChecksum = sha256sum.split(" ")[0]; // Expected checksum for the APK const expectedChecksum = "979fa82f632d004a3f94dd7cd366be2a8bbab55f19d0bfd722f852c3cea674d4"; if (actualChecksum !== expectedChecksum) { logger.warn("APK checksum verification failed", { expected: expectedChecksum, actual: actualChecksum }); throw new Error(`APK checksum verification failed. Expected: ${expectedChecksum}, Got: ${actualChecksum}`); } logger.info("APK checksum verified successfully", { checksum: actualChecksum }); logger.info("APK downloaded successfully", { path: apkPath, size: stats.size }); return apkPath; } catch (error) { // Clean up failed download try { await fs.unlink(apkPath); } catch { } throw new Error(`Failed to download APK: ${error instanceof Error ? error.message : String(error)}`); } } /** * Install APK */ async install(apkPath: string): Promise<void> { try { logger.info("Installing APK", { path: apkPath }); const result = await this.adb.executeCommand(`install "${apkPath}"`); const resultString = result.toString().toLowerCase(); if (resultString.includes("failure") || resultString.includes("error")) { throw new Error(`Installation failed: ${result.toString()}`); } if (!resultString.includes("success")) { logger.warn("Installation result unclear", { result: result.toString() }); } logger.info("APK installed successfully"); } catch (error) { throw new Error(`Failed to install APK: ${error instanceof Error ? error.message : String(error)}`); } } /** * Enable Accessibility Service */ async enable(): Promise<void> { try { logger.info("Enabling Accessibility Service input method"); await new TerminateApp(this.device).execute(AccessibilityServiceManager.PACKAGE); await new LaunchApp(this.device).execute( AccessibilityServiceManager.PACKAGE, false, false, AccessibilityServiceManager.ACTIVITY ); await new TapOnElement(this.device).execute({ text: "Open Accessibility Settings", action: "tap" }); await new TapOnElement(this.device).execute({ text: "AutoMobile A11Y Service", action: "tap" }); await new TapOnElement(this.device).execute({ text: "Use AutoMobile A11Y Service", action: "tap" }); await new TapOnElement(this.device).execute({ elementId: "android:id/accessibility_permission_enable_allow_button", action: "tap" }); await new PressButton(this.device).execute("back"); await new PressButton(this.device).execute("back"); await new PressButton(this.device).execute("back"); logger.info("Accessibility Service enabled successfully"); } catch (error) { throw new Error(`Failed to enable Accessibility Service: ${error instanceof Error ? error.message : String(error)}`); } } /** * Clean up temporary APK file */ async cleanupApk(apkPath: string): Promise<void> { try { await fs.unlink(apkPath); logger.info("Temporary APK file cleaned up", { path: apkPath }); } catch (error) { logger.warn("Failed to clean up temporary APK file", { path: apkPath, error: error instanceof Error ? error.message : String(error) }); } } /** * Complete setup process for Accessibility Service */ async setup(force: boolean = false): Promise<{ success: boolean; message: string; error?: string; }> { let apkPath: string | null = null; if (this.attemptedAutomatedSetup) { return { success: false, message: "Setup already attempted", }; } try { // Check if already installed and setup (unless force is true) if (!force && await this.isInstalled() && await this.isEnabled()) { return { success: true, message: "Accessibility Service was already installed and has been activated", }; } this.attemptedAutomatedSetup = true; // Download APK if not installed or force is true if (force || !await this.isInstalled()) { apkPath = await this.downloadApk(); await this.install(apkPath); } // Enable if not enabled if (!await this.isEnabled()) { await this.enable(); } return { success: true, message: "Accessibility Service installed and activated successfully", }; } catch (error) { return { success: false, message: "Failed to setup Accessibility Service", error: error instanceof Error ? error.message : String(error) }; } finally { // Clean up APK file if it was downloaded if (apkPath) { await this.cleanupApk(apkPath); } } } }

Latest Blog Posts

MCP directory API

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

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

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