# Hevy Android Accessibility Bridge — Implementation Design

## Architecture Overview

```
┌──────────────┐    HTTP (localhost)    ┌─────────────────────┐    Accessibility API    ┌──────────┐
│  Fitbit/CLI  │ ──────────────────────→│  HevyBridgeService  │ ──────────────────────→ │   Hevy   │
│  Companion   │ ←── JSON responses ─── │  (AccessibilitySvc) │ ←── UI events/state ─── │  (com.hevy) │
└──────────────┘                        └─────────────────────┘                        └──────────┘
     WiFi/ADB                                  port 18090                              Pixel phone
```

**Key constraints:**
- Zero external dependencies (no NanoHTTPd, no OkHttp, no Gson)
- Uses `java.net.ServerSocket` for HTTP and `org.json` (built into Android) for JSON
- Runs on localhost, no authentication (safe since only local apps / ADB forward can reach it)
- Dual-role: AccessibilityService + HTTP server in one process

---

## Manifest (`AndroidManifest.xml`)

```xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.example.hevybridge">

    <!-- Network: HTTP server on localhost -->
    <uses-permission android:name="android.permission.INTERNET" />
    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />

    <!-- Foreground service (required for long-running HTTP) -->
    <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
    <uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
    <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

    <!-- Wake lock so HTTP stays reachable with screen off -->
    <uses-permission android:name="android.permission.WAKE_LOCK" />

    <application
        android:allowBackup="false"
        android:label="Hevy Bridge"
        android:supportsRtl="true"
        android:theme="@style/Theme.AppCompat.Light.NoActionBar">

        <!-- Minimal launcher activity to guide user to enable accessibility service -->
        <activity
            android:name=".MainActivity"
            android:exported="true"
            android:label="Hevy Bridge Setup">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>

        <!-- The AccessibilityService + HTTP server -->
        <service
            android:name=".HevyBridgeService"
            android:exported="false"
            android:foregroundServiceType="specialUse"
            android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE">

            <intent-filter>
                <action android:name="android.accessibilityservice.AccessibilityService" />
            </intent-filter>

            <!-- Required: This is where the accessibility config XML lives -->
            <meta-data
                android:name="android.accessibilityservice"
                android:resource="@xml/accessibility_service_config" />
        </service>

    </application>
</manifest>
```

---

## Accessibility Service Config (`res/xml/accessibility_service_config.xml`)

```xml
<?xml version="1.0" encoding="utf-8"?>
<accessibility-service
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:accessibilityEventTypes="typeWindowStateChanged|typeWindowContentChanged|typeViewClicked|typeViewFocused|typeViewScrolled"
    android:accessibilityFeedbackType="feedbackGeneric"
    android:accessibilityFlags="flagRetrieveInteractiveWindows|flagReportViewIds|flagIncludeNotImportantViews"
    android:canRetrieveWindowContent="true"
    android:canPerformGestures="true"
    android:notificationTimeout="100"
    android:packageNames="com.hevy"
    android:description="@string/accessibility_service_description" />
```

**Critical flags explained:**
- `flagRetrieveInteractiveWindows` — needed for `getWindows()` to find Hevy's window
- `flagReportViewIds` — surfaces `android:viewId` resource names for reliable targeting
- `canPerformGestures` — required for `dispatchGesture()` (Android 7+, needed for reliable clicks)
- `packageNames="com.hevy"` — scopes service to ONLY Hevy, reducing privacy concerns and battery drain
- `notificationTimeout="100"` — 100ms debounce; fine for interactive mirroring

---

## Service Class: `HevyBridgeService.java`

