ScreenSync MCP guide

Testing Android App Widgets and Glance with Claude Code and MCP

Automate Android App Widgets and Jetpack Glance UI on physical home screen launchers using Claude Code and ScreenSync MCP without in-process test barriers.

Testing Android App Widgets and Glance with Claude Code and MCP

Testing Android App Widgets on physical devices represents one of the most persistent structural blind spots in mobile software quality assurance. Since the release of Android 1.5 Cupcake in 2009, home screen widgets have served as essential glanceable interfaces, enabling users to check live financial tickers, manage calendar events, control audio playback, and monitor fitness tracking without launching full-screen applications. The introduction of Jetpack Glance in modern Android architecture has revolutionized widget development, replacing cumbersome XML layouts with declarative, Compose-style Kotlin code. Yet despite the widespread adoption of modern widgets, automated testing of home screen widgets on authentic hardware remains virtually nonexistent in standard continuous integration workflows. The fundamental architectural dilemma is straightforward: Android widgets do not execute within your application's process. Instead, your application compiles UI definitions into declarative RemoteViews parcels and dispatches them across Binder IPC to the system launcher process (com.google.android.apps.nexuslauncher, com.sec.android.app.launcher, or third-party launchers). Traditional in-process test runners like Espresso are strictly sandboxed to the target application's activity window and main looper, rendering them completely incapable of viewing or clicking widgets on the home screen. By pairing terminal-based AI coding agents like Claude Code with the Model Context Protocol (MCP) through ScreenSync MCP, mobile engineering teams can break through process barriers, orchestrating comprehensive automated widget lifecycles, responsive layout resizing, interactive callback verification, and system broadcast testing on real physical Android hardware.

Key Takeaways

  • External Launcher Process Traversal: ScreenSync MCP bypasses in-process sandboxing, allowing Claude Code to inspect and interact with Android home screen widgets running inside any OEM launcher via get_ui_hierarchy.
  • Jetpack Glance Declarative State Verification: Automate end-to-end testing of Glance composables compiled to RemoteViews, validating asynchronous data loading, state updates, and optimistic UI transitions.
  • Deterministic ADB Widget Telemetry: Trigger instant widget refreshes via androidx.glance.appwidget.action.DEBUG_UPDATE and audit system widget allocations using adb shell dumpsys appwidget.
  • Responsive Grid Resizing & Orientation Testing: Validate multi-cell layouts across 2x2, 3x2, and 4x3 grid configurations, catching layout truncation and text clipping on real launcher screens.
  • Sub-100ms Physical Device Streaming: MediaProjection delivers 60 FPS visual frames at approximately 94 milliseconds median latency over local LAN/USB without root or ADB overhead.
  • Unified 226-Tool Full-Stack Automation: Coordinate 29 mobile and desktop tools with 197 browser tools to verify end-to-end user journeys from cloud APIs to home screen widgets.

Why Traditional Test Frameworks Fail at Home Screen Widgets

Traditional test frameworks fail at Android home screen widgets because widgets execute inside the system launcher process rather than the application process sandbox. In-process test runners like Espresso cannot cross the Binder IPC boundary to inspect external launcher view trees, while unit tests with Robolectric only validate isolated composable logic without verifying real RemoteViews rendering.

To understand why automated mobile widget testing has historically been so difficult, one must examine the execution boundary separating an application from the Android home screen launcher. When a developer writes a standard mobile UI test using Espresso or Compose UI Test (createAndroidComposeRule), the test runner relies on an active Activity handle wrapped inside InstrumentationRegistry. The framework compiles a test APK that executes within the exact same process space as the target application. Espresso directly queries the application's decor view hierarchy and monitors the main thread looper (Looper.getMainLooper()) to idle until animations and asynchronous tasks complete.

However, an App Widget is not an Activity, nor does it possess an active View hierarchy within your application process. An App Widget is a remote visual component hosted by an external system client called the AppWidgetHost—almost universally the device's home screen launcher (such as Google Pixel Launcher, Samsung One UI Home, or Xiaomi HyperOS Launcher). When your application requests a widget update, the following multi-process sequence occurs:

  1. State Evaluation: The application's AppWidgetProvider or Jetpack Glance receiver processes current application state or Datastore preferences.
  2. Parcel Construction: The framework constructs a RemoteViews parcel containing serialized view instructions, method invocations (such as setTextViewText, setViewVisibility), and layout resource IDs.
  3. Binder IPC Dispatch: The application dispatches the RemoteViews parcel across Binder IPC to the operating system's central AppWidgetService.
  4. Launcher Inflation: AppWidgetService forwards the parcel to the AppWidgetHost running inside the launcher process. The launcher inflates the XML layout into its own process window, caching the layout and attaching touch gesture listeners.

Because Espresso's onView() matcher queries strictly within the decor view of your application's active activity, attempting to run Espresso assertions on the home screen immediately triggers a fatal NoMatchingViewException:

androidx.test.espresso.NoMatchingViewException: No views in hierarchy found matching: with id: widget_task_title
If the target view is not part of the view hierarchy, you may need to use UIAutomator instead.

