레시피

앱을 처음 상태로 시작하기, 팝업 닫기부터 목록 읽기, 재시도, 인증번호, 새 IP 주소까지, 일상적인 작업을 Python, Node.js, 셸에서 바로 복사해 쓸 수 있는 패턴으로 정리했습니다.

번역된 페이지입니다. 영어 문서와 내용이 다르면 영어 문서가 기준입니다. English

레시피 하나가 흔한 작업 하나를 해결합니다. 언어를 한 번 고르면 페이지의 모든 예제가 그 언어로 바뀝니다. 예제는 연결된 폰이 d에 들어 있다고 가정합니다(Python에서는 d = connect(), Node.js에서는 const d = await connect()). 셸에서는 모든 droidline 명령이 알아서 폰을 찾습니다.

폰이 준비됐는지 확인하기

화면을 켜고, PIN으로 잠겨 있지 않은지 확인하세요. Droidline은 PIN 잠금을 풀 수 없습니다.

d.wake()
if d.locked():
    raise SystemExit("Unlock the phone, or turn off its screen lock, first")
if d.battery()["level"] < 15:
    print("Battery is low")
await d.wake();
if (await d.locked()) throw new Error("Unlock the phone, or turn off its screen lock, first");
if ((await d.battery()).level < 15) console.log("Battery is low");
droidline wake
if [ "$(droidline locked)" = "true" ]; then
  echo "Unlock the phone first" >&2; exit 1
fi

앱을 깨끗한 상태로 시작하기

launch는 앱을 마지막으로 머물던 화면 그대로 앞으로 가져옵니다. 첫 화면부터 시작하려면 먼저 앱을 멈추세요. 막 설치한 것처럼 시작하려면 데이터를 지우세요.

d.kill("com.example.shop")         # 강제 종료: 몇 초 걸림
d.launch("com.example.shop")

d.clear_data("com.example.shop")   # 로그인과 설정도 지워짐
d.launch("com.example.shop")
await d.kill("com.example.shop");        // 강제 종료: 몇 초 걸림
await d.launch("com.example.shop");

await d.clearData("com.example.shop");   // 로그인과 설정도 지워짐
await d.launch("com.example.shop");
droidline kill com.example.shop
droidline launch com.example.shop

kill과 clear_data는 안드로이드 설정의 버튼을 대신 눌러 주는 방식이라 몇 초가 걸리고, 잠깐 화면에 보입니다. 패키지명은 droidline apps로 볼 수 있습니다.

나올 수도 있는 팝업 닫기

광고, 평점 요청, 팁은 나왔다 안 나왔다 합니다. 각각 조건 확인으로 검사하세요. 조건 확인은 기다리지도 실패하지도 않습니다.

for text in ["Not now", "Close ad", "Skip", "Later"]:
    if d.exists("text", text):
        d.touch("text", text)
for (const text of ["Not now", "Close ad", "Skip", "Later"]) {
  if (await d.exists("text", text)) await d.touch("text", text);
}
for text in "Not now" "Close ad" "Skip" "Later"; do
  if [ "$(droidline exists text "$text")" = "true" ]; then
    droidline touch text "$text"
  fi
done

화면이 열리고 잠시 뒤에 팝업이 나타난다면, 다음 레시피처럼 which로 먼저 나타나는 쪽을 기다리세요.

결과가 여러 갈래인 단계 처리하기

탭한 뒤 앱이 다음 화면을 보여 줄 수도, 에러나 팝업을 보여 줄 수도 있습니다. which는 여러 후보 중 가장 먼저 나타나는 것을 기다리고, 어느 것이 나타났는지 알려 줍니다.

d.touch("text", "Pay")
result = d.which([("text", "Payment complete"),
                  ("textContains", "declined"),
                  ("text", "Confirm")], timeout=30)
if result == 2:
    d.touch("text", "Confirm")
elif result != 0:
    d.screenshot("payment-problem.png")
await d.touch("text", "Pay");
const result = await d.which([["text", "Payment complete"],
                              ["textContains", "declined"],
                              ["text", "Confirm"]], { timeout: 30 });