```java
package com.example.hevybridge;

import android.accessibilityservice.AccessibilityService;
import android.accessibilityservice.GestureDescription;
import android.app.Notification;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.Intent;
import android.graphics.Path;
import android.graphics.Rect;
import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.os.PowerManager;
import android.util.Log;
import android.view.accessibility.AccessibilityEvent;
import android.view.accessibility.AccessibilityNodeInfo;
import android.view.accessibility.AccessibilityWindowInfo;

import org.json.JSONArray;
import org.json.JSONObject;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.ServerSocket;
import java.net.Socket;
import java.net.URLDecoder;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class HevyBridgeService extends AccessibilityService {
    private static final String TAG = "HevyBridge";
    private static final int PORT = 18090;
    private static final String CHANNEL_ID = "hevy_bridge_foreground";
    private static final int NOTIFICATION_ID = 1;

    private ServerSocket serverSocket;
    private ExecutorService executor;
    private Handler mainHandler;
    private PowerManager.WakeLock wakeLock;

    // ============ LIFECYCLE ============

    @Override
    public void onCreate() {
        super.onCreate();
        mainHandler = new Handler(Looper.getMainLooper());
        executor = Executors.newCachedThreadPool();
        acquireWakeLock();
        startForegroundNotification();
        startHttpServer();
    }

    @Override
    public void onDestroy() {
        stopHttpServer();
        releaseWakeLock();
        executor.shutdown();
        super.onDestroy();
    }

    // ============ ACCESSIBILITY EVENTS ============

    @Override
    public void onAccessibilityEvent(AccessibilityEvent event) {
        if (event.getPackageName() != null &&
            event.getPackageName().toString().equals("com.hevy")) {
            Log.d(TAG, "Hevy event: " + event.getEventType() +
                  " class=" + event.getClassName());
        }
    }

    @Override
    public void onInterrupt() {
        Log.w(TAG, "Accessibility service interrupted");
    }

    // ============ FOREGROUND / WAKE LOCK ============

    private void acquireWakeLock() {
        PowerManager pm = (PowerManager) getSystemService(POWER_SERVICE);
        wakeLock = pm.newWakeLock(
            PowerManager.PARTIAL_WAKE_LOCK,
            "HevyBridge::HttpServer"
        );
        wakeLock.acquire(60 * 60 * 1000L); // 1 hour timeout
    }

    private void releaseWakeLock() {
        if (wakeLock != null && wakeLock.isHeld()) {
            wakeLock.release();
        }
    }

    private void startForegroundNotification() {
        createNotificationChannel();
        Intent intent = new Intent(this, MainActivity.class);
        PendingIntent pi = PendingIntent.getActivity(
            this, 0, intent,
            PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT
        );
        Notification notification = new Notification.Builder(this, CHANNEL_ID)
            .setContentTitle("Hevy Bridge Active")
            .setContentText("HTTP bridge running on port " + PORT)
            .setSmallIcon(android.R.drawable.ic_menu_manage)
            .setContentIntent(pi)
            .setOngoing(true)
            .build();
        startForeground(NOTIFICATION_ID, notification);
    }

    private void createNotificationChannel() {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            NotificationChannel channel = new NotificationChannel(
                CHANNEL_ID,
                "Hevy Bridge",
                NotificationManager.IMPORTANCE_LOW
            );
            channel.setDescription("Foreground notification for Hevy HTTP bridge");
            NotificationManager nm = getSystemService(NotificationManager.class);
            nm.createNotificationChannel(channel);
        }
    }

    // ============ HTTP SERVER ============

    private void startHttpServer() {
        executor.submit(() -> {
            try {
                serverSocket = new ServerSocket(PORT, 10,
                    java.net.InetAddress.getByName("127.0.0.1"));
                Log.i(TAG, "HTTP server listening on 127.0.0.1:" + PORT);
                while (!serverSocket.isClosed()) {
                    Socket client = serverSocket.accept();
                    executor.submit(() -> handleHttpRequest(client));
                }
            } catch (IOException e) {
                if (!serverSocket.isClosed()) {
                    Log.e(TAG, "Server error", e);
                }
            }
        });
    }

    private void stopHttpServer() {
        try {
            if (serverSocket != null) serverSocket.close();
        } catch (IOException e) {
            Log.e(TAG, "Error closing server", e);
        }
    }

    private void handleHttpRequest(Socket client) {
        try {
            BufferedReader reader = new BufferedReader(
                new InputStreamReader(client.getInputStream()));
            String requestLine = reader.readLine();
            if (requestLine == null) return;

            String[] parts = requestLine.split(" ");
            if (parts.length < 3) return;

            String method = parts[0];
            String path = parts[1];
            Log.d(TAG, method + " " + path);

            // Read headers (simple skip)
            String header;
            int contentLength = 0;
            while ((header = reader.readLine()) != null && !header.isEmpty()) {
                if (header.toLowerCase().startsWith("content-length:")) {
                    contentLength = Integer.parseInt(header.substring(15).trim());
                }
            }

            // Read body if POST
            String body = "";
            if (contentLength > 0) {
                char[] buf = new char[contentLength];
                int read = reader.read(buf);
                if (read > 0) body = new String(buf, 0, read);
            }

            // Route
            String response = route(method, path, body);

            // Send response
            OutputStream out = client.getOutputStream();
            byte[] respBytes = response.getBytes("UTF-8");
            String httpResponse = "HTTP/1.1 200 OK\r\n" +
                "Content-Type: application/json\r\n" +
                "Access-Control-Allow-Origin: *\r\n" +
                "Connection: close\r\n" +
                "Content-Length: " + respBytes.length + "\r\n" +
                "\r\n";
            out.write(httpResponse.getBytes("UTF-8"));
            out.write(respBytes);
            out.flush();
        } catch (Exception e) {
            Log.e(TAG, "Request handling error", e);
        } finally {
            try { client.close(); } catch (IOException ignored) {}
        }
    }

    // ============ ROUTING ============

    private String route(String method, String path, String body) {
        try {
            if (method.equals("GET") && path.equals("/state")) {
                return handleGetState();
            }
            if (method.equals("GET") && path.startsWith("/node")) {
                return handleGetNode(path);
            }
            if (method.equals("GET") && path.equals("/window")) {
                return handleGetWindow();
            }
            if (method.equals("POST") && path.equals("/action/set_done")) {
                return handleSetDone(body);
            }
            if (method.equals("POST") && path.equals("/action/skip_rest")) {
                return handleSkipRest();
            }
            if (method.equals("POST") && path.equals("/action/timer_adjust")) {
                return handleTimerAdjust(body);
            }
            if (method.equals("POST") && path.equals("/action/click")) {
                return handleClick(body);
            }
            if (method.equals("POST") && path.equals("/action/gesture")) {
                return handleGesture(body);
            }
            if (method.equals("GET") && path.equals("/health")) {
                return jsonOk("status", "ok");
            }
            return jsonError(404, "Not found: " + method + " " + path);
        } catch (Exception e) {
            Log.e(TAG, "Route error", e);
            return jsonError(500, e.getMessage());
        }
    }

    // ============ ENDPOINT HANDLERS ============

    /**
     * GET /state
     * Returns current Hevy UI state: visible exercise, set number, rest timer, etc.
     */
    private String handleGetState() {
        final String[] result = {null};
        final Object lock = new Object();
        final Exception[] error = {null};

        mainHandler.post(() -> {
            try {
                JSONObject state = new JSONObject();
                AccessibilityNodeInfo root = getRootInActiveWindow();
                if (root == null) {
                    result[0] = jsonOk("hevy_visible", false);
                    return;
                }
                state.put("hevy_visible", true);

                // Hunt for known UI elements
                findAndSet(state, root, "exercise_name", "com.hevy:id/exercise_name");
                findAndSet(state, root, "set_number", "com.hevy:id/set_number");
                findAndSet(state, root, "set_weight", "com.hevy:id/set_weight");
                findAndSet(state, root, "set_reps", "com.hevy:id/set_reps");
                findAndSet(state, root, "rest_timer", "com.hevy:id/rest_timer");
                findAndSet(state, root, "rest_timer_text", "com.hevy:id/rest_timer_text");

                // Check for timer node
                AccessibilityNodeInfo timerNode = findFirstByResourceId(root, "com.hevy:id/rest_timer");
                if (timerNode != null) {
                    state.put("timer_seconds", parseTimerText(timerNode));
                    timerNode.recycle();
                }

                // Check for rest screen indicators
                List<AccessibilityNodeInfo> restNodes = root.findAccessibilityNodeInfosByText("REST");
                state.put("rest_screen_visible", restNodes != null && !restNodes.isEmpty());
                if (restNodes != null) {
                    for (AccessibilityNodeInfo n : restNodes) n.recycle();
                }

                root.recycle();
                result[0] = jsonSuccess(state);
            } catch (Exception e) {
                error[0] = e;
            } finally {
                synchronized (lock) { lock.notify(); }
            }
        });

        synchronized (lock) {
            try { lock.wait(5000); } catch (InterruptedException e) {
                return jsonError(500, "Interrupted");
            }
        }
        if (error[0] != null) return jsonError(500, error[0].getMessage());
        return result[0] != null ? result[0] : jsonError(500, "null result");
    }

    /**
     * GET /window — dumps full accessibility tree as JSON.
     */
    private String handleGetWindow() {
        final String[] result = {null};
        final Object lock = new Object();
        final Exception[] error = {null};

        mainHandler.post(() -> {
            try {
                AccessibilityNodeInfo root = getRootInActiveWindow();
                if (root == null) {
                    result[0] = jsonOk("hevy_visible", false);
                    return;
                }
                JSONObject tree = dumpNode(root, 0);
                root.recycle();
                result[0] = jsonSuccess(tree);
            } catch (Exception e) {
                error[0] = e;
            } finally {
                synchronized (lock) { lock.notify(); }
            }
        });

        synchronized (lock) {
            try { lock.wait(10000); } catch (InterruptedException e) {
                return jsonError(500, "Interrupted");
            }
        }
        if (error[0] != null) return jsonError(500, error[0].getMessage());
        return result[0] != null ? result[0] : jsonError(500, "null result");
    }

    /**
     * GET /node?query=X — find nodes whose resource-id contains X.
     */
    private String handleGetNode(String path) {
        String query = extractQueryParam(path, "query");
        if (query == null) return jsonError(400, "Missing ?query= param");

        final String[] result = {null};
        final Object lock = new Object();
        final Exception[] error = {null};

        mainHandler.post(() -> {
            try {
                AccessibilityNodeInfo root = getRootInActiveWindow();
                if (root == null) {
                    result[0] = jsonOk("found", false);
                    return;
                }
                List<AccessibilityNodeInfo> matches = findByResourceIdFragment(root, query);
                JSONArray arr = new JSONArray();
                for (AccessibilityNodeInfo n : matches) {
                    arr.put(nodeToJson(n));
                }
                root.recycle();
                JSONObject response = new JSONObject();
                response.put("count", arr.length());
                response.put("matches", arr);
                result[0] = jsonSuccess(response);
            } catch (Exception e) {
                error[0] = e;
            } finally {
                synchronized (lock) { lock.notify(); }
            }
        });

        synchronized (lock) {
            try { lock.wait(5000); } catch (InterruptedException e) {
                return jsonError(500, "Interrupted");
            }
        }
        if (error[0] != null) return jsonError(500, error[0].getMessage());
        return result[0] != null ? result[0] : jsonError(500, "null result");
    }

    /**
     * POST /action/set_done
     * Finds and clicks the Done/Log Set button.
     */
    private String handleSetDone(String body) {
        final String[] result = {null};
        final Object lock = new Object();
        final Exception[] error = {null};

        mainHandler.post(() -> {
            try {
                JSONObject response = new JSONObject();
                AccessibilityNodeInfo root = getRootInActiveWindow();
                if (root == null) {
                    result[0] = jsonError(400, "Hevy not visible");
                    return;
                }

                // Strategy 1: Resource IDs (most reliable)
                String[] doneIds = {
                    "com.hevy:id/btn_done",
                    "com.hevy:id/button_done",
                    "com.hevy:id/log_set_button",
                    "com.hevy:id/checkmark_button",
                    "com.hevy:id/doneButton"
                };
                for (String id : doneIds) {
                    AccessibilityNodeInfo node = findFirstByResourceId(root, id);
                    if (node != null && node.isClickable()) {
                        boolean ok = performClick(node);
                        node.recycle();
                        root.recycle();
                        response.put("method", "resource_id");
                        response.put("resource_id", id);
                        response.put("success", ok);
                        result[0] = jsonSuccess(response);
                        return;
                    }
                    if (node != null) node.recycle();
                }

                // Strategy 2: Text search (fallback)
                String[] doneTexts = {"Done", "Log Set", "COMPLETE", "Save"};
                for (String text : doneTexts) {
                    AccessibilityNodeInfo node = findFirstByText(root, text);
                    if (node != null && node.isClickable()) {
                        boolean ok = performClick(node);
                        node.recycle();
                        root.recycle();
                        response.put("method", "text_match");
                        response.put("matched_text", text);
                        response.put("success", ok);
                        result[0] = jsonSuccess(response);
                        return;
                    }
                    if (node != null) node.recycle();
                }

                root.recycle();
                result[0] = jsonError(404, "Done button not found");
            } catch (Exception e) {
                error[0] = e;
            } finally {
                synchronized (lock) { lock.notify(); }
            }
        });

        synchronized (lock) {
            try { lock.wait(5000); } catch (InterruptedException e) {
                return jsonError(500, "Interrupted");
            }
        }
        if (error[0] != null) return jsonError(500, error[0].getMessage());
        return result[0] != null ? result[0] : jsonError(500, "null result");
    }

    /**
     * POST /action/skip_rest
     * Finds and clicks the Skip Rest button during rest timer.
     */
    private String handleSkipRest() {
        final String[] result = {null};
        final Object lock = new Object();
        final Exception[] error = {null};

        mainHandler.post(() -> {
            try {
                JSONObject response = new JSONObject();
                AccessibilityNodeInfo root = getRootInActiveWindow();
                if (root == null) {
                    result[0] = jsonError(400, "Hevy not visible");
                    return;
                }

                String[] skipIds = {
                    "com.hevy:id/btn_skip_rest",
                    "com.hevy:id/skip_rest_button",
                    "com.hevy:id/rest_skip",
                    "com.hevy:id/skipButton"
                };
                for (String id : skipIds) {
                    AccessibilityNodeInfo node = findFirstByResourceId(root, id);
                    if (node != null && node.isClickable()) {
                        boolean ok = performClick(node);
                        node.recycle();
                        root.recycle();
                        response.put("method", "resource_id");
                        response.put("resource_id", id);
                        response.put("success", ok);
                        result[0] = jsonSuccess(response);
                        return;
                    }
                    if (node != null) node.recycle();
                }

                String[] skipTexts = {"Skip Rest", "Skip", "SKIP"};
                for (String text : skipTexts) {
                    AccessibilityNodeInfo node = findFirstByText(root, text);
                    if (node != null && node.isClickable()) {
                        boolean ok = performClick(node);
                        node.recycle();
                        root.recycle();
                        response.put("method", "text_match");
                        response.put("matched_text", text);
                        response.put("success", ok);
                        result[0] = jsonSuccess(response);
                        return;
                    }
                    if (node != null) node.recycle();
                }

                root.recycle();
                result[0] = jsonError(404, "Skip rest button not found");
            } catch (Exception e) {
                error[0] = e;
            } finally {
                synchronized (lock) { lock.notify(); }
            }
        });

        synchronized (lock) {
            try { lock.wait(5000); } catch (InterruptedException e) {
                return jsonError(500, "Interrupted");
            }
        }
        if (error[0] != null) return jsonError(500, error[0].getMessage());
        return result[0] != null ? result[0] : jsonError(500, "null result");
    }

    /**
     * POST /action/timer_adjust
     * Body: { "seconds": 90 }
     * Adjusts the rest timer to the specified value.
     */
    private String handleTimerAdjust(String body) {
        final String[] result = {null};
        final Object lock = new Object();
        final Exception[] error = {null};

        mainHandler.post(() -> {
            try {
                JSONObject params = new JSONObject(body);
                int targetSeconds = params.optInt("seconds", 60);
                JSONObject response = new JSONObject();

                AccessibilityNodeInfo root = getRootInActiveWindow();
                if (root == null) {
                    result[0] = jsonError(400, "Hevy not visible");
                    return;
                }

                // Strategy 1: Click the timer text to open adjustment dialog
                AccessibilityNodeInfo timerNode = findFirstByResourceId(root, "com.hevy:id/rest_timer");
                if (timerNode == null) {
                    timerNode = findFirstByResourceId(root, "com.hevy:id/timer_value");
                }
                if (timerNode != null && timerNode.isClickable()) {
                    performClick(timerNode);
                    timerNode.recycle();
                    try { Thread.sleep(400); } catch (InterruptedException ignored) {}
                    // Refresh root after dialog opens
                    AccessibilityNodeInfo newRoot = getRootInActiveWindow();
                    if (newRoot != null) {
                        AccessibilityNodeInfo input = findFirstByClassName(newRoot, "android.widget.EditText");
                        if (input != null) {
                            performSetText(input, String.valueOf(targetSeconds));
                            input.recycle();
                            AccessibilityNodeInfo confirm = findFirstByText(newRoot, "OK");
                            if (confirm == null) confirm = findFirstByText(newRoot, "Set");
                            if (confirm == null) confirm = findFirstByText(newRoot, "Save");
                            if (confirm != null) {
                                performClick(confirm);
                                confirm.recycle();
                            }
                            newRoot.recycle();
                            root.recycle();
                            response.put("method", "text_input");
                            response.put("seconds", targetSeconds);
                            result[0] = jsonSuccess(response);
                            return;
                        }
                        newRoot.recycle();
                    }
                }
                if (timerNode != null) timerNode.recycle();

                // Strategy 2: Use +/- stepper buttons (15s increments)
                AccessibilityNodeInfo plusBtn = findFirstByResourceId(root, "com.hevy:id/btn_timer_plus");
                if (plusBtn == null) plusBtn = findFirstByText(root, "+");

                AccessibilityNodeInfo minusBtn = findFirstByResourceId(root, "com.hevy:id/btn_timer_minus");
                if (minusBtn == null) minusBtn = findFirstByText(root, "-");

                // Read current timer
                AccessibilityNodeInfo currentTimerNode = findFirstByResourceId(root, "com.hevy:id/rest_timer");
                int currentSeconds = currentTimerNode != null ? parseTimerText(currentTimerNode) : 60;
                if (currentTimerNode != null) currentTimerNode.recycle();

                int step = 15;
                int delta = targetSeconds - currentSeconds;
                int clicks = Math.abs(delta) / step;
                AccessibilityNodeInfo btn = delta > 0 ? plusBtn : minusBtn;

                for (int i = 0; i < clicks && btn != null; i++) {
                    performClick(btn);
                    try { Thread.sleep(80); } catch (InterruptedException ignored) {}
                }

                if (plusBtn != null) plusBtn.recycle();
                if (minusBtn != null) minusBtn.recycle();
                root.recycle();

                response.put("method", "stepper");
                response.put("clicks", clicks);
                response.put("target_seconds", targetSeconds);
                result[0] = jsonSuccess(response);
            } catch (Exception e) {
                error[0] = e;
            } finally {
                synchronized (lock) { lock.notify(); }
            }
        });

        synchronized (lock) {
            try { lock.wait(10000); } catch (InterruptedException e) {
                return jsonError(500, "Interrupted");
            }
        }
        if (error[0] != null) return jsonError(500, error[0].getMessage());
        return result[0] != null ? result[0] : jsonError(500, "null result");
    }

    /**
     * POST /action/click
     * Body: { "resource_id": "com.hevy:id/..." } or { "text": "Done" }
     */
    private String handleClick(String body) {
        final String[] result = {null};
        final Object lock = new Object();
        final Exception[] error = {null};

        mainHandler.post(() -> {
            try {
                JSONObject params = new JSONObject(body);
                AccessibilityNodeInfo root = getRootInActiveWindow();
                if (root == null) {
                    result[0] = jsonError(400, "Hevy not visible");
                    return;
                }

                AccessibilityNodeInfo target = null;
                if (params.has("resource_id")) {
                    target = findFirstByResourceId(root, params.getString("resource_id"));
                } else if (params.has("text")) {
                    target = findFirstByText(root, params.getString("text"));
                }

                if (target == null) {
                    root.recycle();
                    result[0] = jsonError(404, "Target not found");
                    return;
                }

                boolean ok = performClick(target);
                target.recycle();
                root.recycle();
                result[0] = jsonOk("success", ok);
            } catch (Exception e) {
                error[0] = e;
            } finally {
                synchronized (lock) { lock.notify(); }
            }
        });

        synchronized (lock) {
            try { lock.wait(5000); } catch (InterruptedException e) {
                return jsonError(500, "Interrupted");
            }
        }
        if (error[0] != null) return jsonError(500, error[0].getMessage());
        return result[0] != null ? result[0] : jsonError(500, "null result");
    }

    /**
     * POST /action/gesture
     * Body: { "x": 540, "y": 1200 }
     * Direct screen coordinate tap.
     */
    private String handleGesture(String body) {
        try {
            JSONObject params = new JSONObject(body);
            float x = (float) params.getDouble("x");
            float y = (float) params.getDouble("y");

            final boolean[] success = {false};
            final Object lock = new Object();

            mainHandler.post(() -> {
                GestureDescription.Builder builder = new GestureDescription.Builder();
                Path path = new Path();
                path.moveTo(x, y);
                builder.addStroke(new GestureDescription.StrokeDescription(path, 0, 1));

                dispatchGesture(builder.build(), new GestureResultCallback() {
                    @Override
                    public void onCompleted(GestureDescription gd) {
                        success[0] = true;
                        synchronized (lock) { lock.notify(); }
                    }
                    @Override
                    public void onCancelled(GestureDescription gd) {
                        success[0] = false;
                        synchronized (lock) { lock.notify(); }
                    }
                }, null);
            });

            synchronized (lock) {
                try { lock.wait(2000); } catch (InterruptedException e) {
                    return jsonError(500, "Interrupted");
                }
            }
            return jsonOk("success", success[0], "x", x, "y", y);
        } catch (Exception e) {
            return jsonError(400, e.getMessage());
        }
    }

    // ============ ACCESSIBILITY TREE HELPERS ============

    /**
     * Three-tier click strategy:
     * 1. ACTION_CLICK on node itself
     * 2. ACTION_CLICK on first clickable parent
     * 3. dispatchGesture at node's screen center
     */
    private boolean performClick(AccessibilityNodeInfo node) {
        if (node == null) return false;

        // Tier 1: Direct click
        if (node.isClickable()) {
            boolean ok = node.performAction(AccessibilityNodeInfo.ACTION_CLICK);
            if (ok) return true;
        }

        // Tier 2: Walk to clickable parent
        AccessibilityNodeInfo parent = node.getParent();
        while (parent != null) {
            if (parent.isClickable()) {
                boolean ok = parent.performAction(AccessibilityNodeInfo.ACTION_CLICK);
                parent.recycle();
                if (ok) return true;
                break;
            }
            AccessibilityNodeInfo next = parent.getParent();
            parent.recycle();
            parent = next;
        }

        // Tier 3: Gesture tap at node center
        Rect bounds = new Rect();
        node.getBoundsInScreen(bounds);
        if (!bounds.isEmpty()) {
            return performGestureTap(bounds.centerX(), bounds.centerY());
        }

        return false;
    }

    private boolean performGestureTap(float x, float y) {
        final boolean[] result = {false};
        final Object lock = new Object();

        mainHandler.post(() -> {
            GestureDescription.Builder builder = new GestureDescription.Builder();
            Path path = new Path();
            path.moveTo(x, y);
            builder.addStroke(new GestureDescription.StrokeDescription(path, 0, 1));

            dispatchGesture(builder.build(), new GestureResultCallback() {
                @Override
                public void onCompleted(GestureDescription gd) {
                    result[0] = true;
                    synchronized (lock) { lock.notify(); }
                }
                @Override
                public void onCancelled(GestureDescription gd) {
                    result[0] = false;
                    synchronized (lock) { lock.notify(); }
                }
            }, null);
        });

        synchronized (lock) {
            try { lock.wait(1000); } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }
        return result[0];
    }

    private boolean performSetText(AccessibilityNodeInfo node, String text) {
        if (node == null) return false;
        Bundle args = new Bundle();
        args.putCharSequence(
            AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE,
            text
        );
        return node.performAction(AccessibilityNodeInfo.ACTION_SET_TEXT, args);
    }

    // ============ TREE SEARCH ============

    private AccessibilityNodeInfo findFirstByResourceId(AccessibilityNodeInfo root, String resourceId) {
        List<AccessibilityNodeInfo> results = new ArrayList<>();
        findByResourceId(root, resourceId, results);
        return results.isEmpty() ? null : results.get(0);
    }

    private void findByResourceId(AccessibilityNodeInfo node, String resourceId,
                                   List<AccessibilityNodeInfo> results) {
        if (resourceId.equals(node.getViewIdResourceName())) {
            results.add(node); // caller must recycle
            return;
        }
        for (int i = 0; i < node.getChildCount(); i++) {
            AccessibilityNodeInfo child = node.getChild(i);
            if (child != null) {
                findByResourceId(child, resourceId, results);
                if (results.isEmpty()) child.recycle();
            }
        }
    }

    private AccessibilityNodeInfo findFirstByText(AccessibilityNodeInfo root, String text) {
        List<AccessibilityNodeInfo> nodes = root.findAccessibilityNodeInfosByText(text);
        if (nodes == null || nodes.isEmpty()) return null;
        for (AccessibilityNodeInfo n : nodes) {
            if (n.isClickable()) return n;
        }
        return nodes.get(0);
    }

    private AccessibilityNodeInfo findFirstByClassName(AccessibilityNodeInfo root, String className) {
        if (className.equals(root.getClassName())) return root;
        for (int i = 0; i < root.getChildCount(); i++) {
            AccessibilityNodeInfo child = root.getChild(i);
            if (child != null) {
                AccessibilityNodeInfo found = findFirstByClassName(child, className);
                if (found != null) return found;
                child.recycle();
            }
        }
        return null;
    }

    private List<AccessibilityNodeInfo> findByResourceIdFragment(
            AccessibilityNodeInfo root, String fragment) {
        List<AccessibilityNodeInfo> results = new ArrayList<>();
        collectByResourceIdFragment(root, fragment, results);
        return results;
    }

    private void collectByResourceIdFragment(AccessibilityNodeInfo node, String fragment,
                                              List<AccessibilityNodeInfo> results) {
        String id = node.getViewIdResourceName();
        if (id != null && id.contains(fragment)) {
            results.add(node);
        }
        for (int i = 0; i < node.getChildCount(); i++) {
            AccessibilityNodeInfo child = node.getChild(i);
            if (child != null) {
                collectByResourceIdFragment(child, fragment, results);
            }
        }
    }

    /**
     * Parse Hevy rest timer text like "1:30" or "0:45" into total seconds.
     */
    private int parseTimerText(AccessibilityNodeInfo node) {
        CharSequence text = node.getText();
        if (text == null) return -1;
        String s = text.toString().trim();
        String[] parts = s.split(":");
        if (parts.length == 2) {
            try {
                return Integer.parseInt(parts[0]) * 60 + Integer.parseInt(parts[1]);
            } catch (NumberFormatException ignored) {}
        }
        try {
            return Integer.parseInt(s);
        } catch (NumberFormatException ignored) {}
        return -1;
    }

    private void findAndSet(JSONObject json, AccessibilityNodeInfo root,
                            String key, String resourceId) {
        AccessibilityNodeInfo node = findFirstByResourceId(root, resourceId);
        if (node != null) {
            CharSequence text = node.getText();
            json.put(key, text != null ? text.toString() : "(no text)");
            node.recycle();
        }
    }

    // ============ JSON OUTPUT HELPERS ============

    private JSONObject nodeToJson(AccessibilityNodeInfo node) {
        JSONObject j = new JSONObject();
        try {
            j.put("class", String.valueOf(node.getClassName()));
            j.put("resource_id", node.getViewIdResourceName());
            j.put("text", String.valueOf(node.getText()));
            j.put("content_desc", String.valueOf(node.getContentDescription()));
            j.put("clickable", node.isClickable());
            j.put("enabled", node.isEnabled());
            j.put("focusable", node.isFocusable());
            j.put("checked", node.isChecked());
            Rect bounds = new Rect();
            node.getBoundsInScreen(bounds);
            j.put("bounds", bounds.flattenToString());
            j.put("child_count", node.getChildCount());
        } catch (Exception ignored) {}
        return j;
    }

    private JSONObject dumpNode(AccessibilityNodeInfo node, int depth) {
        JSONObject j = nodeToJson(node);
        try {
            j.put("depth", depth);
            JSONArray children = new JSONArray();
            for (int i = 0; i < node.getChildCount(); i++) {
                AccessibilityNodeInfo child = node.getChild(i);
                if (child != null) {
                    children.put(dumpNode(child, depth + 1));
                    child.recycle();
                }
            }
            j.put("children", children);
        } catch (Exception ignored) {}
        return j;
    }

    // ============ JSON FACTORIES ============

    private String jsonOk(String key, Object value) {
        try {
            JSONObject j = new JSONObject();
            j.put(key, value);
            return j.toString();
        } catch (Exception e) { return "{}"; }
    }

    private String jsonOk(String key1, Object val1, String key2, Object val2) {
        try {
            JSONObject j = new JSONObject();
            j.put(key1, val1);
            j.put(key2, val2);
            return j.toString();
        } catch (Exception e) { return "{}"; }
    }

    private String jsonOk(String key1, Object val1, String key2, Object val2,
                          String key3, Object val3) {
        try {
            JSONObject j = new JSONObject();
            j.put(key1, val1);
            j.put(key2, val2);
            j.put(key3, val3);
            return j.toString();
        } catch (Exception e) { return "{}"; }
    }

    private String jsonOk(String key1, Object val1, String key2, Object val2,
                          String key3, Object val3, String key4, Object val4) {
        try {
            JSONObject j = new JSONObject();
            j.put(key1, val1);
            j.put(key2, val2);
            j.put(key3, val3);
            j.put(key4, val4);
            return j.toString();
        } catch (Exception e) { return "{}"; }
    }

    private String jsonSuccess(JSONObject data) {
        try {
            JSONObject j = new JSONObject();
            j.put("ok", true);
            j.put("data", data);
            return j.toString();
        } catch (Exception e) { return jsonError(500, e.getMessage()); }
    }

    private String jsonError(int code, String message) {
        try {
            JSONObject j = new JSONObject();
            j.put("ok", false);
            j.put("error", code);
            j.put("message", message);
            return j.toString();
        } catch (Exception e) { return "{\"ok\":false}"; }
    }

    private String extractQueryParam(String path, String key) {
        int q = path.indexOf('?');
        if (q < 0) return null;
        String query = path.substring(q + 1);
        for (String pair : query.split("&")) {
            String[] kv = pair.split("=", 2);
            if (kv.length == 2 && kv[0].equals(key)) {
                try {
                    return URLDecoder.decode(kv[1], "UTF-8");
                } catch (Exception e) { return kv[1]; }
            }
        }
        return null;
    }
}
```