To circumvent this limitation, Android developers traditionally rely on one of three compromised alternatives:

  1. Headless Unit Testing (glance-testing and Robolectric): Jetpack Glance introduces runGlanceAppWidgetUnitTest, allowing developers to verify composable structure in headless JVM environments. While excellent for verifying basic conditional logic, unit tests run against mock classes and completely ignore real launcher rendering, OEM font scaling, dark mode palette injection, and Android Binder serialization limits.
  2. Brittle UIAutomator Scripts: Developers write standalone UIAutomator tests (UiDevice.getInstance().findObject()). However, UIAutomator scripts rely on hardcoded resource IDs or fragile XPath queries. Because every Android OEM customizes their launcher's view hierarchy and widget container framing differently, UIAutomator scripts written for a Pixel device break spuriously when executed against Samsung One UI or Motorola launchers.
  3. Manual Human QA: Engineering teams manually install APKs, long-press the home screen, drag widgets onto the launcher grid, tap action buttons, and visually confirm state changes. This tedious process is slow, prone to human error, and completely detached from automated CI/CD pipelines.

ScreenSync MCP resolves this dilemma by decoupling test orchestration from the application memory space. Operating over the Model Context Protocol from the developer's workstation, Claude Code interacts with the device as an external, intelligent observer. ScreenSync MCP queries the Android Window Manager through system-level accessibility interfaces, providing Claude Code with the authoritative node hierarchy of the active foreground window—regardless of whether that window belongs to your application, the Pixel Launcher, or the system settings sheet.

Widget Testing Dimension Headless Unit Testing (glance-testing) In-Process Espresso (createAndroidComposeRule) Standalone UIAutomator Scripts Cloud Device Farms (BrowserStack / SauceLabs) ScreenSync MCP + Claude Code (Physical Device)
Execution Sandbox JVM Host Memory / Mock Android Runtime Application Process Boundary (Instrumentation) System-Level UI (UiDevice Daemon) Remote Emulated Cloud VM External Workstation stdio MCP + Physical Android OS
Launcher Traversal Impossible; no launcher process exists Impossible; crashes with NoMatchingViewException Possible, but requires fragile selector regex Fragile; launcher gestures frequently timeout in cloud Native; inspects exact launcher node tree via get_ui_hierarchy
Interactive Tap Callbacks Mocked; calls actionRunCallback in isolation Unsupported on external launcher Possible; taps screen coordinates High latency; tap timing often misses asynchronous updates Full verification; taps remote views and monitors background worker triage
RemoteViews IPC Validation Ignored; does not serialize Binder parcels Unsupported Ignored; cannot inspect Binder IPC buffer size Ignored Catches TransactionTooLargeException via get_logcat telemetry
Responsive Grid Resizing Simulates predefined DpSize breakpoints Unsupported Extremely difficult to script drag handles Nearly impossible across varied cloud screen ratios Native; scripts drag gestures via control_swipe and audits layouts
Visual Rendering Verification None; headless layout assertions only In-process screenshot libraries only Low frame-rate ADB screencap (~1.5 FPS) Compressed cloud WebRTC stream prone to frame tearing Native MediaProjection (~94 ms latency) via get_latest_screenshot

Jetpack Glance Architecture: Declarative UI, RemoteViews, and State Synchronization

Jetpack Glance translates declarative Compose-style code into Android RemoteViews that the system launcher inflates in an isolated process. Glance manages asynchronous state through GlanceStateDefinition and Datastore, dispatching user tap interactions through ActionCallback handlers without launching the primary application activity or requiring persistent in-memory UI observers.

To build and automate tests for modern Android widgets, engineering teams must understand the internal architecture of Jetpack Glance. Unlike standard Jetpack Compose for Android (which emits direct layout nodes to an active AndroidComposeView rendered by Skia or the RenderThread), Jetpack Glance utilizes a custom Compose runtime. When the Glance runtime composes your UI tree, it emits a hierarchy of GlanceNode objects rather than standard Android LayoutNode primitives. These GlanceNode elements are subsequently translated by Glance's internal compiler into an Android RemoteViews object containing standard framework view instructions (FrameLayout, LinearLayout, TextView, ImageView, Button).

Core Architectural Components of Jetpack Glance

A production-grade Jetpack Glance implementation consists of four interconnected layers:

  1. GlanceAppWidget: The core definition class extending GlanceAppWidget. It overrides the provideGlance(context, id) coroutine function, binding the widget's UI composable to its reactive state container.
  2. GlanceStateDefinition: Manages persistent widget state. Glance defaults to PreferencesGlanceStateDefinition, which stores widget key-value pairs inside Google's lightweight DataStore<Preferences> file. Whenever the Datastore file updates, Glance automatically schedules a widget recomposition.
  3. ActionCallback: Handles user interactions. When a user taps a Glance component configured with GlanceModifier.clickable(actionRunCallback<MyCallback>()), the launcher broadcasts an intent to your application. Android instantiates the ActionCallback in the background, executing the specified action without opening an activity.
  4. GlanceAppWidgetReceiver: A standard Android BroadcastReceiver that intercepts system widget events (APPWIDGET_UPDATE, APPWIDGET_DELETED, APPWIDGET_ENABLED) and delegates update processing to the underlying GlanceAppWidget.

Production Kotlin Implementation: Reactive Task Widget with Responsive Sizing

