scrcpy-gotchas — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited scrcpy-gotchas (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
scrcpy_tap, scrcpy_swipe, scrcpy_long_press, and scrcpy_scroll take device-native coordinates — the same space that scrcpy_ui_dump / scrcpy_ui_find_element report in their bounds and tapX/tapY, and the same space adb shell input tap uses. So the reliable workflow needs no scaling math:
// Find the element (returns native tapX/tapY), then tap them straight through
const el = scrcpy_ui_find_element({ text: "<label>" }) // → { tapX, tapY }
scrcpy_tap({ x: el.tapX, y: el.tapY })The input tools scale native → video-frame coordinates internally before sending the touch to the scrcpy server, so you never compute a scale factor yourself.
scrcpy_screenshot returns a downscaled image (e.g. 576×1024 at maxSize=1024), not the native frame. Tapping a pixel coordinate read off the screenshot lands in the wrong place. Always source coordinates from ui_dump / ui_find_element (native), never from the screenshot.
Why internal scaling is required: the scrcpy server's touch protocol is not resolution-independent — the touch message's screen size must exactly match the encoder's video-frame size, or the server silently drops the event. The tools handle this conversion; you just supply native coords.
The four search criteria have different matching behaviors:
| Criterion | Match type | Case sensitivity |
|---|---|---|
text | Partial substring (includes) | Case-insensitive |
contentDesc | Partial substring (includes) | Case-insensitive |
resourceId | Exact equality (===) | Case-sensitive |
className | Exact equality (===) | Case-sensitive |
All criteria are combined with AND logic — an element must satisfy every supplied criterion.
// text: case-insensitive substring — "bater" matches "Batería"
scrcpy_ui_find_element({ text: "bater" }) // ✓
// resourceId: exact match only — must be full string
scrcpy_ui_find_element({ resourceId: "android:id/title" }) // must match exactly
// className: full qualified name — "Button" won't match "android.widget.Button"
scrcpy_ui_find_element({ className: "android.widget.Button" }) // full name requiredtext and contentDesc matching lowercases both sides and uses String.includes(). However, Unicode normalization differences between the XML dump (from uiautomator) and your query string can cause mismatches. Searching for an accented word (e.g. "Batería") may return 0 even though the element displays that exact text, while a partial ASCII-only substring (e.g. "bater") will find it.
Rule of thumb: Search with lowercase ASCII-only substrings when possible. If that fails, use ui_dump and check the raw XML for the actual byte sequence.
For tappable cards — search results, feed rows, media thumbnails — the visible label is often rendered by a child view that the clickable wrapper ViewGroup does not expose as text. A text: query then returns 0 matches (or only matches an unrelated element like the search bar).
The label instead lives in the wrapper's `contentDesc`, which is usually richer (e.g. it bundles secondary metadata into one string) and easier to disambiguate between similar items:
// May return 0 — the result card has no `text`
scrcpy_ui_find_element({ text: "<title>" })
// Matches the clickable wrapper's contentDesc, which carries the label
scrcpy_ui_find_element({ contentDesc: "<title>" })
// → { contentDesc: "...", tapX, tapY, clickable: true }Rule of thumb: if text: finds nothing for something you can clearly see on screen, retry with contentDesc: (or fall back to scrcpy_ui_dump and grep the XML) before assuming it isn't there.
uiautomator dump wraps attribute values in double quotes (") by default. But when a value itself contains double quotes (common in X/Twitter content-desc fields), it switches to single quotes ('). The parseUiNodes() parser only handles double-quote delimiters:
HTML attr regex: content-desc="([^"]*)"
↑ only matches "..."So content-desc='tweet with "quoted" text' → contentDesc: "" (empty string — parser misses it entirely).
Impact: ui_find_element won't find tweets/items whose contentDesc contains double quotes. The element is still in the dump but its contentDesc is silently empty.
Workaround: for text that reliably exists, use text: queries instead. For X timeline tweets, search by partial lowercase ASCII snippets that avoid quoted substrings.
// This tweet: "a company just fired 90% of their dev team because claude code..."
// Won't match — contentDesc parsing fails due to embedded double quotes
scrcpy_ui_find_element({ contentDesc: "claude" }) // → 0 results
// But this promoted tweet has no embedded quotes → contentDesc parses fine
scrcpy_ui_find_element({ contentDesc: "runpod" }) // → foundADB's input text (used by scrcpy_input_text) crashes on non-ASCII characters like ¡, ¿, ñ, accented vowels (é, á, etc.), and !. The command throws a NullPointerException and silently fails.
Workaround: Use scrcpy_clipboard_set with paste: true instead — it handles any Unicode text:
// BAD — crashes with special characters
scrcpy_input_text({ text: "¡Hola, mamá!" })
// GOOD — clipboard handles all characters
scrcpy_clipboard_set({ text: "¡Hola, mamá!", paste: true })Critical: `paste: true` requires an active scrcpy session. Without one, the clipboard is set but the paste action is silently skipped (message: "Paste action not performed"). Always scrcpy_start_session before sending non-ASCII text.
!) causes partial text lossEven though scrcpy_input_text returns success, using ! in the string can cause partial text loss — leading characters may be dropped. For example, "Hello world!" may type as "llo world!" (the ! causes the ADB input text command to partially fail). The clipboard workaround above avoids this completely.
Start a scrcpy session proactively when doing multi-step interactions. It unlocks:
scrcpy_clipboard_set with paste: true)Without a session, clipboard paste silently degrades and every screenshot is a slow ADB call.
Input (tap, swipe) and screenshots continue to work even when the screen is turned off via scrcpy_screen_off. Screenshots taken while screen is off will show a black/dark frame. Use scrcpy_screen_on to wake the device when needed.
scrcpy_clipboard_get can fail even with an active scrcpy session, especially on Android 10 devices. If it returns an error, the clipboard content is still likely not critical — rely on direct state checks via ui_dump instead.
`scrcpy_scroll` does NOT work reliably on `RecyclerView` or `ScrollView` containers. Many Android apps (Settings, Chrome, launchers, messaging apps) use RecyclerView for scrollable lists. scrcpy_scroll may return success but produce no visible movement.
Use `scrcpy_swipe` instead for scrolling:
// BAD — may have no effect on RecyclerView
scrcpy_scroll({ x: 720, y: 1500, dx: 0, dy: 1 })
// GOOD — swipe gestures reliably scroll all scrollable views
scrcpy_swipe({ x1: 720, y1: 1800, x2: 720, y2: 800, duration: 500 })For a swipe-down (content scrolls up): y1 > y2. For a swipe-up (content scrolls down): y1 < y2. Duration 300-500ms is a good default for natural scrolling.
Watch for momentum side effects: Long swipes can carry momentum that collapses toolbars, scrolls beyond intended position, or triggers overscroll actions. For precise scrolling, use shorter swipe distances (e.g., 200-400px delta).
When sending messages in WhatsApp (and likely other messaging apps), scrcpy_tap on the send button can silently miss — the hit target is small and easy to land just outside. Prefer scrcpy_key_event with ENTER instead, which is more reliable.
These keycodes have been confirmed working via scrcpy_key_event:
| Keycode | Effect |
|---|---|
HOME (3) | Go to home screen |
BACK (4) | Navigate back |
ENTER (66) | Submit/send in input fields |
APP_SWITCH (187) | Open recent apps |
VOLUME_UP (24) | Increase volume |
VOLUME_DOWN (25) | Decrease volume |
MENU (82) | Open menu (context-dependent) |
Use scrcpy_key_event with the string name (e.g., "HOME") or numeric keycode.
scrcpy_app_start brings an app to the foreground but reuses its existing task, preserving scroll position, UI state, and back stack. This is efficient for quick app switching but can be surprising:
// Opens Settings but preserves scroll position from previous session
scrcpy_app_start({ packageName: "com.android.settings" })To force a clean launch, prefix the package name with +:
scrcpy_app_start({ packageName: "+com.android.settings" })This force-stops the app before launching, clearing its state.
Use only scrcpy_* tools when controlling an Android device. Do not reach for browser tools (browser_*, chrome-devtools_*) in an Android context. Common mistake: using browser_wait instead of just proceeding to the next action.
Visible text labels (in `text`, `contentDesc`, `hint`) are locale-dependent. What reads "Chats" in English is "Chats" in Spanish, "Discussions" in French, etc. Hardcoding text strings makes the skill fragile across devices.
Always prefer locale-independent selectors first:
| Selector | Locale-safe? | Example |
|---|---|---|
resourceId | Yes | com.whatsapp:id/entry |
className | Yes | android.widget.Switch |
text | No | "Mensaje" (placeholder, varies) |
contentDesc | No | "Nuevo chat" (varies) |
Workflow for text-based targeting:
scrcpy_ui_dump to discover the actual label on the deviceresourceId + className when possibleIn this document, example labels are shown as generic patterns like <app-name> or with English/Spanish examples marked (locale-dependent). Treat them as illustrations of the type of text to expect, not exact strings to copy.
com.twitter.androidandroid:id/list) inside nested ViewPager + ScrollView. Scroll with scrcpy_swipe.text field is always empty. The contentDesc bundles author, handle, tweet body, engagement stats, and timestamp into one long string.BACK to return to X.android.widget.FrameLayout elements with contentDesc labels (locale-dependent). Active tab has selected="true". Discover actual labels via ui_dump.contentDesc on the parent LinearLayout; active tab has selected="true".BACK restores them.resourceId="com.twitter.android:id/banner_text". Tapping it scrolls to top.HorizontalScrollView sub-tab bar (tab_layout). Tab labels are locale-dependent; discover via ui_dump. Trending topic titles use text (not contentDesc).resourceId="com.twitter.android:id/tweet_ad_badge_top_right" with a short text label (locale-dependent, e.g. "Ad" / "Anuncio").com.android.chromescrcpy_swipe on the web content area (below the toolbar, y > 280).com.android.chrome:id/url_bar. Tap it, paste a URL via clipboard_set, and press ENTER to navigate.bounds="[0,0][0,0]" or zero-dimension bounds. Elements scrolled past the top show bounds="[x,top][x,top]" (height=0). Filter these out: only tap elements with positive height.BACK to dismiss them.ui_find_element with targeted criteria is far more efficient than parsing the full ui_dump response.com.android.settingsBACK returns one level.android.widget.Switch with checkable="true", checked="true"|"false". The text shows on/off state (locale-dependent, e.g. "On"/"Off"). The parent row also has checkable="true".contentDesc on the clickable row: <ssid>,<status>,<signal>,<security>. The title and summary are separate text fields.ProgressBar with resource ID com.android.settings:id/progress_bar_animation and a scanning message (locale-dependent).com.android.settings:id/settings_button_no_background.scrcpy_screen_record_start with duration (seconds) auto-stops after that duration. The video is written to the specified remotePath on the device.scrcpy_screen_record_stop reports "No recording in progress" — this is expected. The file is already saved.scrcpy_file_pull with pullToHost: true to retrieve the recording if you still need it.duration parameter, recording continues indefinitely until screen_record_stop is called.// Start a 10-second recording
scrcpy_screen_record_start({ duration: 10, remotePath: "/sdcard/demo.mp4" })
// Pull it after it auto-stops
scrcpy_file_pull({ remotePath: "/sdcard/demo.mp4", localPath: "/tmp/demo.mp4" })scrcpy_file_push copies a file from the host to the device. Both paths must be absolute.scrcpy_file_pull copies from the device to the host. Both paths must be absolute.scrcpy_file_list verifies file presence and shows metadata: permissions, owner, group, size, date, isDirectory.scrcpy_file_push({ localPath: "/tmp/data.txt", remotePath: "/sdcard/data.txt" })
scrcpy_file_list({ path: "/sdcard/" }) // verify it's there
scrcpy_file_pull({ remotePath: "/sdcard/data.txt", localPath: "/tmp/copy.txt" })scrcpy_rotate_device toggles between portrait and landscape. There's no separate portrait/landscape command — each call flips orientation.scrcpy_ui_dump shows rotation="1" (landscape) or rotation="0" (portrait) on the root <hierarchy> node.[0,0][1440,2560] → landscape [0,0][2392,1440]. Height is reduced by status bar area.tapX/tapY from a landscape dump are valid for taps in landscape — no manual coordinate transformation needed.scrcpy_shell_exec runs arbitrary shell commands on the device via adb shell.&& or ;.getprop, settings get, dumpsys, pm list, wm, df, top, ps.// Device properties
scrcpy_shell_exec({ command: "getprop ro.build.version.release && getprop ro.product.model" })
// → "10\nNokia 8 Sirocco"
// Screen info
scrcpy_shell_exec({ command: "wm size && wm density" })
// → "Physical size: 1440x2560\nPhysical density: 560"
// Battery details
scrcpy_shell_exec({ command: "dumpsys battery | grep level" })
// → " level: 100"
// Installed third-party packages
scrcpy_shell_exec({ command: "pm list packages -3" })
// → "package:com.whatsapp\npackage:com.twitter.android\n..."scrcpy_start_video_stream starts an HTTP MJPEG stream at http://127.0.0.1:7183 and opens an ffplay viewer window on the host.scrcpy_stop_video_stream to clean up.maxSize parameter (default 1024). Use maxFps to cap frame rate (default 30).scrcpy_start_video_stream() // opens ffplay window at http://127.0.0.1:7183
// Optional: custom port and resolution
scrcpy_start_video_stream({ port: 8080, maxSize: 720, maxFps: 15 })
scrcpy_stop_video_stream() // close the stream and ffplay windowandroid.widget.ScrollView with com.android.launcher3:id/workspace. Horizontal swipes navigate between home screen pages.EditText at com.android.launcher3:id/fallback_search_view with a placeholder hint (locale-dependent)androidx.recyclerview.widget.RecyclerView (com.android.launcher3:id/apps_list_view) with app icons as android.widget.TextView elementscom.android.launcher3:id/prediction_row) showing 5 suggested apps at the toptext and contentDesc set to the same app name. Off-screen icons show bounds="[0,0][0,0]".LauncherAppWidgetHostView nodes with descriptive contentDesc (e.g. "At a Glance" / "De un vistazo", "Digital Clock" / "Reloj digital").scrcpy_expand_settings opens the Quick Settings panel (requires active scrcpy session). scrcpy_collapse_panels closes it.checkable="true" and checked="true"/"false". Use checked to determine state; text shows locale-dependent on/off labels.contentDesc. Example: WiFi tile → "Wi-Fi,<signal-strength>,<ssid>".android.widget.LinearLayout with clickable="true" instead of Switch.androidx.viewpager.widget.ViewPager (quick_settings_panel). A page indicator shows the current page (contentDesc like "Page 1 of 2"). Swipe horizontally on the ViewPager to change pages.android.widget.SeekBar at com.android.systemui:id/slider.android:id/edit), user switch, settings gear (opens Settings app).contentDesc (e.g., "Flashlight" for English, "Linterna" for Spanish — discover via ui_dump first) and tap its center coordinates.// Toggle the flashlight tile (find by contentDesc substring)
const tile = scrcpy_ui_find_element({ contentDesc: "flashlight" }) // try English first
if (!tile.count) tile = scrcpy_ui_find_element({ contentDesc: "linterna" }) // fall back
scrcpy_tap({ x: tile.elements[0].tapX, y: tile.elements[0].tapY })
// Verify: tile.text changed, checked flippedcom.android.systemui. Quick settings, notifications, and status bar are not part of regular app windows — they overlay the existing content.com.whatsapp+ to force-stop before launch: +com.whatsappandroid.widget.FrameLayout elements): chats, updates, communities, calls. Active tab has selected="true". Tab labels are locale-dependent — discover via ui_dump.android:id/list with com.whatsapp:id/contact_row_container rows. Contact names are in com.whatsapp:id/conversations_row_contact_name (text field).com.whatsapp:id/conversation_contact_name, last-seen at com.whatsapp:id/conversation_contact_status.com.whatsapp:id/entry (android.widget.EditText) — tap to focus, then use scrcpy_clipboard_set with paste: true (requires active scrcpy session) for any text with non-ASCII characters. See Non-ASCII text input above.scrcpy_key_event with ENTER instead of tapping the send button.~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.