Skip to content

Python Android Automation Without Root

You can automate Android from Python without rooting the device.

For many workflows, you do not need private app data, privileged system permissions, or a modified OS. You need to tap visible UI, type into fields, wait for screens, and capture enough evidence when something fails.

Handsets provides a small Python wrapper around a fast Android CLI so you can write those flows in normal Python.

Quick answer

Install the package, open a session, and act on visible labels:

from handsets import Session

with Session() as d:
    d.tap("Sign in", visible=True, unique=True)
    d.fill("Email", "you@example.com")
    d.fill("Password", "secret")
    d.tap("Continue", visible=True, unique=True)
    d.wait(text="Dashboard", timeout="15s")

That is Python Android automation without root: normal USB debugging, no Appium server, no coordinate guessing, and no rooted device image.

Install

Install the Python package:

pip install handsets

You also need adb on your path and USB debugging enabled on the Android device.

Then connect:

from handsets import Session

with Session() as d:
    d.tap("Continue")

The device does not need root. Handsets starts a small daemon as the Android shell user through adb.

Requirements

You need:

  • macOS or Linux on the host.
  • adb available on $PATH.
  • USB debugging enabled on the phone or emulator.
  • A device that appears in adb devices.
  • The handsets Python package.

You do not need root, Magisk, a custom ROM, or an installed helper app on the device.

Tap by visible text

The simplest useful action is a text lookup:

from handsets import Session

with Session() as d:
    d.tap("Sign in", visible=True, unique=True, timeout="5s")

visible=True avoids hidden nodes. unique=True fails if more than one node matches. That is safer than tapping whichever match happens to come first.

Fill a login form

import os
from handsets import Session

with Session() as d:
    d.tap("Sign in", visible=True, unique=True)
    d.fill("Email", os.environ["APP_EMAIL"])
    d.fill("Password", os.environ["APP_PASSWORD"])
    d.tap("Continue", visible=True, unique=True)
    d.wait(text="Dashboard", timeout="15s")

That is a complete no-root Android login flow.

No Appium server. No coordinate math. No rooted test image.

Use selectors when labels are repeated

Real screens often repeat labels.

Use selectors when plain text is too broad:

from handsets import Session

with Session() as d:
    d.fill('EditText:below(TextView[text=Email])', "you@example.com")
    d.fill('EditText:below(TextView[text=Password])', "secret")
    d.tap('Button:has-text("Continue")', visible=True, unique=True)

The selector syntax is CSS-like and inspired by Playwright.

Capture screenshots on failure

A practical automation script should leave artifacts behind.

from handsets import Session, Timeout

with Session() as d:
    try:
        d.tap("Continue", visible=True, unique=True)
        d.wait(text="Dashboard", timeout="15s")
    except Timeout:
        d.screenshot("/tmp/android-failure.jpg", size=768)
        raise

For many failures, a small screenshot plus the current UI dump is enough to debug the issue.

If you also want the current UI text, call the CLI from your failure handler or use the JSON mode in a subprocess:

import subprocess

subprocess.run(["hs", "ui"], check=False)
subprocess.run(["hs", "see", "--size", "768", "/tmp/android-failure.jpg"], check=False)

Batch tight loops

For tight loops, avoid starting a process for every command. Use a batch context:

from handsets import Session

labels = ["One", "Two", "Three", "Done"]

with Session() as d:
    with d.batch(timeout="5s", retries=2) as b:
        for label in labels:
            b.tap(label, visible=True)
        b.wait(text="Complete")

This keeps the command path warm and reduces overhead in repeated UI actions.

When to use Python instead of shell

Shell is great for linear flows:

hs tap "Sign in"
hs fill "Email" "you@example.com"
hs wait "Dashboard"

Python is better when you need:

  • Branching logic.
  • Structured retries.
  • Integration with APIs.
  • Test assertions.
  • Data-driven flows.
  • Better error handling.

The same device control surface works both ways.

Pytest smoke test example

For a simple release smoke test, wrap the session in a test:

import os
from handsets import Session


def test_login_smoke():
    with Session() as d:
        d.tap("Sign in", visible=True, unique=True, timeout="5s")
        d.fill("Email", os.environ["APP_EMAIL"])
        d.fill("Password", os.environ["APP_PASSWORD"])
        d.tap("Continue", visible=True, unique=True)
        d.wait(text="Dashboard", timeout="20s")

That test still behaves like a normal user flow. The phone is not rooted and the app is not granted special permissions.

Limitations

No-root Android automation still follows Android's security model.

Secure windows may block screenshots. App-private data remains private. Some protected settings require device-owner policy or root. That is expected.

For UI automation, though, the shell user can do a lot:

  • Tap.
  • Type.
  • Swipe.
  • Read visible UI.
  • Wait for text or activities.
  • Capture screenshots when allowed.

That covers most app smoke tests and agent workflows.

FAQ

Can Python control Android without root?

Yes. Python can control Android through adb and a tool like Handsets. Root is not required for normal UI actions such as tapping, typing, swiping, waiting for text, or taking screenshots when the app allows it.

Do I need Appium for Python Android automation?

No. Appium is useful for WebDriver-style mobile testing, but smaller Python scripts can use Handsets when the target is Android-only and the workflow is label-based.

Can this run on a real phone?

Yes. It works with real Android phones and emulators as long as adb devices can see the target.

Can I automate secure screens?

You can still interact with visible UI, but Android may block screenshots for windows that use FLAG_SECURE. That is an Android platform rule, not a Handsets-specific limitation.