Here is a complete, production-grade Kotlin implementation demonstrating a responsive task widget built with Jetpack Glance, complete with asynchronous state updates, action callbacks, and responsive size definitions:

package com.example.screensync.widgets

import android.content.Context
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.glance.*
import androidx.glance.action.ActionParameters
import androidx.glance.action.actionParametersOf
import androidx.glance.action.actionStartActivity
import androidx.glance.action.clickable
import androidx.glance.appwidget.*
import androidx.glance.appwidget.action.ActionCallback
import androidx.glance.appwidget.action.actionRunCallback
import androidx.glance.appwidget.state.updateAppWidgetState
import androidx.glance.layout.*
import androidx.glance.state.GlanceStateDefinition
import androidx.glance.state.PreferencesGlanceStateDefinition
import androidx.glance.text.FontWeight
import androidx.glance.text.Text
import androidx.glance.text.TextStyle
import com.example.screensync.MainActivity

class TaskGlanceWidget : GlanceAppWidget() {

    // Define responsive layout breakpoints for 2x2, 3x2, and 4x2 grid cells
    companion object {
        val SMALL_SQUARE = DpSize(120.dp, 120.dp)
        val MEDIUM_HORIZONTAL = DpSize(240.dp, 120.dp)
        val LARGE_EXPANDED = DpSize(240.dp, 240.dp)

        val KEY_TASK_TITLE = stringPreferencesKey("key_task_title")
        val KEY_TASK_COMPLETED = booleanPreferencesKey("key_task_completed")
        val KEY_TASK_COUNT = stringPreferencesKey("key_task_count")
    }

    override val sizeMode = SizeMode.Responsive(
        setOf(SMALL_SQUARE, MEDIUM_HORIZONTAL, LARGE_EXPANDED)
    )

    override val stateDefinition: GlanceStateDefinition<*> = PreferencesGlanceStateDefinition

    override suspend fun provideGlance(context: Context, id: GlanceId) {
        provideContent {
            val prefs = currentState<Preferences>()
            val taskTitle = prefs[KEY_TASK_TITLE] ?: "Review Daily Pull Requests"
            val isCompleted = prefs[KEY_TASK_COMPLETED] ?: false
            val taskCount = prefs[KEY_TASK_COUNT] ?: "3 pending"

            val size = LocalSize.current

            GlanceTheme {
                when {
                    size.width >= LARGE_EXPANDED.width && size.height >= LARGE_EXPANDED.height -> {
                        LargeWidgetLayout(taskTitle, isCompleted, taskCount)
                    }
                    size.width >= MEDIUM_HORIZONTAL.width -> {
                        MediumWidgetLayout(taskTitle, isCompleted, taskCount)
                    }
                    else -> {
                        SmallWidgetLayout(taskTitle, isCompleted)
                    }
                }
            }
        }
    }

    @Composable
    private fun SmallWidgetLayout(title: String, completed: Boolean) {
        Column(
            modifier = GlanceModifier
                .fillMaxSize()
                .background(GlanceTheme.colors.surface)
                .padding(12.dp)
                .clickable(actionStartActivity<MainActivity>()),
            verticalAlignment = Alignment.Vertical.CenterVertically,
            horizontalAlignment = Alignment.Horizontal.CenterHorizontally
        ) {
            Text(
                text = if (completed) "Done" else "Pending",
                style = TextStyle(
                    color = GlanceTheme.colors.primary,
                    fontSize = 14.sp,
                    fontWeight = FontWeight.Bold
                )
            )
            Spacer(modifier = GlanceModifier.height(6.dp))
            Button(
                text = if (completed) "Reopen" else "Complete",
                onClick = actionRunCallback<ToggleTaskActionCallback>()
            )
        }
    }

    @Composable
    private fun MediumWidgetLayout(title: String, completed: Boolean, count: String) {
        Row(
            modifier = GlanceModifier
                .fillMaxSize()
                .background(GlanceTheme.colors.surface)
                .padding(16.dp)
                .clickable(actionStartActivity<MainActivity>()),
            verticalAlignment = Alignment.Vertical.CenterVertically
        ) {
            Column(modifier = GlanceModifier.defaultWeight()) {
                Text(
                    text = title,
                    style = TextStyle(
                        color = GlanceTheme.colors.onSurface,
                        fontSize = 16.sp,
                        fontWeight = FontWeight.Medium
                    ),
                    maxLines = 1
                )
                Text(
                    text = count,
                    style = TextStyle(
                        color = GlanceTheme.colors.secondary,
                        fontSize = 12.sp
                    )
                )
            }
            Button(
                text = if (completed) "Completed" else "Mark Done",
                onClick = actionRunCallback<ToggleTaskActionCallback>()
            )
        }
    }