---

## Usage / API Reference

### Complete Endpoint Reference

| Method | Endpoint | Body | Returns |
|--------|----------|------|---------|
| `GET` | `/health` | — | `{"status":"ok"}` |
| `GET` | `/state` | — | Full Hevy UI state (exercise, sets, timer, etc.) |
| `GET` | `/window` | — | Full accessibility tree dump as JSON |
| `GET` | `/node?query=X` | — | List of nodes whose resource-id contains X |
| `POST` | `/action/set_done` | `{}` | Click result + method used |
| `POST` | `/action/skip_rest` | `{}` | Click result |
| `POST` | `/action/timer_adjust` | `{"seconds":90}` | Adjustment method + target |
| `POST` | `/action/click` | `{"resource_id":"..."}` or `{"text":"..."}` | Click result |
| `POST` | `/action/gesture` | `{"x":500,"y":1200}` | Tap result |

All responses: `{"ok":true,"data":{...}}` or `{"ok":false,"error":...,"message":"..."}`

### Example Usage

```bash
# Health check
curl http://localhost:18090/health
# → {"status":"ok"}

# Get current Hevy state
curl http://localhost:18090/state
# → {"ok":true,"data":{"hevy_visible":true,"exercise_name":"Bench Press","set_number":"Set 3",...}}

# Log current set as done
curl -X POST http://localhost:18090/action/set_done

# Skip rest timer
curl -X POST http://localhost:18090/action/skip_rest

# Set timer to 90 seconds
curl -X POST http://localhost:18090/action/timer_adjust -d '{"seconds":90}'

# Debug: dump full tree
curl http://localhost:18090/window

# Debug: find nodes with "timer" in resource-id
curl "http://localhost:18090/node?query=timer"

# Generic click by resource-id
curl -X POST http://localhost:18090/action/click -d '{"resource_id":"com.hevy:id/btn_add_set"}'

# Generic click by text
curl -X POST http://localhost:18090/action/click -d '{"text":"Add Set"}'

# Direct screen tap (540,1850)
curl -X POST http://localhost:18090/action/gesture -d '{"x":540,"y":1850}'
```