if (result === 2) await d.touch("text", "Confirm");
else if (result !== 0) await d.screenshot("payment-problem.png");
droidline touch text Pay
result=$(droidline which text="Payment complete" textContains=declined text=Confirm --timeout 30)
case "$result" in
  2) droidline touch text Confirm ;;
  0) ;;
  *) droidline screenshot payment-problem.png ;;
esac

작업이 끝나기를 기다리기

스피너나 "Loading" 레이블이 사라지기를, 또는 결과가 나타나기를 기다리세요.

d.wait_gone("id", "progress", 60)
d.wait("textContains", "Upload complete", 120)
await d.waitGone("id", "progress", 60);
await d.wait("textContains", "Upload complete", 120);
droidline wait_gone id progress 60
droidline wait textContains "Upload complete" 120

텍스트 입력과 키 누르기

input은 입력란을 채우며, 원래 있던 내용을 바꿔 씁니다. append=True를 넘기면 뒤에 덧붙입니다. sendkey는 키를 누릅니다. 예를 들어 Enter를 눌러 검색을 실행할 수 있습니다.

d.input("id", "search", "android automation")
d.sendkey("enter")

d.input("id", "note", " and more", append=True)
d.clear("id", "note")
await d.input("id", "search", "android automation");
await d.sendkey("enter");

await d.input("id", "note", " and more", { append: true });
await d.clear("id", "note");
droidline input id search "android automation"
droidline sendkey enter

한국어, 중국어, 이모지를 포함해 어떤 언어와 문자든 입력할 수 있습니다. 키 이름에는 enter, tab, del, space, escape, back, home 등이 있습니다.

목록의 모든 항목 읽기

dump는 트리 전체를 돌려주므로, 평범한 코드 몇 줄로 값을 모을 수 있습니다.

def texts(node):
    if node.get("text"):
        yield node["text"]
    for child in node.get("children", []):
        yield from texts(child)

screen = d.dump()
print(list(texts(screen["tree"])))
function* texts(node) {
  if (node.text) yield node.text;
  for (const child of node.children ?? []) yield* texts(child);
}

const screen = await d.dump();
console.log([...texts(screen.tree)]);
droidline dump | jq -r '.. | .text? // empty | select(. != "")'

긴 목록이라면 모으고, 위로 스와이프하고, 다시 모으기를 반복하다가 새 항목이 나오지 않으면 멈추세요.

seen = []
while True:
    new = [t for t in texts(d.dump()["tree"]) if t not in seen]
    if not new:
        break
    seen += new
    d.swipe("up")
print(seen)
const seen = [];
for (;;) {
  const fresh = [...texts((await d.dump()).tree)].filter((t) => !seen.includes(t));
  if (fresh.length === 0) break;
  seen.push(...fresh);
  await d.swipe("up");
}
console.log(seen);

실패했을 때 스크린샷 남기기

실행 전체를 감싸서, 어떤 실패든 스크린샷과 화면 덤프를 남기게 하세요.

from datetime import datetime
from droidline import DroidlineError

try:
    run_my_steps(d)
except DroidlineError:
    stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
    d.screenshot(f"fail-{stamp}.png")
    d.dump(f"fail-{stamp}.json")
    raise
try {
  await runMySteps(d);
} catch (e) {
  const stamp = new Date().toISOString().replace(/[:.]/g, "-");
  await d.screenshot(`fail-${stamp}.png`);
  await d.dump(`fail-${stamp}.json`);
  throw e;
}
trap 'droidline screenshot "fail-$(date +%s).png"' ERR

가끔 실패하는 단계 재시도하기

에러는 재시도가 도움이 될지를 알려 줍니다. 그런 에러만 재시도하세요.

import time
from droidline import DroidlineError

for attempt in range(3):
    try:
        d.touch("text", "Refresh")
        break
    except DroidlineError as e:
        if not e.retryable or attempt == 2:
            raise
        time.sleep(2)
import { DroidlineError } from "droidline";

for (let attempt = 0; ; attempt++) {
  try {
    await d.touch("text", "Refresh");
    break;
  } catch (e) {
    if (!(e instanceof DroidlineError) || !e.retryable || attempt === 2) throw e;
    await new Promise((r) => setTimeout(r, 2000));
  }
}
for attempt in 1 2 3; do
  droidline touch text Refresh && break
  sleep 2