    @Composable
    private fun LargeWidgetLayout(title: String, completed: Boolean, count: String) {
        Column(
            modifier = GlanceModifier
                .fillMaxSize()
                .background(GlanceTheme.colors.surface)
                .padding(16.dp)
                .clickable(actionStartActivity<MainActivity>())
        ) {
            Row(verticalAlignment = Alignment.Vertical.CenterVertically) {
                Text(
                    text = "Sprint Tasks",
                    style = TextStyle(
                        color = GlanceTheme.colors.primary,
                        fontSize = 18.sp,
                        fontWeight = FontWeight.Bold
                    )
                )
                Spacer(modifier = GlanceModifier.defaultWeight())
                Text(
                    text = count,
                    style = TextStyle(color = GlanceTheme.colors.secondary, fontSize = 12.sp)
                )
            }
            Spacer(modifier = GlanceModifier.height(12.dp))
            Row(
                modifier = GlanceModifier.fillMaxWidth(),
                verticalAlignment = Alignment.Vertical.CenterVertically
            ) {
                Text(
                    text = title,
                    modifier = GlanceModifier.defaultWeight(),
                    style = TextStyle(color = GlanceTheme.colors.onSurface, fontSize = 14.sp)
                )
                Button(
                    text = if (completed) "Undo" else "Check",
                    onClick = actionRunCallback<ToggleTaskActionCallback>()
                )
            }
        }
    }
}

class ToggleTaskActionCallback : ActionCallback {
    override suspend fun onAction(
        context: Context,
        glanceId: GlanceId,
        parameters: ActionParameters
    ) {
        // Update persistent Datastore preferences reactively
        updateAppWidgetState(context, PreferencesGlanceStateDefinition, glanceId) { prefs ->
            val current = prefs[TaskGlanceWidget.KEY_TASK_COMPLETED] ?: false
            prefs.toMutablePreferences().apply {
                this[TaskGlanceWidget.KEY_TASK_COMPLETED] = !current
            }
        }
        // Force the widget to recompose with new state
        TaskGlanceWidget().update(context, glanceId)
    }
}

class TaskGlanceWidgetReceiver : GlanceAppWidgetReceiver() {
    override val glanceAppWidget: GlanceAppWidget = TaskGlanceWidget()
}

When building complex mobile architectures with background database updates or scheduled synchronization tasks, developers can explore testing Android WorkManager with Claude Code and MCP and testing Android broadcast receivers with Claude Code and MCP to verify how background system workers trigger reactive widget recompositions.

Jetpack Glance & AppWidget Feature Underlying Framework API Execution Process Plane ScreenSync MCP Tool & ADB Inspection Primary Failure Mode Caught
Widget Placement & Inflation AppWidgetHost + AppWidgetManager System Launcher Process (nexuslauncher) get_ui_hierarchy, control_tap Layout inflation failure; missing preview resource
Interactive Tap Dispatch ActionCallback via actionRunCallback Application Background Broadcast control_tap, get_logcat PendingIntent delivery failure; unhandled callback exception
Reactive State Update PreferencesGlanceStateDefinition Application Datastore Storage get_ui_hierarchy, get_latest_screenshot Stale UI cache; widget fails to recompose after state write
Responsive Grid Resizing SizeMode.Responsive (LocalSize.current) System Launcher Window Layout control_swipe, get_latest_screenshot Text clipping, truncated labels, or composable overlap
Debug Broadcast Redraw androidx.glance.appwidget.action.DEBUG_UPDATE OS Intent Dispatcher adb shell am broadcast, get_ui_hierarchy Receiver not exported; missing debug intent filter
IPC Transaction Budget Binder RemoteViews Parcel Serialization System Binder Buffer (1 MB limit) get_logcat (TransactionTooLargeException) Uncompressed bitmaps or oversized lists crash Binder IPC
Dark Theme Dynamic Color GlanceTheme.colors (Monet Engine) System UI / Window Decor get_latest_screenshot Insufficient contrast between foreground text and widget background
Process Death Recovery Saved Datastore preferences reload Cold Process Recreation adb shell am kill, control_launch_app Widget state wiped when OS terminates host application process

How ScreenSync MCP Bridges Claude Code to the Android Launcher

ScreenSync MCP bridges Claude Code to Android home screen launchers by decoupling visual display streaming from ADB control commands. Claude Code inspects the active launcher hierarchy using get_ui_hierarchy, dispatches touch gestures via control_tap and control_swipe, and validates visual layout fidelity through native MediaProjection frames captured at approximately 94 milliseconds latency.

The architectural breakthrough of ScreenSync MCP lies in its three-plane separation, engineered specifically to eliminate the barriers of in-process test tools:

Architectural Plane Underlying Transport & Port Protocol & Latency Operational Role During Widget Automation
Cognitive Agent Plane Local stdio Process Pipe JSON-RPC (Sub-millisecond) Claude Code parses technical instructions, evaluates launcher hierarchies, and plans multi-step gestures
Visual Verification Plane Local Wi-Fi / Reverse USB Sockets (Port 3000) Native MediaProjection (~94 ms median) Streams live 60 FPS video frames (Android 7.0+, zero root, zero ADB overhead) to verify widget rendering
Hardware & OS Control Plane Physical USB or Wireless Debugging ADB Daemon Socket Dispatches adb shell am broadcasts, extracts accessibility trees (get_ui_hierarchy), and triggers touch taps

1. Traversing External Launcher Hierarchies via get_ui_hierarchy

