autool/DroidBot/platforms/android/adapters/droidbot_app.py
2026-06-17 19:44:18 +08:00

271 lines
10 KiB
Python

import logging
import socket
import subprocess
import time
import json
import struct
import traceback
DROIDBOT_APP_REMOTE_ADDR = "tcp:7336"
DROIDBOT_APP_PACKAGE = "io.github.ylimit.droidbotapp"
DROIDBOT_APP_PACKET_HEAD_LEN = 6
ACCESSIBILITY_SERVICE = DROIDBOT_APP_PACKAGE + "/io.github.privacystreams.accessibility.PSAccessibilityService"
MAX_NUM_GET_VIEWS = 5
GET_VIEW_WAIT_TIME = 1
from ....exceptions import ADBException
class EOF(Exception):
"""
Exception in telnet connection
"""
pass
class DroidBotAppConn(object):
"""
a connection with droidbot app.
"""
def __init__(self, device=None):
"""
initiate a droidbot app connection
:param device: instance of Device
:return:
"""
self.logger = logging.getLogger(self.__class__.__name__)
self.host = "localhost"
if device is None:
from DroidBot.platforms.android import AndroidDevice as Device
device = Device()
self.device = device
self.port = self.device.get_random_port()
self.connected = False
self.__can_wait = True
self.sock = None
self.last_acc_event = None
self.enable_accessibility_hard = device.enable_accessibility_hard
self.ignore_ad = device.ignore_ad
if self.ignore_ad:
import re
self.__first_cap_re = re.compile("(.)([A-Z][a-z]+)")
self.__all_cap_re = re.compile("([a-z0-9])([A-Z])")
def __id_convert(self, name):
name = name.replace(".", "_").replace(":", "_").replace("/", "_")
s1 = self.__first_cap_re.sub(r"\1_\2", name)
return self.__all_cap_re.sub(r"\1_\2", s1).lower()
def set_up(self):
device = self.device
if DROIDBOT_APP_PACKAGE in device.adb.get_installed_apps():
self.logger.debug("DroidBot app was already installed.")
else:
# install droidbot app
import pkg_resources
droidbot_app_path = pkg_resources.resource_filename("DroidBot", "resources/droidbotApp.apk")
install_cmd = ["install", droidbot_app_path]
self.device.adb.run_cmd(install_cmd)
self.logger.debug("DroidBot app installed.")
device.adb.enable_accessibility_service(ACCESSIBILITY_SERVICE)
time.sleep(1)
if ACCESSIBILITY_SERVICE not in device.get_service_names() \
and self.device.get_sdk_version() < 23 and self.enable_accessibility_hard:
device.adb.enable_accessibility_service_db(ACCESSIBILITY_SERVICE)
if ACCESSIBILITY_SERVICE not in device.get_service_names():
raise ADBException("无障碍服务启用失败")
# device.start_app(droidbot_app)
if ACCESSIBILITY_SERVICE not in device.get_service_names() and self.__can_wait:
raise ADBException("无障碍服务启用失败")
def tear_down(self):
# Note: uninstall_app now works on the device's configured app,
# not arbitrary packages. Use adb directly for DroidBot app.
self.device.adb.run_cmd(["uninstall", DROIDBOT_APP_PACKAGE])
def connect(self):
self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
# forward host port to remote port
serial_cmd = "" if self.device is None else "-s " + self.device.serial
forward_cmd = "adb %s forward tcp:%d %s" % (serial_cmd, self.port, DROIDBOT_APP_REMOTE_ADDR)
subprocess.check_call(forward_cmd.split())
self.sock.connect((self.host, self.port))
import threading
listen_thread = threading.Thread(target=self.listen_messages)
listen_thread.start()
except subprocess.CalledProcessError as e:
self.connected = False
error_msg = f"ADB forward failed: device '{self.device.serial}' not found or disconnected"
self.logger.error(error_msg)
raise ADBException(error_msg)
except socket.error:
self.connected = False
traceback.print_exc()
raise ADBException("Failed to connect to DroidBot app")
def sock_read(self, rest_len):
buf = None
while rest_len:
pkt = self.sock.recv(rest_len)
if not pkt:
raise EOF()
if not buf:
buf = pkt
else:
buf += pkt
rest_len -= len(pkt)
return buf
def read_head(self):
header = self.sock_read(DROIDBOT_APP_PACKET_HEAD_LEN)
data = struct.unpack(">BBI", header)
return data
def listen_messages(self):
self.logger.debug("start listening messages")
self.connected = True
reconnect_attempts = 0
max_reconnect_attempts = 3
try:
while self.connected:
_, _, message_len = self.read_head()
message = self.sock_read(message_len)
if not isinstance(message, str):
message = message.decode()
self.handle_message(message)
reconnect_attempts = 0 # 成功接收消息后重置重连计数
print("[CONNECTION] %s is disconnected" % self.__class__.__name__)
except ADBException:
# 设备断开连接,不再重试
self.logger.error("Device disconnected, stopping reconnection attempts")
self.connected = False
except Exception:
if self.check_connectivity():
reconnect_attempts += 1
if reconnect_attempts > max_reconnect_attempts:
self.logger.error(f"Max reconnection attempts ({max_reconnect_attempts}) reached, giving up")
self.connected = False
return
traceback.print_exc()
# clear self.last_acc_event
self.logger.warning(f"Restarting droidbot app (attempt {reconnect_attempts}/{max_reconnect_attempts})")
self.last_acc_event = None
self.disconnect()
try:
self.connect()
except ADBException:
self.logger.error("Failed to reconnect, device may be disconnected")
self.connected = False
def handle_message(self, message):
acc_event_idx = message.find("AccEvent >>> ")
if acc_event_idx >= 0:
if acc_event_idx > 0:
self.logger.warning("Invalid data before packet head: " + message[:acc_event_idx])
body = json.loads(message[acc_event_idx + len("AccEvent >>> "):])
self.last_acc_event = body
return
rotation_idx = message.find("rotation >>> ")
if rotation_idx >= 0:
if rotation_idx > 0:
self.logger.warning("Invalid data before packet head: " + message[:rotation_idx])
self.device.handle_rotation()
return
self.logger.warning("Unhandled message from droidbot app: " + message)
raise DroidBotAppConnException()
def check_connectivity(self):
"""
check if droidbot app is connected
:return: True for connected
"""
return self.connected
def disconnect(self):
"""
disconnect telnet
"""
self.connected = False
if self.sock is not None:
try:
self.sock.close()
except Exception as e:
self.logger.error(e)
try:
forward_remove_cmd = "adb -s %s forward --remove tcp:%d" % (self.device.serial, self.port)
p = subprocess.Popen(forward_remove_cmd.split(), stderr=subprocess.PIPE, stdout=subprocess.PIPE)
out, err = p.communicate()
except Exception as e:
self.logger.error(e)
self.__can_wait = False
def __view_tree_to_list(self, view_tree, view_list):
tree_id = len(view_list)
view_tree['temp_id'] = tree_id
bounds = [[-1, -1], [-1, -1]]
bounds[0][0] = view_tree['bounds'][0]
bounds[0][1] = view_tree['bounds'][1]
bounds[1][0] = view_tree['bounds'][2]
bounds[1][1] = view_tree['bounds'][3]
width = bounds[1][0] - bounds[0][0]
height = bounds[1][1] - bounds[0][1]
view_tree['size'] = "%d*%d" % (width, height)
view_tree['bounds'] = bounds
# 重命名 'class' 为 'class_name' (符合 ViewDict 标准)
if 'class' in view_tree:
view_tree['class_name'] = view_tree.pop('class')
view_list.append(view_tree)
children_ids = []
for child_tree in view_tree['children']:
if self.ignore_ad and child_tree['resource_id'] is not None:
id_word_list = self.__id_convert(child_tree['resource_id']).split('_')
if "ad" in id_word_list or \
"banner" in id_word_list:
continue
child_tree['parent'] = tree_id
self.__view_tree_to_list(child_tree, view_list)
children_ids.append(child_tree['temp_id'])
view_tree['children'] = children_ids
def get_views(self):
get_views_times = 0
while not self.last_acc_event:
self.logger.warning("last_acc_event is None, waiting")
get_views_times += 1
if get_views_times > MAX_NUM_GET_VIEWS:
self.logger.warning("cannot get non-None last_acc_event")
return None
time.sleep(GET_VIEW_WAIT_TIME)
if 'view_list' in self.last_acc_event:
return self.last_acc_event['view_list']
import copy
view_tree = copy.deepcopy(self.last_acc_event['root_node'])
# print view_tree
if not view_tree:
return None
view_tree['parent'] = -1
view_list = []
self.__view_tree_to_list(view_tree, view_list)
self.last_acc_event['view_list'] = view_list
return view_list
if __name__ == "__main__":
droidbot_app_conn = DroidBotAppConn()
droidbot_app_conn.set_up()
droidbot_app_conn.connect()