**From Fitbit over WiFi:** Use `fetch('http://PHONE_IP:18090/...')`.
**From ADB:** `adb forward tcp:18090 tcp:18090` then `curl localhost:18090/...`

---

## Key iOS-to-Android Mapping

| Concept | iOS (Hevy) | Android (Hevy) |
|---------|-----------|----------------|
| Find element | `XCUIElement` queries | `findAccessibilityNodeInfosByText()` or by `viewIdResourceName` |
| Tap element | `.tap()` | `performAction(ACTION_CLICK)` or `dispatchGesture()` |
| Read text | `.label` | `getText()` / `getContentDescription()` |
| App package | `com.hevy.ios` | `com.hevy` |
| Foreground check | XCUITest `activate()` | `getRootInActiveWindow()` → check package name |
| Permissions | iOS Accessibility permission | Android Accessibility Service (user must enable in Settings) |

---

## Critical Pitfalls

### 1. RESOURCE IDS ARE GUESSES — MUST BE VERIFIED
The resource-ids in the code (`com.hevy:id/btn_done`, `com.hevy:id/skip_rest_button`, etc.) are educated guesses based on common Android naming conventions. You MUST extract the real ones:
```bash
# Dump Hevy APK resource IDs
aapt dump resources hevy.apk | grep -E 'id/(btn|button|timer|rest|set|exercise)' | head -30
```
Or use the runtime debug endpoint `GET /window` to discover actual IDs on the target device.