When an Android device returns to the home screen (via control_key with key code 3 for KEYCODE_HOME), the device window manager places the launcher activity at the top of the z-order. When Claude Code invokes ScreenSync MCP's get_ui_hierarchy tool, the desktop hub requests an instantaneous accessibility node dump across the active foreground window. Unlike Espresso, which is restricted to the application's activity view tree, get_ui_hierarchy captures the complete XML layout of the launcher:

<node package="com.google.android.apps.nexuslauncher" class="android.widget.FrameLayout" bounds="[0,0][1080,2400]">
  <node class="com.android.launcher3.CellLayout" resource-id="com.google.android.apps.nexuslauncher:id/workspace">
    <node class="com.android.launcher3.widget.LauncherAppWidgetHostView" bounds="[64,320][1016,840]">
      <node package="com.example.screensync.widgets" class="android.widget.LinearLayout" resource-id="com.example.screensync:id/glance_root">
        <node class="android.widget.TextView" text="Review Daily Pull Requests" resource-id="com.example.screensync:id/text_title" bounds="[96,360][720,440]" />
        <node class="android.widget.TextView" text="3 pending" resource-id="com.example.screensync:id/text_count" bounds="[96,448][400,500]" />
        <node class="android.widget.Button" text="Mark Done" resource-id="com.example.screensync:id/btn_action" clickable="true" bounds="[740,380][984,480]" />
      </node>
    </node>
  </node>
</node>

Claude Code parses node bounds, package attributes, text contents, and clickability directly. It does not rely on fragile pixel coordinate guesses; it identifies the button node at bounds [740,380][984,480] and invokes control_tap directly at its center coordinate (x=862, y=430).

2. High-Fidelity Visual Verification via Native MediaProjection

While structural node trees provide reliable assertions for layout existence, visual verification ensures that the widget renders cleanly without typographic clipping, contrast failures, or asset loading stalls. ScreenSync MCP captures real-time video frames using Android's native MediaProjection API (supported on Android 7.0 and higher). Screen capture requires zero root access and consumes zero ADB bandwidth, maintaining a median latency of approximately 94 milliseconds over a local area network. When validating whether a widget's dynamic Material You theme correctly adapts when switching between light and dark system modes, Claude Code invokes get_latest_screenshot to confirm visual aesthetics.

3. The 226-Tool Ecosystem Advantage

ScreenSync MCP exposes a comprehensive toolset of 226 tools:

  • 29 Phone & Desktop Tools: 12 device control tools (control_tap, control_swipe, control_type, control_key, control_open_url, control_launch_app), 3 desktop OS tools (os_mouse_click, os_type, os_hotkey), and 14 capture and telemetry tools (get_latest_screenshot, get_ui_hierarchy, get_logcat, record_screen, get_device_status).
  • 197 Browser Tools: The web_* suite operates through an authenticated Chrome/Edge browser extension. In multi-surface workflows—such as testing whether updating a task inside a web dashboard immediately pushes an FCM notification that recomposes the Android home screen widget—Claude Code can execute browser actions and verify mobile widget updates within a single unified agent session. For a detailed reference of all capabilities, see the complete Android MCP tool reference deep dive.

Step-by-Step Setup: Workstation Hub, Physical Device, and Claude Code Registration

Configuring ScreenSync MCP for widget automation requires running the Node.js 18 desktop hub, registering the stdio MCP server in Claude Code, and connecting an Android 7.0+ device via USB or wireless debugging. Establishing reverse port forwarding on port 3000 guarantees continuous MediaProjection streaming across network state changes.

Engineering teams can establish this automated testing environment on Windows, macOS, or Linux through five sequential steps:

Step 1: Workstation Baseline Prerequisites

Ensure your development environment meets the following specifications:

  1. Workstation Environment: Windows 10/11, macOS, or Linux running Node.js 18 or later with adb (Android Platform Tools) installed and accessible in your system PATH.
  2. Physical Smartphone: An authentic Android device running Android 7.0 (API level 24) or higher connected via USB cable or local Wi-Fi.
  3. Application Build: A debug APK containing your Jetpack Glance widget implementation installed on the device.

Step 2: Launch the Desktop Hub

Download the ScreenSync MCP desktop hub from screensyncmcp.tech and extract the archive to a local workstation directory. Execute the launcher script:

# On Windows (PowerShell or Command Prompt)
cd C:\tools\screensync-hub
.\start-hub.bat

# On macOS or Linux (Terminal)
cd ~/tools/screensync-hub
chmod +x start-hub.sh
./start-hub.sh

The desktop hub initializes an HTTP server on port 3000 to ingest local MediaProjection video frames and launches a JSON-RPC Model Context Protocol server over stdio. The console output displays your unique authentication token (SCREEN_SYNC_TOKEN). For comprehensive documentation, visit the official ScreenSync MCP setup guide.

Step 3: Register ScreenSync MCP with Claude Code

Register the desktop hub with the Claude Code CLI in your terminal:

claude mcp add screensync node "/path/to/screensync-hub/screensync-hub.js"

If you are developing inside Claude Desktop, edit your claude_desktop_config.json file (Settings > Developer > Edit Config):

{
  "mcpServers": {
    "screensync": {
      "command": "node",
      "args": ["/path/to/screensync-hub/screensync-hub.js"],
      "env": {
        "SCREEN_SYNC_TOKEN": "your_authenticated_token_here"
      }
    }
  }
}

