mirror of
https://github.com/LmeSzinc/StarRailCopilot.git
synced 2024-12-12 07:29:03 +00:00
e89c8e7013
(cherry picked from commit 886736de45ceac0f23250c9aa27d1cb516f0311b)
427 lines
15 KiB
Python
427 lines
15 KiB
Python
import re
|
|
import time
|
|
from functools import wraps
|
|
|
|
import cv2
|
|
import numpy as np
|
|
from adbutils.errors import AdbError
|
|
from lxml import etree
|
|
|
|
from module.base.decorator import Config
|
|
from module.config.server import DICT_PACKAGE_TO_ACTIVITY
|
|
from module.device.connection import Connection
|
|
from module.device.method.utils import (ImageTruncated, PackageNotInstalled, RETRY_TRIES, handle_adb_error,
|
|
handle_unknown_host_service, remove_prefix, retry_sleep)
|
|
from module.exception import RequestHumanTakeover, ScriptError
|
|
from module.logger import logger
|
|
|
|
|
|
def retry(func):
|
|
@wraps(func)
|
|
def retry_wrapper(self, *args, **kwargs):
|
|
"""
|
|
Args:
|
|
self (Adb):
|
|
"""
|
|
init = None
|
|
for _ in range(RETRY_TRIES):
|
|
try:
|
|
if callable(init):
|
|
retry_sleep(_)
|
|
init()
|
|
return func(self, *args, **kwargs)
|
|
# Can't handle
|
|
except RequestHumanTakeover:
|
|
break
|
|
# When adb server was killed
|
|
except ConnectionResetError as e:
|
|
logger.error(e)
|
|
|
|
def init():
|
|
self.adb_reconnect()
|
|
# AdbError
|
|
except AdbError as e:
|
|
if handle_adb_error(e):
|
|
def init():
|
|
self.adb_reconnect()
|
|
elif handle_unknown_host_service(e):
|
|
def init():
|
|
self.adb_start_server()
|
|
self.adb_reconnect()
|
|
else:
|
|
break
|
|
# Package not installed
|
|
except PackageNotInstalled as e:
|
|
logger.error(e)
|
|
|
|
def init():
|
|
self.detect_package()
|
|
# ImageTruncated
|
|
except ImageTruncated as e:
|
|
logger.error(e)
|
|
|
|
def init():
|
|
pass
|
|
# Unknown
|
|
except Exception as e:
|
|
logger.exception(e)
|
|
|
|
def init():
|
|
pass
|
|
|
|
logger.critical(f'Retry {func.__name__}() failed')
|
|
raise RequestHumanTakeover
|
|
|
|
return retry_wrapper
|
|
|
|
|
|
def load_screencap(data):
|
|
"""
|
|
Args:
|
|
data: Raw data from `screencap`
|
|
|
|
Returns:
|
|
np.ndarray:
|
|
"""
|
|
# Load data
|
|
header = np.frombuffer(data[0:12], dtype=np.uint32)
|
|
channel = 4 # screencap sends an RGBA image
|
|
width, height, _ = header # Usually to be 1280, 720, 1
|
|
|
|
image = np.frombuffer(data, dtype=np.uint8)
|
|
if image is None:
|
|
raise ImageTruncated('Empty image after reading from buffer')
|
|
|
|
try:
|
|
image = image[-int(width * height * channel):].reshape(height, width, channel)
|
|
except ValueError as e:
|
|
# ValueError: cannot reshape array of size 0 into shape (720,1280,4)
|
|
raise ImageTruncated(str(e))
|
|
|
|
image = cv2.cvtColor(image, cv2.COLOR_BGRA2BGR)
|
|
if image is None:
|
|
raise ImageTruncated('Empty image after cv2.cvtColor')
|
|
|
|
return image
|
|
|
|
|
|
class Adb(Connection):
|
|
__screenshot_method = [0, 1, 2]
|
|
__screenshot_method_fixed = [0, 1, 2]
|
|
|
|
@staticmethod
|
|
def __load_screenshot(screenshot, method):
|
|
if method == 0:
|
|
pass
|
|
elif method == 1:
|
|
screenshot = screenshot.replace(b'\r\n', b'\n')
|
|
elif method == 2:
|
|
screenshot = screenshot.replace(b'\r\r\n', b'\n')
|
|
else:
|
|
raise ScriptError(f'Unknown method to load screenshots: {method}')
|
|
|
|
# fix compatibility issues for adb screencap decode problem when the data is from vmos pro
|
|
# When use adb screencap for a screenshot from vmos pro, there would be a header more than that from emulator
|
|
# which would cause image decode problem. So i check and remove the header there.
|
|
screenshot = remove_prefix(screenshot, b'long long=8 fun*=10\n')
|
|
|
|
image = np.frombuffer(screenshot, np.uint8)
|
|
if image is None:
|
|
raise ImageTruncated('Empty image after reading from buffer')
|
|
|
|
image = cv2.imdecode(image, cv2.IMREAD_COLOR)
|
|
if image is None:
|
|
raise ImageTruncated('Empty image after cv2.imdecode')
|
|
|
|
cv2.cvtColor(image, cv2.COLOR_BGR2RGB, dst=image)
|
|
if image is None:
|
|
raise ImageTruncated('Empty image after cv2.cvtColor')
|
|
|
|
return image
|
|
|
|
def __process_screenshot(self, screenshot):
|
|
for method in self.__screenshot_method_fixed:
|
|
try:
|
|
result = self.__load_screenshot(screenshot, method=method)
|
|
self.__screenshot_method_fixed = [method] + self.__screenshot_method
|
|
return result
|
|
except (OSError, ImageTruncated):
|
|
continue
|
|
|
|
self.__screenshot_method_fixed = self.__screenshot_method
|
|
if len(screenshot) < 500:
|
|
logger.warning(f'Unexpected screenshot: {screenshot}')
|
|
raise OSError(f'cannot load screenshot')
|
|
|
|
@retry
|
|
@Config.when(DEVICE_OVER_HTTP=False)
|
|
def screenshot_adb(self):
|
|
data = self.adb_shell(['screencap', '-p'], stream=True)
|
|
if len(data) < 500:
|
|
logger.warning(f'Unexpected screenshot: {data}')
|
|
|
|
return self.__process_screenshot(data)
|
|
|
|
@retry
|
|
@Config.when(DEVICE_OVER_HTTP=True)
|
|
def screenshot_adb(self):
|
|
data = self.adb_shell(['screencap'], stream=True)
|
|
if len(data) < 500:
|
|
logger.warning(f'Unexpected screenshot: {data}')
|
|
|
|
return load_screencap(data)
|
|
|
|
@retry
|
|
def screenshot_adb_nc(self):
|
|
data = self.adb_shell_nc(['screencap'])
|
|
if len(data) < 500:
|
|
logger.warning(f'Unexpected screenshot: {data}')
|
|
|
|
return load_screencap(data)
|
|
|
|
@retry
|
|
def click_adb(self, x, y):
|
|
start = time.time()
|
|
self.adb_shell(['input', 'tap', x, y])
|
|
if time.time() - start <= 0.05:
|
|
self.sleep(0.05)
|
|
|
|
@retry
|
|
def swipe_adb(self, p1, p2, duration=0.1):
|
|
duration = int(duration * 1000)
|
|
self.adb_shell(['input', 'swipe', *p1, *p2, duration])
|
|
|
|
@retry
|
|
def app_current_adb(self):
|
|
"""
|
|
Copied from uiautomator2
|
|
|
|
Returns:
|
|
str: Package name.
|
|
|
|
Raises:
|
|
OSError
|
|
|
|
For developer:
|
|
Function reset_uiautomator need this function, so can't use jsonrpc here.
|
|
"""
|
|
# Related issue: https://github.com/openatx/uiautomator2/issues/200
|
|
# $ adb shell dumpsys window windows
|
|
# Example output:
|
|
# mCurrentFocus=Window{41b37570 u0 com.incall.apps.launcher/com.incall.apps.launcher.Launcher}
|
|
# mFocusedApp=AppWindowToken{422df168 token=Token{422def98 ActivityRecord{422dee38 u0 com.example/.UI.play.PlayActivity t14}}}
|
|
# Regexp
|
|
# r'mFocusedApp=.*ActivityRecord{\w+ \w+ (?P<package>.*)/(?P<activity>.*) .*'
|
|
# r'mCurrentFocus=Window{\w+ \w+ (?P<package>.*)/(?P<activity>.*)\}')
|
|
_focusedRE = re.compile(
|
|
r'mCurrentFocus=Window{.*\s+(?P<package>[^\s]+)/(?P<activity>[^\s]+)\}'
|
|
)
|
|
m = _focusedRE.search(self.adb_shell(['dumpsys', 'window', 'windows']))
|
|
if m:
|
|
return m.group('package')
|
|
|
|
# try: adb shell dumpsys activity top
|
|
_activityRE = re.compile(
|
|
r'ACTIVITY (?P<package>[^\s]+)/(?P<activity>[^/\s]+) \w+ pid=(?P<pid>\d+)'
|
|
)
|
|
output = self.adb_shell(['dumpsys', 'activity', 'top'])
|
|
ms = _activityRE.finditer(output)
|
|
ret = None
|
|
for m in ms:
|
|
ret = m.group('package')
|
|
if ret: # get last result
|
|
return ret
|
|
raise OSError("Couldn't get focused app")
|
|
|
|
@retry
|
|
def _app_start_adb_monkey(self, package_name=None, allow_failure=False):
|
|
"""
|
|
Args:
|
|
package_name (str):
|
|
allow_failure (bool):
|
|
|
|
Returns:
|
|
bool: If success to start
|
|
|
|
Raises:
|
|
PackageNotInstalled:
|
|
"""
|
|
if not package_name:
|
|
package_name = self.package
|
|
result = self.adb_shell([
|
|
'monkey', '-p', package_name, '-c',
|
|
'android.intent.category.LAUNCHER', '--pct-syskeys', '0', '1'
|
|
])
|
|
if 'No activities found' in result:
|
|
# ** No activities found to run, monkey aborted.
|
|
if allow_failure:
|
|
return False
|
|
else:
|
|
logger.error(result)
|
|
raise PackageNotInstalled(package_name)
|
|
elif 'inaccessible' in result:
|
|
# /system/bin/sh: monkey: inaccessible or not found
|
|
return False
|
|
else:
|
|
# Events injected: 1
|
|
# ## Network stats: elapsed time=4ms (0ms mobile, 0ms wifi, 4ms not connected)
|
|
return True
|
|
|
|
@retry
|
|
def _app_start_adb_am(self, package_name=None, activity_name=None, allow_failure=False):
|
|
"""
|
|
Args:
|
|
package_name (str):
|
|
activity_name (str):
|
|
allow_failure (bool):
|
|
|
|
Returns:
|
|
bool: If success to start
|
|
|
|
Raises:
|
|
PackageNotInstalled:
|
|
"""
|
|
if not package_name:
|
|
package_name = self.package
|
|
if not activity_name:
|
|
result = self.adb_shell(['dumpsys', 'package', package_name])
|
|
res = re.search(r'android.intent.action.MAIN:\s+\w+ ([\w.\/]+) filter \w+\s+'
|
|
r'.*\s+Category: "android.intent.category.LAUNCHER"',
|
|
result)
|
|
if res:
|
|
# com.bilibili.azurlane/com.manjuu.azurlane.MainActivity
|
|
activity_name = res.group(1)
|
|
try:
|
|
activity_name = activity_name.split('/')[-1]
|
|
except IndexError:
|
|
logger.error(f'No activity name from {activity_name}')
|
|
return False
|
|
else:
|
|
if allow_failure:
|
|
return False
|
|
else:
|
|
logger.error(result)
|
|
raise PackageNotInstalled(package_name)
|
|
|
|
cmd = ['am', 'start', '-a', 'android.intent.action.MAIN', '-c',
|
|
'android.intent.category.LAUNCHER', '-n', f'{package_name}/{activity_name}']
|
|
if self.is_local_network_device and self.is_waydroid:
|
|
cmd += ['--windowingMode', '4']
|
|
ret = self.adb_shell(cmd)
|
|
# Invalid activity
|
|
# Starting: Intent { act=android.intent.action.MAIN cat=[android.intent.category.LAUNCHER] cmp=... }
|
|
# Error type 3
|
|
# Error: Activity class {.../...} does not exist.
|
|
if 'Error: Activity class' in ret:
|
|
if allow_failure:
|
|
return False
|
|
else:
|
|
logger.error(ret)
|
|
return False
|
|
# Already running
|
|
# Warning: Activity not started, intent has been delivered to currently running top-most instance.
|
|
if 'Warning: Activity not started' in ret:
|
|
logger.info('App activity is already started')
|
|
return True
|
|
# Starting: Intent { act=android.intent.action.MAIN cat=[android.intent.category.LAUNCHER] cmp=com.YoStarEN.AzurLane/com.manjuu.azurlane.MainActivity }
|
|
# java.lang.SecurityException: Permission Denial: starting Intent { act=android.intent.action.MAIN cat=[android.intent.category.LAUNCHER] flg=0x10000000 cmp=com.YoStarEN.AzurLane/com.manjuu.azurlane.MainActivity } from null (pid=5140, uid=2000) not exported from uid 10064
|
|
# at android.os.Parcel.readException(Parcel.java:1692)
|
|
# at android.os.Parcel.readException(Parcel.java:1645)
|
|
# at android.app.ActivityManagerProxy.startActivityAsUser(ActivityManagerNative.java:3152)
|
|
# at com.android.commands.am.Am.runStart(Am.java:643)
|
|
# at com.android.commands.am.Am.onRun(Am.java:394)
|
|
# at com.android.internal.os.BaseCommand.run(BaseCommand.java:51)
|
|
# at com.android.commands.am.Am.main(Am.java:124)
|
|
# at com.android.internal.os.RuntimeInit.nativeFinishInit(Native Method)
|
|
# at com.android.internal.os.RuntimeInit.main(RuntimeInit.java:290)
|
|
if 'Permission Denial' in ret:
|
|
if allow_failure:
|
|
return False
|
|
else:
|
|
logger.error(ret)
|
|
logger.error('Permission Denial while starting app, probably because activity invalid')
|
|
return False
|
|
# Success
|
|
# Starting: Intent...
|
|
return True
|
|
|
|
# No @retry decorator since _app_start_adb_am and _app_start_adb_monkey have @retry already
|
|
# @retry
|
|
def app_start_adb(self, package_name=None, activity_name=None, allow_failure=False):
|
|
"""
|
|
Args:
|
|
package_name (str):
|
|
If None, to get from config
|
|
activity_name (str):
|
|
If None, to get from DICT_PACKAGE_TO_ACTIVITY
|
|
If still None, launch from monkey
|
|
If monkey failed, fetch activity name and launch from am
|
|
allow_failure (bool):
|
|
True for no PackageNotInstalled raising, just return False
|
|
|
|
Returns:
|
|
bool: If success to start
|
|
|
|
Raises:
|
|
PackageNotInstalled:
|
|
"""
|
|
if not package_name:
|
|
package_name = self.package
|
|
if not activity_name:
|
|
activity_name = DICT_PACKAGE_TO_ACTIVITY.get(package_name)
|
|
|
|
if activity_name:
|
|
if self._app_start_adb_am(package_name, activity_name, allow_failure):
|
|
return True
|
|
if self._app_start_adb_monkey(package_name, allow_failure):
|
|
return True
|
|
if self._app_start_adb_am(package_name, activity_name, allow_failure):
|
|
return True
|
|
|
|
logger.error('app_start_adb: All trials failed')
|
|
return False
|
|
|
|
@retry
|
|
def app_stop_adb(self, package_name=None):
|
|
""" Stop one application: am force-stop"""
|
|
if not package_name:
|
|
package_name = self.package
|
|
self.adb_shell(['am', 'force-stop', package_name])
|
|
|
|
@retry
|
|
def dump_hierarchy_adb(self, temp: str = '/data/local/tmp/hierarchy.xml') -> etree._Element:
|
|
"""
|
|
Args:
|
|
temp (str): Temp file store on emulator.
|
|
|
|
Returns:
|
|
etree._Element:
|
|
"""
|
|
# Remove existing file
|
|
# self.adb_shell(['rm', '/data/local/tmp/hierarchy.xml'])
|
|
|
|
# Dump hierarchy
|
|
for _ in range(2):
|
|
response = self.adb_shell(['uiautomator', 'dump', '--compressed', temp])
|
|
if 'hierchary' in response:
|
|
# UI hierchary dumped to: /data/local/tmp/hierarchy.xml
|
|
break
|
|
else:
|
|
# <None>
|
|
# Must kill uiautomator2
|
|
self.app_stop_adb('com.github.uiautomator')
|
|
self.app_stop_adb('com.github.uiautomator.test')
|
|
continue
|
|
|
|
# Read from device
|
|
content = b''
|
|
for chunk in self.adb.sync.iter_content(temp):
|
|
if chunk:
|
|
content += chunk
|
|
else:
|
|
break
|
|
|
|
# Parse with lxml
|
|
hierarchy = etree.fromstring(content)
|
|
return hierarchy
|