### 2. ACCESSIBILITY SERVICE MUST BE ENABLED MANUALLY
Unlike iOS where accessibility can sometimes be granted programmatically, Android requires the user to go to **Settings → Accessibility → Hevy Bridge** and toggle it ON. Your `MainActivity` must detect this and guide the user:
```java
if (!isAccessibilityServiceEnabled()) {
    startActivity(new Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS));
}
```

### 3. MAIN THREAD REQUIREMENT — YOUR BIGGEST BUG SOURCE
**All `AccessibilityNodeInfo` operations MUST happen on the main (UI) thread.** If you call `getRootInActiveWindow()` or `performAction()` from an HTTP worker thread, you'll get silent `null` returns or no-ops. Every endpoint handler above delegates to `mainHandler.post()` and blocks on a lock.

### 4. NODE RECYCLING — MEMORY LEAKS WILL KILL YOUR SERVICE
Every `AccessibilityNodeInfo` object must be `.recycle()`d. The system enforces a ~50 unrecycled node limit before throttling. Watch especially for:
- `getChild()` — each child returned must be recycled
- `getParent()` — each parent must be recycled
- `findAccessibilityNodeInfosByText()` — every node in the returned list must be recycled

### 5. CLICKABLE PARENTS
Hevy may wrap text in non-clickable `TextView` children inside a clickable `LinearLayout`. `performClick()` handles this by walking up to the first clickable parent (Tier 2).