done

웹 페이지나 딥 링크 열기

d.chrome.go("droidline.dev")                 # Chrome의 현재 탭에서
d.open_url("https://droidline.dev/docs/")    # 기본 브라우저에서
d.open_url("myapp://orders/42")              # 딥 링크를 지원하는 앱의 해당 화면으로 바로 이동
await d.chrome.go("droidline.dev");
await d.openUrl("https://droidline.dev/docs/");
await d.openUrl("myapp://orders/42");
droidline chrome.go droidline.dev
droidline open_url https://droidline.dev/docs/

launch로 바로 열 수 없는 화면에 가는 방법은 딥 링크뿐인 경우가 많습니다.

알림으로 오는 인증번호 기다리기

먼저 해당 앱의 알림이 PC로 오도록 허용합니다(notify_filter로 하거나 앱의 상태 탭에서 한 번만 하면 됩니다). 그런 다음 메시지를 기다렸다가 숫자를 뽑아냅니다.

import re

d.notify_filter(["com.google.android.apps.messaging"])
n = d.wait_notification("textContains", "code", 120)
code = re.search(r"\d{4,8}", n["text"]).group()
d.input("id", "otp", code)
await d.notifyFilter(["com.google.android.apps.messaging"]);
const n = await d.waitNotification("textContains", "code", 120);
const code = n.text.match(/\d{4,8}/)[0];
await d.input("id", "otp", code);
code=$(droidline --json wait_notification textContains code 120 | jq -r '.value.text' | grep -oE '[0-9]{4,8}' | head -1)
droidline input id otp "$code"

클립보드로 복사하고 붙여 넣기

d.clipboard("text to paste")
d.long_touch("id", "message")      # 입력란 메뉴에 Paste가 나옴
d.touch("text", "Paste")

print(d.clipboard())               # 다시 읽기
await d.clipboard("text to paste");
await d.longTouch("id", "message");
await d.touch("text", "Paste");

console.log(await d.clipboard());
droidline clipboard "text to paste"
droidline clipboard

Android 10 이상에서 클립보드를 읽으려면 Droidline 키보드가 현재 키보드여야 합니다.

새 모바일 IP 주소 받기

비행기 모드를 켰다 끄면 통신사가 새 주소를 줍니다. 그 사이에 폰의 연결이 끊기므로, 폰이 혼자 끝까지 실행하는 batch 하나로 실행하세요.

d.batch([("airplane", True), ("sleep", 3000), ("airplane", False)], wait=True)
print(d.network())                 # {'type': 'mobile', 'airplane': False, 'metered': True}
await d.batch([["airplane", true], ["sleep", 3000], ["airplane", false]], { wait: true });
console.log(await d.network());
droidline batch '[["airplane",true],["sleep",3000],["airplane",false]]' --wait

비행기 모드가 꺼지면 폰은 알아서 PC에 다시 접속합니다. 이런 명령이 결과를 알리는 방식은 개념에서 설명합니다.

모든 폰에서 같은 단계 실행하기

from concurrent.futures import ThreadPoolExecutor
from droidline import Droidline

dl = Droidline()
names = [p["name"] for p in dl.devices() if p["online"]]

def job(name):
    d = dl.device(name)
    d.launch("com.android.chrome")
    return name, d.current()["package"]

with ThreadPoolExecutor(len(names) or 1) as pool:
    print(list(pool.map(job, names)))
import { Droidline } from "droidline";

const dl = new Droidline();
const online = (await dl.devices()).filter((p) => p.online);
console.log(await Promise.all(online.map(async (p) => {
  const d = dl.device(p.name);
  await d.launch("com.android.chrome");
  return [p.name, (await d.current()).package];
})));
for name in $(droidline --json devices | jq -r '.value[] | select(.online) | .name'); do
  droidline -d "$name" launch com.android.chrome &
done
wait

여러 폰에서 더 자세히 다룹니다. 이름 붙이기, 폰별 프록시, 선반 가득한 폰을 문제없이 유지하는 법이 나옵니다.