(Note: ScreenSync MCP integrates with local stdio MCP clients including Claude Code, Claude Desktop, Cursor, and VS Code agent mode. ChatGPT's web application requires remote HTTPS endpoints and does not support local stdio processes).

Step 4: Install the Android App and Authorize MediaProjection

  1. Download the free ScreenSync MCP mobile application (com.screensync.mcp) from Google Play.
  2. Launch the app on your physical Android device. Allow local network mDNS discovery to locate the desktop hub automatically, or scan the pairing QR code displayed in the hub console.
  3. Tap Start Projection and confirm Android's native MediaProjection screen capture consent modal.
  4. Unlock Developer Options on your smartphone (Settings > About Phone > tap Build Number 7 times) and enable USB Debugging (or Wireless Debugging). If you prefer wireless operation, review our guide on pairing Android wireless debugging to an MCP server without USB.
  5. Verify ADB connectivity by running adb devices in your workstation terminal.

Step 5: Establish USB Reverse Port Forwarding

To ensure uninterrupted visual streaming even when testing network drops, airplane mode transitions, or Wi-Fi reconnects, configure USB reverse port forwarding:

adb reverse tcp:3000 tcp:3000

With reverse port forwarding active, MediaProjection video frames route directly through the physical USB cable to port 3000 on your workstation, guaranteeing continuous visual delivery regardless of local Wi-Fi fluctuations.

Practical QA Workflows: Automated Home Screen Widget Testing Scenarios

Practical widget QA workflows validate four primary lifecycle events: pinning widgets programmatically to the home screen launcher, verifying initial data rendering, dispatching interactive tap callbacks to update widget state, and testing multi-column responsive grid resizing. Claude Code automates these scenarios by combining launcher accessibility node traversal with real-time logcat triage.

Workflow 1: Programmatic Widget Pinning and Placement Verification

Android 8.0 (API 26) introduced AppWidgetManager.requestPinAppWidget, allowing applications to prompt the launcher to place a widget on the home screen without requiring the user to navigate the full launcher widget menu.

Developer Prompt:
"Claude, launch our app com.example.screensync, tap 'Add Task Widget to Home', and confirm the launcher pinning dialog. Then press the home button, inspect the launcher hierarchy via get_ui_hierarchy, and verify that our TaskGlanceWidget is present on the workspace with its default task title."

Step 1: Trigger Pinning Request in App

Claude Code launches the application using control_launch_app, locates the pinning button, and invokes control_tap.

Step 2: Confirm System Pinning Dialog

The system displays the launcher's confirmation dialog (com.android.launcher3 or com.google.android.apps.nexuslauncher). Claude Code calls get_ui_hierarchy to locate the confirmation button:

<node package="com.google.android.apps.nexuslauncher" class="android.widget.Button" text="Add to home screen" resource-id="android:id/button1" />

Claude Code calls control_tap on the button.

Step 3: Return to Home Screen and Assert Widget Placement

Claude Code sends control_key with key code 3 (KEYCODE_HOME). It calls get_ui_hierarchy and asserts that the widget view exists on the launcher grid:

<node class="com.android.launcher3.widget.LauncherAppWidgetHostView">
  <node text="Review Daily Pull Requests" resource-id="com.example.screensync:id/text_title" />
  <node text="3 pending" resource-id="com.example.screensync:id/text_count" />
</node>

It then calls get_latest_screenshot to confirm that the widget rendered without visual artifacts.

Workflow 2: Testing Interactive ActionCallback and Optimistic UI Updates

The primary functional test for an interactive widget is ensuring that tapping a button executes an ActionCallback, mutates Datastore preferences, and recomposes the widget without launching the main application activity.

Developer Prompt:
"Claude, on the home screen, tap the 'Mark Done' button on our TaskGlanceWidget. Assert that the button text updates to 'Completed', that the title changes to 'Done', and verify in logcat that ToggleTaskActionCallback executed without launching MainActivity."

Step 1: Tap Action Button on Launcher

Claude Code calls get_ui_hierarchy to locate the widget's action button bounds, then invokes control_tap directly on the button node.

Step 2: Assert Recomposition in Launcher Node Tree

Claude Code waits 250 milliseconds for the asynchronous Datastore write and widget update broadcast to process. It calls get_ui_hierarchy and asserts the new state:

<node class="android.widget.TextView" text="Done" resource-id="com.example.screensync:id/text_title" />
<node class="android.widget.Button" text="Completed" resource-id="com.example.screensync:id/btn_action" />

Step 3: Verify Background Execution via Logcat

Claude Code calls get_logcat with filter ToggleTaskActionCallback to confirm clean background execution:

I TaskGlanceWidget: ToggleTaskActionCallback.onAction: glanceId=12, completed=true
I AppWidgetManager: Updating widget id: 12 with new RemoteViews

It confirms that ActivityManager logged zero activity launch intents for MainActivity, ensuring the user remained undisturbed on the home screen.

Workflow 3: Validating Responsive Grid Resizing (2x2 to 4x2)

Jetpack Glance's SizeMode.Responsive allows widgets to provide distinct composable layouts depending on the grid dimensions allocated by the launcher. Testing these size transitions on real devices verifies that labels do not clip when the user resizes the widget.

Developer Prompt:
"Claude, test responsive layout switching for our widget on the home screen. Long-press the widget to reveal the resize handles, drag the right handle outward to expand the widget from 2x2 to 4x2 cells, tap the background to commit the resize, and assert that the layout switches from SmallWidgetLayout to LargeWidgetLayout showing the 'Sprint Tasks' header."

Step 1: Reveal Resize Handles via Long Press

Claude Code locates the widget node bounds ([64,320][540,800]) and invokes control_swipe with identical start and end coordinates over an 800ms duration to simulate a touch-and-hold gesture. The launcher enters edit mode and displays four circular resize handles.

Step 2: Drag Resize Handle

Claude Code identifies the right resize handle via get_ui_hierarchy at coordinates (540, 560) and invokes control_swipe from (540, 560) to (1020, 560) over 600ms, dragging the right edge across the launcher grid columns.

Step 3: Commit Resize and Assert New Layout

Claude Code calls control_tap on an empty launcher area (500, 1800) to exit edit mode. It immediately calls get_ui_hierarchy and verifies that the new composable layout rendered:

<node class="android.widget.TextView" text="Sprint Tasks" resource-id="com.example.screensync:id/text_header" />
<node class="android.widget.TextView" text="3 pending" resource-id="com.example.screensync:id/text_count" />

It invokes get_latest_screenshot to confirm that the multi-line layout displays all components cleanly without clipping text baselines.

Workflow 4: Triaging System Broadcast Updates and Force Refreshes via ADB

Testing how widgets recover when the system triggers an update broadcast ensures that background alarms and push sync events properly refresh widget contents.

Developer Prompt:
"Claude, execute an ADB broadcast to trigger a debug update on our Glance widget: adb shell am broadcast -a androidx.glance.appwidget.action.DEBUG_UPDATE -n com.example.screensync/.widgets.TaskGlanceWidgetReceiver. Verify in logcat that the receiver handled the intent, and check that the widget timestamp refreshed."

Claude Code executes the broadcast command through the terminal:

adb shell am broadcast -a androidx.glance.appwidget.action.DEBUG_UPDATE -n com.example.screensync/.widgets.TaskGlanceWidgetReceiver

Output:

Broadcasting: Intent { act=androidx.glance.appwidget.action.DEBUG_UPDATE cmp=com.example.screensync/.widgets.TaskGlanceWidgetReceiver }
Broadcast completed: result=0

Claude Code calls get_logcat to verify that onUpdate executed cleanly and calls get_ui_hierarchy to assert that the refreshed data matches expected telemetry. For comprehensive architectural testing across complete application lifecycles, review our autonomous mobile app testing Claude Code guide.

Common Widget Implementation Pitfalls and Logcat Diagnostics

Common widget implementation pitfalls include triggering TransactionTooLargeException crashes from oversized bitmap parcels, misconfiguring GlanceModifier.clickable action intents, and failing to handle launcher process death. Claude Code identifies these defects by streaming logcat telemetry, auditing Binder transaction allocations, and monitoring memory footprints during rapid widget state transitions.

1. The 1MB Binder Limit: TransactionTooLargeException

The most catastrophic defect in Android widget development occurs when developers attempt to pass uncompressed or high-resolution bitmaps inside a RemoteViews parcel. Because all RemoteViews travel through Android's central Binder transaction buffer—which is shared across all inter-process communications on the entire operating system and capped at 1 megabyte—passing large images throws a fatal TransactionTooLargeException:

E JavaBinder: !!! FAILED BINDER TRANSACTION !!!  (parcel size = 1428520)
E AppWidgetHostView: updateAppWidget failed
E AndroidRuntime: java.lang.RuntimeException: android.os.TransactionTooLargeException: data parcel size 1428520 bytes
    at android.os.BinderProxy.transactNative(Native Method)
    at android.app.IUiModeManager$Stub$Proxy.updateAppWidget(IUiModeManager.java:312)

When this occurs, the launcher removes the widget layout and renders a generic "Problem loading widget" error box.

Defensive Implementation: Always downscale bitmaps before passing them to Glance composables, or store large image files in internal storage and pass content URIs to ImageProvider:

// Defective: Passing full-resolution bitmap directly to Glance
Image(
    provider = ImageProvider(highResUserBitmap), // CRASH: Exceeds 1MB Binder buffer
    contentDescription = "Avatar"
)

// Resilient: Downscale to exact widget pixel bounds
val scaledBitmap = Bitmap.createScaledBitmap(highResUserBitmap, 96, 96, true)
Image(
    provider = ImageProvider(scaledBitmap),
    contentDescription = "Avatar"
)

Claude Code detects TransactionTooLargeException in get_logcat, identifies the oversized asset in the source code, and refactors the image pipeline to enforce strict dimension clamping.

2. Async Data Stalls in provideGlance()

Because provideGlance() is a suspend function, developers often execute long-running network requests directly inside the composition block. If the network request hangs or times out, the widget remains frozen in its previous state or fails to respond to tap actions.

Defensive Architecture: Decouple data fetching from widget composition. Perform network synchronization inside a scheduled CoroutineWorker managed by WorkManager, cache results in Room or Datastore, and let provideGlance() read strictly from local cache. When testing how applications recover from background process terminations, developers can reference our guide on testing Android process death with Claude Code and MCP.

3. Auditing Widget Allocations via dumpsys appwidget

To verify that widget instances do not leak memory or accumulate orphan providers after being deleted from the home screen, Claude Code queries the system AppWidget manager via ADB:

adb shell dumpsys appwidget

Output:

Registered Providers:
  com.example.screensync/.widgets.TaskGlanceWidgetReceiver:
    minWidth=120 minHeight=120 targetCellWidth=2 targetCellHeight=2
    updatePeriodMillis=1800000 resizeMode=3 initialLayout=0
    Active instances: [12]

Claude Code audits this output to verify that the registered widget IDs match active launcher instances, confirming that deleted widgets have their Datastore preferences purged cleanly.

Implementation Checklist for Automated Android Widget Testing

  • [ ] Ensure host development workstation runs Node.js 18+ with Android Platform Tools adb configured in system PATH.
  • [ ] Unpack the ScreenSync MCP desktop hub and execute start-hub.bat (Windows) or start-hub.sh (macOS/Linux).
  • [ ] Register ScreenSync MCP with the Claude Code CLI via claude mcp add screensync node "/path/to/screensync-hub.js".
  • [ ] Install the ScreenSync MCP client (version 2.5.7+) on physical Android 7.0+ hardware and grant MediaProjection consent.
  • [ ] Establish USB reverse port forwarding via adb reverse tcp:3000 tcp:3000 to safeguard video streaming.
  • [ ] Configure your Jetpack Glance widget with responsive breakpoints using SizeMode.Responsive and PreferencesGlanceStateDefinition.
  • [ ] Implement defensive bitmap downscaling to prevent TransactionTooLargeException Binder buffer overruns.
  • [ ] Automate home screen launcher assertions using get_ui_hierarchy, verifying node attributes and interactive callbacks without in-process test runner constraints.
FAQ

Questions, answered

Why can traditional Espresso test suites not test Android home screen widgets?

Espresso cannot test Android home screen widgets because widgets do not execute inside the application process. Widgets are serialized as RemoteViews parcels and rendered inside the system launcher process. Because Espresso's ViewInteraction is sandboxed to the active activity's window decor view, invoking Espresso on the home screen throws a fatal NoMatchingViewException.

How does Jetpack Glance differ from legacy RemoteViews widget development?

Jetpack Glance provides a modern declarative Kotlin DSL inspired by Jetpack Compose to construct Android App Widgets. Instead of directly manipulating fragile XML RemoteViews layouts, developers define composable functions that Glance translates into RemoteViews at runtime, managing state reactively via GlanceStateDefinition and handling click events via ActionCallback.

How does ScreenSync MCP inspect home screen widgets running inside external launchers?

ScreenSync MCP extracts the authoritative system accessibility hierarchy via its get_ui_hierarchy tool. Unlike in-process test runners, get_ui_hierarchy inspects whichever window is currently in the foreground, including OEM launchers like Pixel Launcher and Samsung One UI Home, returning the full node tree of widget buttons, texts, and containers.

Can Claude Code test interactive widget buttons without launching an activity?

Yes. Claude Code uses ScreenSync MCP's control_tap tool to dispatch native touch events directly onto widget action buttons. When a widget uses Glance's actionRunCallback, the click event executes a background ActionCallback or WorkManager worker without launching the main application activity, which Claude Code verifies via get_logcat.

How do developers automate widget placement on real Android devices?

Developers can automate widget placement programmatically using AppWidgetManager.requestPinAppWidget() starting in Android 8.0 (API 26), or use Claude Code to navigate the launcher's widget selection sheet. Once placed on the home screen, Claude Code queries the launcher hierarchy to confirm widget binding and initial layout rendering.

How do you trigger a force refresh of a Glance widget during automated test sweeps?

Developers can trigger an immediate Glance widget redraw via ADB using the broadcast command: adb shell am broadcast -a androidx.glance.appwidget.action.DEBUG_UPDATE -n <package>/.<GlanceReceiver>. Alternatively, triggering system updates via android.appwidget.action.APPWIDGET_UPDATE forces the system AppWidgetManager to refresh all active widget instances.

What causes TransactionTooLargeException in Android App Widgets and how is it tested?

TransactionTooLargeException occurs when the serialized RemoteViews parcel exceeds the Android Binder IPC transaction buffer limit of 1 megabyte, typically caused by passing uncompressed high-resolution bitmap assets. Claude Code tests this edge case by loading dense data sets into the widget and monitoring get_logcat for fatal Binder transaction errors.

How does ScreenSync MCP stream widget visuals without consuming ADB bandwidth?

ScreenSync MCP captures device frames through Android's native MediaProjection API, which streams high-fidelity visual frames over local Wi-Fi or USB reverse sockets at approximately 94 milliseconds median latency. Because display streaming bypasses ADB, the ADB socket remains dedicated to high-speed command execution and hierarchy extraction.

Written by

ScreenSync MCP team

The ScreenSync MCP team at EpsolDev builds the Android app, the desktop MCP hub and the browser extension that let AI agents see and control a real phone. These guides come out of that work.