### 6. dispatchGesture() AS LAST RESORT
`performAction(ACTION_CLICK)` sometimes fails on custom views. `dispatchGesture()` with the node's screen bounds almost always works but requires `canPerformGestures=true` in config XML.

### 7. ANDROID 10+ FOREGROUND SERVICE REQUIREMENT
The HTTP server must run as a foreground service with a persistent notification or Android kills it within minutes. The design above uses `startForeground()` with a low-importance notification channel.

### 8. SCREEN-OFF: getRootInActiveWindow() RETURNS NULL
When the screen is off, the window content is not available. The wake lock keeps the CPU alive but doesn't keep the screen on. For reliable operation, the screen must be on (consider `FLAG_KEEP_SCREEN_ON` in Hevy itself, or brief wake-ups).

### 9. BATTERY OPTIMIZATION EXEMPTION
The user must exempt Hevy Bridge from battery optimization:
```java
Intent intent = new Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS);
intent.setData(Uri.parse("package:" + getPackageName()));
startActivity(intent);
```

### 10. Hevy DISPLAYS REST TIMER IN MULTIPLE CONTEXTS
Hevy shows the rest timer in at least two different UI states:
- **Full rest timer screen** (between sets, with Skip button prominent)
- **Small timer overlay** (during workout, in a corner)

