Limrun Android Emulator
Interact with an app running on a Limrun cloud Android emulator, from any
environment (Linux, Windows, macOS, VM, container). This skill is
build-agnostic: it assumes the APK was already built, usually by
limrun-gradle. Keep build concerns in that skill; this one is about
driving the running emulator.
Never use a local emulator, a local Android SDK, or Android Studio.
Auth and CLI
Install if needed:
. Auth is
or
(it may be set outside the project, so don't ask for it just
because it's missing from
or the shell). The CLI is the source of truth:
the commands in this skill are verified, but if a flag errors or you need one
not shown here, check
lim android <subcommand> --help
instead of guessing.
Installing an app
You can build with Limrun's remote Gradle service and have the APK installed
on a fresh emulator automatically, or install a pre-built local APK or a URL.
One default to know first:
opens an ADB tunnel
(
) and a browser tab with the live stream (
) unless told
otherwise. As an agent, pass
always, and
unless you
need adb right away.
Build and install
Upload the APK built by limrun-gradle as a named asset, then create an
emulator with it pre-installed:
bash
lim gradle build . --upload myapp.apk
lim android create --install-asset myapp.apk --no-open --no-connect
Create blocks until the instance is ready to drive; no boot wait is needed.
The create output includes a signed stream URL; share it with the user as a
Markdown link, like Live emulator. If you have a
browser the user can see, open the URL there and tell them. Create also
prints a console URL: it opens the same live view but requires a console
login, so prefer the signed stream URL for sharing.
Useful create flags:
(reuse a running instance with the
same labels),
(delete when the CLI exits),
(where the instance runs; don't use
, it is deprecated),
/
,
, and
(find labeled instances later with
lim android list --label-selector k=v
).
Install app from local
Create a new emulator, then install a local file or a URL:
bash
lim android create --no-open --no-connect
lim android install-app ./app-debug.apk
lim android install-app https://example.com/app.apk
A local path is uploaded to Limrun Asset Storage first; a URL is fetched by
the instance itself, which is much faster for large APKs than uploading from
your machine.
returns as soon as the app is sent; the install
finishes in the background within seconds. Newly installed apps land in the
app drawer, not the home screen, so don't look for their icon; just launch
the app with
lim android launch-app <package> --detach
(without
it blocks watching the app until it exits), or confirm over adb with
pm list packages | grep <name>
.
Every time you need to install a new version of the APK, sync it instead of
reinstalling:
bash
lim android sync ./app-debug.apk
It sends only a delta against the APK already on the instance, then
reinstalls.
keeps re-syncing on file changes, and
--launch-mode ForegroundIfRunning|RelaunchIfRunning
controls what happens to
the running app after each install.
Targeting the right instance
Most
commands default to the last created instance and resolve
the "current" one from the
git repo / worktree of your cwd. In a different
directory (or outside any git repo) a command can report that no recent
instance was found even though one is running. The reliable recipe:
bash
lim android list # shows all instances and their IDs
lim android element-tree --id <that-id> # pass --id to EVERY lim android command
Once you have the ID (format
), pass
--id <android-instance-id>
to all
calls for the rest of the
session. Alternatively,
the project so the workspace resolves on
its own. When controlling multiple instances, always pass
.
Launching the app
Launch and stop installed apps by package name:
bash
lim android launch-app com.example.app --detach # launch and return
lim android launch-app com.example.app # launch and watch until it exits
lim android launch-app com.example.app --mode RelaunchIfRunning # restart for a clean state
lim android terminate-app com.example.app # stop it, e.g. to reset app state
Without
,
blocks watching the app: when it crashes,
ANRs, or is stopped, the command prints the exit reason, crash details with
the stack trace, and a recent app log tail, then returns. That report is the
way to see why an app died without adb; logs while the app is running still
need adb (below). There is no
; discover package names over adb
with
, or take the application ID from the build.
Logs, files, and shell over adb
Logcat, file transfer, and arbitrary shell go through plain
over the
CLI's tunnel. Start the tunnel in a background shell and keep it alive:
bash
lim android connect # prints "Tunnel started on 127.0.0.1:<port>."
runs
for you (use
if adb isn't on PATH).
The printed
is the device serial; pass it with
to
every adb call (
also lists it):
bash
SERIAL=127.0.0.1:<port>
adb -s $SERIAL logcat -d | tail -100 # dump recent logs, don't stream into context
adb -s $SERIAL logcat -d --pid=$(adb -s $SERIAL shell pidof -s com.example.app) | tail -50 # only the app's logs (app must be running)
adb -s $SERIAL shell pm list packages | grep example # package name discovery
adb -s $SERIAL push ./fixture.json /sdcard/Download/
adb -s $SERIAL pull /sdcard/Download/out.json ./
The tunnel lives and dies with the process that started it: when that shell
exits,
shows the serial as
while the instance keeps
running. Just run
again to get a new tunnel (the port
changes each time). Stale offline serials are harmless;
clears them.
Testing changes
When emulator interaction is part of the task, test new or changed
functionality with the interaction commands after each install or sync. Focus
on what changed, plus a quick smoke test of core flows. Start by reading the
element tree to see what's on screen before acting:
The output is the raw UIAutomator XML hierarchy on a single line, so a plain
grep echoes the whole document. Split it into one node per line first, then
grep for the
,
,
, or
you need
rather than dumping the whole tree into context:
bash
lim android element-tree | sed 's/></>\n</g' | grep -i "save"
Interacting with the app
Prefer tapping by resource id, then by visible text or content description,
then coordinates as a last resort:
bash
lim android tap-element --resource-id com.example.app:id/startButton
lim android tap-element --text "Save"
lim android tap-element --content-desc "Open menu"
lim android tap 360 800
Selector values match
exactly, not by substring:
does not
match a "Save draft" button. Copy the value verbatim from
. A
selector that matches nothing fails in a couple of seconds with
No element found for selector
. Other selectors:
,
,
,
,
,
, and
; combine them to narrow a match. On web pages in the
browser, resource ids are the page's own DOM ids (like
) and are
often empty; select by
plus
there, or fall back to
coordinates from the node's
. To inspect matches without tapping, use
with the same selectors:
bash
lim android find-element --text "Save" # table of matches with bounds
For text input, target the field directly; no prior focus tap is needed.
takes the same selectors as
(
--class-name android.widget.EditText --focused
works for a field with no resource id):
bash
lim android type "hello world" --resource-id com.example.app:id/searchBox
lim android type "hello" --x 360 --y 400 # by coordinate
lim android press-key enter
lim android press-key backspace # --modifier shift/control/alt/command to combine
For scrolling and navigation:
bash
lim android scroll down --amount 600
lim android scroll up --amount 300
lim android press-key back
lim android press-key home
lim android open-url "https://example.com" # opens in the default browser; also fires deep links
After every interaction, re-run
to confirm the UI transitioned.
No sleep is needed between a tap and
; the tap blocks until
done. A page load after
is asynchronous though: re-run
until the node you expect appears. A present but childless
means the page is still loading, not a broken tree.
When the element tree is empty
Some React Native and Expo apps expose no accessibility nodes at all, which
leaves
,
, and
blind (system dialogs
still expose nodes). Fall back to driving by pixels: take a screenshot, read
the coordinates of the target, and use
/
. Screenshot
pixels map 1:1 to tap coordinates, so a button centered at (360, 1322) in the
image is tapped with
. Re-screenshot after each
action to confirm the result.
Screenshots and video
Screenshot takes a
positional path (not
):
bash
lim android screenshot screenshot.png
lim android screenshot screenshot.png --id <android-instance-id>
Use the element tree for functional assertions (element existence, text, state
changes) and screenshots only for visual properties. For anything involving
motion (animations, gameplay, streaming UI), prefer video:
bash
lim android record start # non-blocking
lim android record stop -o /tmp/recording.mp4
accepts
. Recorded frames are half the
screenshot resolution, so read tap coordinates from screenshots, never from
video frames. For UI changes, include a demo video in the pull request so the
user can see it.
Simulate the microphone with an audio file
For voice-driven flows (assistants, speech-to-text, audio calls), play a local
audio file as the emulator's microphone. The app hears the audio through its
normal capture pipeline:
bash
lim android play-on-microphone ./fixtures/command.wav # loops by default
lim android play-on-microphone ./fixtures/command.mp3 --once
WAV and MP3 work. The file is pushed over adb, so this command needs a local
binary (
if it's not on PATH) and opens its own short-lived
tunnel. Camera injection is iOS-only; for camera-driven test flows use
limrun-ios-simulator.
Shape network bandwidth
Test slow-network behavior by capping the instance's Wi-Fi bandwidth:
bash
lim android set-wifi-bandwidth --down-kbps 1000 --up-kbps 500
lim android set-wifi-bandwidth --down-kbps 0 --up-kbps 0 # 0 clears the limit
Preview URL for humans
Upload the APK to Limrun Asset Storage and return a preview URL for the user
to open and test the app manually in the browser:
bash
export ASSET_NAME=myapp.apk # can be any name
lim asset push ./app-debug.apk -n ${ASSET_NAME}
echo "https://console.limrun.com/preview?asset=${ASSET_NAME}&platform=android"
Opening the link in the Limrun console provisions an emulator with the APK
pre-installed.
Cleanup
When the work is completed, you can delete the emulator.
takes a
positional ID (
is not a valid flag here, unlike other commands):
bash
lim android delete <android-instance-id>
Gotchas
- The fleet is x86_64. Emulators report ABIs and run
arm64 code through translation, but an APK whose native libraries are
arm64-only for some vendor SDKs installs fine and then crashes with
when that code first loads. Build with x86_64 native
libs included.
- Selectors match exactly. and
need the full, exact string from ; substrings match nothing.
- returns before the install finishes. The app lands a few
seconds later; verify with or before
launching. Prefer or over raw
: a big APK over streams with zero progress
output and looks hung for minutes, and killing it mid-stream corrupts the
install.
- The ADB tunnel is session-bound. It dies with the shell that started it
while the instance keeps running; reconnect with and
re-read the port, it changes every time.
- Failed create pipes can still leak an instance. If a invocation
errors client-side (broken pipe, JSON parse), check ; the
instance may exist anyway and should be deleted.
- Empty element tree usually means a React Native app, not a broken
instance. See "When the element tree is empty" above.
- can be large. Pipe through to extract what you
need rather than dumping the whole tree into context.
- Instance resolution can miss in a non-git dir. See "Targeting the right
instance" above; pass when in doubt.
- Build errors are the build skill's job. If the APK isn't building, the
failure is upstream; go back to limrun-gradle.