271 lines
10 KiB
Python
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()
|