Your handlers need to account for both. Use `GET /window` to understand the current UI structure.

### 11. TIMER ADJUST IS THE HARDEST ENDPOINT
Hevy's timer adjustment UI varies significantly across versions:
- Some have +/- 15s stepper buttons
- Some have a clickable timer that opens a number-picker dialog
- Some have a slider/dial
The `timer_adjust` handler uses three strategies. Expect to tune this per Hevy version.

### 12. CONCURRENT REQUEST RACES
Two rapid requests (e.g., `/state` during a `set_done` animation) can interleave. The bridge processes one request per connection, but multiple connections can run concurrently. If the UI is mid-transition, results may be inconsistent.

### 13. PORT CONFLICTS
Port 18090 is intentionally obscure but could clash. Consider making it configurable or checking availability at startup.

### 14. HTTP REQUEST BODY READING
The simple `BufferedReader.read(char[])` approach works for small JSON bodies but won't handle chunked encoding. All requests from your companion should use `Content-Length` with small bodies.

### 15. FITBIT-TO-PHONE NETWORKING
At the gym, the Fitbit and phone must be on the same WiFi network (or use ADB forwarding for testing). The phone's IP may change (DHCP), so the Fitbit companion needs a discovery mechanism or static IP. The phone must also allow incoming connections on port 18090 (no firewall blocking local network traffic).

---

## Minimal MainActivity

```java
package com.example.hevybridge;

import android.content.Intent;
import android.os.Bundle;
import android.provider.Settings;
import android.widget.Button;
import android.widget.TextView;
import androidx.appcompat.app.AppCompatActivity;

public class MainActivity extends AppCompatActivity {
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        TextView statusText = findViewById(R.id.status_text);
        Button btnSettings = findViewById(R.id.btn_settings);

        updateStatus(statusText);

        btnSettings.setOnClickListener(v -> {
            startActivity(new Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS));
        });
    }

    @Override
    protected void onResume() {
        super.onResume();
        updateStatus(findViewById(R.id.status_text));
    }

    private void updateStatus(TextView tv) {
        boolean enabled = isAccessibilityServiceEnabled();
        tv.setText(enabled ? "✓ Service enabled — HTTP on :18090" : "✗ Service disabled");
    }

    private boolean isAccessibilityServiceEnabled() {
        String service = getPackageName() + "/" + HevyBridgeService.class.getName();
        try {
            int enabled = Settings.Secure.getInt(
                getContentResolver(), Settings.Secure.ACCESSIBILITY_ENABLED);
            if (enabled != 1) return false;
            String services = Settings.Secure.getString(
                getContentResolver(), Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES);
            return services != null && services.contains(service);
        } catch (Settings.SettingNotFoundException e) {
            return false;
        }
    }
}
```

---

## Build Configuration (minimal `build.gradle`)

```groovy
android {
    namespace 'com.example.hevybridge'
    compileSdk 34
    defaultConfig {
        applicationId "com.example.hevybridge"
        minSdk 26
        targetSdk 34
        versionCode 1
        versionName "1.0"
    }
}

dependencies {
    implementation 'androidx.appcompat:appcompat:1.6.1'
    // No other dependencies — zero external libs
}
```

---

## Initial Setup Checklist (User-Facing)

1. Install the APK (download or `adb install`)
2. Open "Hevy Bridge Setup" from launcher
3. Tap "Open Accessibility Settings"
4. Find "Hevy Bridge" in the list, toggle ON, confirm warning
5. Return to app — status shows "✓ Service enabled"
6. Optionally: exempt from battery optimization
7. Test: `adb forward tcp:18090 tcp:18090 && curl localhost:18090/health`

---

## Architecture Decision: Why No NanoHTTPd/OkHttp

The design uses raw `java.net.ServerSocket` with manual HTTP parsing because:
1. **Zero external dependencies** — the APK is tiny and has no supply-chain risk
2. **No Gradle dependency resolution** — simpler build, fewer version conflicts
3. **The HTTP surface is trivial** — 2 GET methods, 5 POST methods, simple JSON bodies
4. **NanoHTTPd is single-file but requires a Gradle dependency or manual file inclusion** — either way adds complexity

The tradeoff: no HTTPS (not needed for localhost), no request routing framework (just if/else), no streaming body parsing (fixed-length only). All acceptable for a gym companion bridge.

---

## Summary

This design delivers:
- **Zero external dependencies** — pure Android SDK with `ServerSocket` and `org.json`
- **9 REST endpoints** covering state readout, 3 Hevy actions (set_done, skip_rest, timer_adjust), generic click/gesture, and 2 debug tools (window dump, node search)
- **3-tier clicking strategy** (ACTION_CLICK → parent ACTION_CLICK → dispatchGesture) for maximum reliability across Hevy UI variations
- **Proper lifecycle** — foreground service, wake lock, main-thread safety, node recycling
- **No cloud dependency** — works on local WiFi between phone and Fitbit
- **Debug-first** — `/window` and `/node` endpoints let you discover real resource IDs before hardcoding

The single biggest implementation risk: **resource IDs change between Hevy versions**. Mitigation: use `/window` to discover actual IDs on your target version, and implement text-based fallbacks for all critical actions (as shown in the code above).
