diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 000000000..09263ed4e --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,36 @@ +{ + "name": "Baas for Android Dev Container", + "build": { + "dockerfile": "../deploy/android/dockerfile", + "context": ".." + }, + // "overrideCommand": false, + "remoteUser": "vscode", + "customizations": { + "vscode": { + "extensions": [ + "ms-python.python", + "ms-python.vscode-pylance", + "tamasfe.even-better-toml" + ], + "settings": { + "python.defaultInterpreterPath": "../.venv/bin/python", + "python.analysis.typeCheckingMode": "basic", + "python.terminal.activateEnvironment": true + } + } + }, + "containerEnv": { + "PYTHONUNBUFFERED": "1", + "PIP_DISABLE_PIP_VERSION_CHECK": "1", + "PIP_NO_CACHE_DIR": "false", + "ADB_SERVER_SOCKET": "tcp:host.docker.internal:5037" + }, + // "mounts": [ + // "source=~/.pyside6-android-deploy,target=${containerWorkspaceFolder}/.pyside6-android-deploy,type=bind,consistency=cached" + // ], + "postCreateCommand": "bash deploy/android/setup_devcontainer.sh", + "runArgs": [ + "--network=host" + ] +} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..775fdbad5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +.venv +__pycache__ +config/ +error.log + +/.sdk +/.pyside6_android_deploy +/.qt-lib +/.buildozer +/deployment +buildozer.spec +build \ No newline at end of file diff --git a/.python-version b/.python-version new file mode 100644 index 000000000..bd28b9c5c --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.9 diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 000000000..24c27b6d8 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,57 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Attach to BoA", + "type": "debugpy", + "request": "attach", + "connect": { + "host": "host.docker.internal", + "port": 5678 + }, + "pathMappings": [ + { + "localRoot": "${workspaceFolder}", + "remoteRoot": "." + }, + { + "localRoot": "${workspaceFolder}/.venv/lib/python3.9/site-packages", + "remoteRoot": "${workspaceFolder}/.buildozer/android/platform/build-arm64-v8a/build/python-installs/boa/arm64-v8a/" + }, + { + "localRoot": "/usr/local/lib/python3.9/", + "remoteRoot": "${workspaceFolder}/.buildozer/android/platform/build-arm64-v8a/build/other_builds/python3/arm64-v8a__ndk_target_24/python3/Lib/" + } + ], + "justMyCode": false + }, + { + "name": "Sync and Start BoA", + "type": "debugpy", + "request": "attach", + "preLaunchTask": "prepare-android-debug", + "connect": { + "host": "host.docker.internal", + "port": 5678 + }, + "pathMappings": [ + { + "localRoot": "${workspaceFolder}", + "remoteRoot": "." + }, + { + "localRoot": "${workspaceFolder}/.venv/lib/python3.9/site-packages", + "remoteRoot": "${workspaceFolder}/.buildozer/android/platform/build-arm64-v8a/build/python-installs/boa/arm64-v8a/" + }, + { + "localRoot": "/usr/local/lib/python3.9/", + "remoteRoot": "${workspaceFolder}/.buildozer/android/platform/build-arm64-v8a/build/other_builds/python3/arm64-v8a__ndk_target_24/python3/Lib/" + } + ], + "justMyCode": false + } + ] +} \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 000000000..5ee2b81ab --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,33 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "prepare-android-debug", + "type": "shell", + "command": "python3 ./deploy/android/sync.py && adb forward tcp:5678 tcp:5678 && adb shell am force-stop top.qwq123.boa && adb shell am start -n top.qwq123.boa/org.baas.boa.MainActivity && sleep 1", + "problemMatcher": [], + "presentation": { + "echo": true, + "reveal": "always", + "focus": false, + "panel": "shared", + "showReuseMessage": true, + "clear": false + } + }, + { + "label": "BoA: Sync Files", + "type": "shell", + "command": "python3 ./deploy/android/sync.py", + "problemMatcher": [], + "presentation": { + "echo": true, + "reveal": "always", + "focus": false, + "panel": "shared", + "showReuseMessage": true, + "clear": false + } + } + ] +} \ No newline at end of file diff --git a/android_main.py b/android_main.py new file mode 100644 index 000000000..dca9a43a5 --- /dev/null +++ b/android_main.py @@ -0,0 +1,254 @@ +# noqa: E402 +# ruff: noqa: E402 +""" +This file is the entry point for the Android version of BAAS. +""" + +# # Uncomment following lines to enable debugging with debugpy +# import debugpy +# debugpy.listen(5678, in_process_debug_adapter=True) +# print("Waiting for debugger attach...") +# debugpy.wait_for_client() + +# Setup Shizuku and ADB +import core.android.classes # Load classes early # noqa: F401 +from core.android.util import main_activity +Main = main_activity() + +from core.android.shizuku import request_permission +from core.android import patch_adb, ShizukuClient +from core.utils import Logger +logger = Logger(None) +shizuku = ShizukuClient(logger) +request_permission() +shizuku.connect() +patch_adb(shizuku, logger) + + +import sys +import types + +################### psutil mock ################### +class Process: + def __init__(self, pid=None): + if pid is not None and not isinstance(pid, int): + raise TypeError("pid must be an integer") + self.pid = pid + + def is_running(self): + return False + + def children(self, recursive=False): + return [] + + def cmdline(self): + return [] + + def name(self): + return "" + + def exe(self): + return "" + + def info(self): + return {'pid': self.pid, 'name': '', 'exe': '', 'cmdline': ''} + + +def process_iter(attrs=None, ad_value=None): + return iter([]) + + +def pid_exists(pid): + return False + + +class Error(Exception): + pass + + +class NoSuchProcess(Error): + pass + + +class AccessDenied(Error): + pass + + +class TimeoutExpired(Error): + pass + +psutil_mock = types.ModuleType('psutil') +psutil_mock.Process = Process # type: ignore +psutil_mock.process_iter = process_iter # type: ignore +psutil_mock.pid_exists = pid_exists # type: ignore +psutil_mock.Error = Error # type: ignore +psutil_mock.NoSuchProcess = NoSuchProcess # type: ignore +psutil_mock.AccessDenied = AccessDenied # type: ignore +psutil_mock.TimeoutExpired = TimeoutExpired # type: ignore + +sys.modules['psutil'] = psutil_mock +sys.modules['psutil._psutil_linux'] = types.ModuleType('psutil._psutil_linux') + + +################### Compatible Layer for Qt5 ################### + +import os +import sys +import runpy +import functools +from typing import Any, Callable, Type + +import qtpy +import qtpy.QtCore +import qtpy.QtGui +import qtpy.QtWidgets +from qtpy.QtWidgets import QTableWidget + +QTableWidget.update + +def monkey_patch(cls: Type, attr_name: str) -> Callable[[Callable], Callable]: + """ + 一个用于简化猴子补丁类方法的装饰器。 + + 用法: + ```python + @monkey_patch(MyClass, 'my_method') + def my_patched_method(original_method, self, *args, **kwargs): + # 执行一些操作 + result = original_method(self, *args, **kwargs) + # 执行另一些操作 + return result + ``` + """ + def decorator(patch_func: Callable) -> Callable: + original_func = getattr(cls, attr_name) + + @functools.wraps(patch_func) + def wrapper(*args, **kwargs) -> Any: + return patch_func(original_func, *args, **kwargs) + + setattr(cls, attr_name, wrapper) + return wrapper + return decorator + +# --- Monkey patch 1: QApplication shim and PyQt5 aliasing --- + +OriginalQApplication = qtpy.QtWidgets.QApplication +_app = qtpy.QtWidgets.QApplication(sys.argv) +if _app is None: + _app = OriginalQApplication(sys.argv) + +class PatchedQApplication(OriginalQApplication): + def __new__(cls, *args, **kwargs): + return _app + + @staticmethod + def exec(*args, **kwargs): + return OriginalQApplication.exec() + +qtpy.QtCore.pyqtSignal = qtpy.QtCore.Signal +qtpy.QtCore.QRegExp = qtpy.QtCore.QRegularExpression +qtpy.QtCore.pyqtProperty = qtpy.QtCore.Property +qtpy.QtCore.pyqtSlot = qtpy.QtCore.Slot +qtpy.QtGui.QRegExpValidator = qtpy.QtGui.QRegularExpressionValidator + +@monkey_patch(qtpy.QtCore.QTranslator, 'translate') +def patch_QTranslator_translate(translate, self, context, sourceText, disambiguation=None, n=-1): + if isinstance(context, (bytes, bytearray)): + context = context.decode('utf-8') + if isinstance(sourceText, (bytes, bytearray)): + sourceText = sourceText.decode('utf-8') + if isinstance(disambiguation, (bytes, bytearray)): + disambiguation = disambiguation.decode('utf-8') + return translate(self, context, sourceText, disambiguation, n) + +qtpy.QtWidgets.qApp = _app +qtpy.QtWidgets.QApplication = PatchedQApplication +qtpy.QtWidgets.QApplication.desktop = lambda: qtpy.QtGui.QGuiApplication.primaryScreen() +sys.modules['PyQt5'] = qtpy +sys.modules['PyQt5.QtCore'] = qtpy.QtCore +sys.modules['PyQt5.QtGui'] = qtpy.QtGui +sys.modules['PyQt5.QtWidgets'] = qtpy.QtWidgets + + +# --- Monkey patch 2: MSFluentWindow.addSubInterface button disconnect compatibility --- +from qfluentwidgets import MSFluentWindow + +@monkey_patch(MSFluentWindow, 'addSubInterface') +def patch_addSubInterface(addSubInterface, self, *args, **kwargs): + btn = addSubInterface(self, *args, **kwargs) + try: + _orig_btn_disconnect = getattr(btn, 'disconnect', None) + except Exception: + _orig_btn_disconnect = None + + def _btn_disconnect_compat(*a, **kw): + if callable(_orig_btn_disconnect): + try: + return _orig_btn_disconnect(*a, **kw) + except TypeError: + pass + for _sig_name in ("clicked", "pressed", "released", "toggled"): + try: + _sig = getattr(btn, _sig_name, None) + if _sig is not None: + try: + _sig.disconnect() + except Exception: + pass + except Exception: + pass + return None + + try: + setattr(btn, 'disconnect', _btn_disconnect_compat) + except Exception: + pass + return btn + + +# --- Monkey patch 3: QWidget.setFocus accept PyQt5-style bool --- +@monkey_patch(qtpy.QtWidgets.QWidget, 'setFocus') +def patch_setFocus(setFocus, self, *args, **kwargs): + if len(args) == 1 and isinstance(args[0], bool): + return setFocus(self) + return setFocus(self, *args, **kwargs) + + +# --- Monkey patch 4: onNavigationChanged --- +@monkey_patch(MSFluentWindow, '__init__') +def patch_init(__init__, self, *args, **kwargs): + __init__(self, *args, **kwargs) + if hasattr(self, 'onNavigationChanged'): + original_onNavigationChanged = self.onNavigationChanged + + @functools.wraps(original_onNavigationChanged) + def patched_onNavigationChanged(*p_args, **p_kwargs): + return original_onNavigationChanged(p_args[0]) + + self.onNavigationChanged = patched_onNavigationChanged + +@monkey_patch(QTableWidget, 'update') +def patch_QTableWidget_update(update, self, *args, **kwargs): + # 如果没有传入参数,说明调用者想要的是 QWidget.update() (重绘整个控件) + if not args and not kwargs: + # 显式调用基类 QWidget 的 update 方法,避开 PySide6 的重载歧义 + return qtpy.QtWidgets.QWidget.update(self) + + # 如果有参数 (例如 QModelIndex),则调用原始方法 (即 QAbstractItemView.update(index)) + return update(self, *args, **kwargs) + + +################### Entry ################### + + +if __name__ == '__main__': + sys.path.append('./') + pwd = os.path.dirname(__file__) + if os.path.exists(os.path.join(pwd, 'window.py')): + path = os.path.join(pwd, 'window.py') + elif os.path.exists(os.path.join(pwd, 'window.pyc')): + path = os.path.join(pwd, 'window.pyc') + else: + raise FileNotFoundError('window.py or window.pyc not found') + runpy.run_path(path, run_name='__main__') diff --git a/core/Baas_thread.py b/core/Baas_thread.py index 83245ebd4..9048eb713 100644 --- a/core/Baas_thread.py +++ b/core/Baas_thread.py @@ -2,6 +2,7 @@ import json import math import os +import re import subprocess import threading import time @@ -29,6 +30,10 @@ from core.pushkit import push from core.scheduler import Scheduler +if utils.host_platform_is_android(): + from core.android.screen import extract_virtual_displays + + func_dict = { 'group': module.group.implement, 'momo_talk': module.momo_talk.implement, @@ -65,7 +70,8 @@ 'friend': module.friend.implement, 'joint_firing_drill': module.joint_firing_drill.implement, 'pass': module.collect_pass_reward.implement, - 'collect_daily_free_power': module.collect_daily_free_power.implement + 'collect_daily_free_power': module.collect_daily_free_power.implement, + 'final_restriction_rls': module.final_restriction_rls.implement, } @@ -113,6 +119,7 @@ def __init__(self, config, logger_signal=None, button_signal=None, update_signal self.screenshot = None self.ocr_img_pass_method = None self.shared_memory_name = None + self.target_display = None def set_ocr(self, ocr): self.ocr = ocr @@ -305,6 +312,12 @@ def init_device(self) -> bool: self.set_screenshot_interval(self.config.screenshot_interval) self.check_resolution() + try: + if utils.host_platform_is_android(): + self._setup_boa() + except Exception: + self.logger.warning("BoA setup failed") + raise self.get_ocr_language() self.identifier = self.server if self.server == "Global": @@ -473,6 +486,13 @@ def thread_starter(self): except Exception as e: self.logger.error(traceback.format_exc()) return + finally: + # Ensure BoA overlay/display settings are cleaned up when the thread exits. + try: + if utils.host_platform_is_android() and self.u2 is not None: + self._clean_boa() + except Exception as e: + self.logger.warning("BoA cleanup failed: " + str(e)) def genScheduleLog(self, task): self.logger.info("Scheduler : {") @@ -1042,8 +1062,34 @@ def _get_android_device_resolution(self): self.u2 = self.u2_client.get_connection() self.check_atx() self.last_refresh_u2_time = time.time() + # TODO: + # Set device resolution for BoA + if utils.host_platform_is_android(): + # self.u2.shell("wm size 720x1280") + # it seems that u2 returns old resolution even after changing resolution + # but screenshot is not affected + # so we just return 1280x720 here + return (1280, 720) return self.resolution_uiautomator2() + def _setup_boa(self): + # create a virtual display + # ref: https://github.com/Genymobile/scrcpy/issues/1887#issuecomment-817520017 + # TODO: further tests for overlay_display_devices on DIFFERENT DEVICES are needed + self.u2.shell("settings put global overlay_display_devices 1280x720/240") + time.sleep(1) + # get display id + display_info = self.u2.shell("dumpsys SurfaceFlinger") # to get stable id + self.target_display = extract_virtual_displays(display_info.output) + if not self.target_display: + raise Exception("Failed to create virtual display for BoA.") + self.control.set_display_id(self.target_display.logical_id) + self.screenshot.set_display_id(self.target_display.stable_id) + + def _clean_boa(self): + # remove virtual display + self.u2.shell('settings put global overlay_display_devices ""') + def resolution_uiautomator2(self): for i in range(0, 3): try: diff --git a/core/android/__init__.py b/core/android/__init__.py new file mode 100644 index 000000000..83c473c16 --- /dev/null +++ b/core/android/__init__.py @@ -0,0 +1,4 @@ +from .shizuku import ShizukuClient +from .adb_adapter import patch_adb + +__all__ = ["ShizukuClient", "patch_adb"] \ No newline at end of file diff --git a/core/android/adb_adapter.py b/core/android/adb_adapter.py new file mode 100644 index 000000000..d1b6861d3 --- /dev/null +++ b/core/android/adb_adapter.py @@ -0,0 +1,217 @@ +import os +import io +import posixpath +import asyncio +import threading +import struct +from typing import TYPE_CHECKING, Iterable, BinaryIO, TypeVar +from typing_extensions import override +import logging + +from pyadbserver.server import OK, AdbServer, App, route +from pyadbserver.transport.device import Device +from pyadbserver.services.host import HostService +from pyadbserver.services.sync import SyncV1Service +from pyadbserver.services.forward import ForwardService +from pyadbserver.services.shell import LocalShellService, g_session, encode_shell_packet, ShellProtocolId, NOOP, ResponseAction, FAIL +from pyadbserver.services.fs import AbstractFileSystem, Dirent, FileStat +from pyadbserver.transport.device_manager import SingleDeviceService + +if TYPE_CHECKING: + from core import Baas_thread + from .shizuku import ShizukuClient + +ADB_PORT = 5037 + +class ShizukuFileSystem(AbstractFileSystem): + def __init__(self, shizuku: 'ShizukuClient', logger: 'Baas_thread.Logger'): + self.shizuku = shizuku + self.logger = logger + + @override + def stat(self, path: str) -> 'FileStat': + stat_result = self.shizuku.fs_stat(path) + if stat_result is None or not stat_result.exists: + raise FileNotFoundError(f"No such file or directory: {path}") + + return FileStat( + size=stat_result.size, + mtime=stat_result.mtime, + mode=stat_result.mode, + ) + + @override + def iterdir(self, path: str) -> Iterable[Dirent]: + dir_stat = self.shizuku.fs_stat(path) + if dir_stat is None or not dir_stat.exists: + raise FileNotFoundError(f"No such file or directory: {path}") + if not dir_stat.isDir: + raise NotADirectoryError(f"Not a directory: {path}") + + files = self.shizuku.fs_list(path) + # This is inefficient as it calls fs_stat for each file. + # A batch API in Shizuku would be better. + for file_name in files: + full_path = posixpath.join(path, file_name) + stat = self.shizuku.fs_stat(full_path) + if stat and stat.exists: + yield Dirent( + name=file_name, + mode=stat.mode, + size=stat.size, + mtime=stat.mtime + ) + + @override + def open_for_read(self, path: str) -> BinaryIO: + content = self.shizuku.fs_read(path) + if content is None: + stat = self.shizuku.fs_stat(path) + if stat is None or not stat.exists: + raise FileNotFoundError(f"No such file or directory: {path}") + if stat.isDir: + raise IsADirectoryError(f"Is a directory: {path}") + raise IOError(f"Could not read file: {path}") + + if isinstance(content, str): + content = content.encode('utf-8') + + return io.BytesIO(content) + + @override + def open_for_write(self, path: str, mode: int) -> BinaryIO: + stat = self.shizuku.fs_stat(path) + if stat and stat.exists and stat.isDir: + raise IsADirectoryError(f"Is a directory: {path}") + + # NOTE: shizuku fs_write does not support setting file mode. + buffer = io.BytesIO() + + def close_with_writeback(): + try: + data = buffer.getvalue() + if not self.shizuku.fs_write(path, data, append=False): + raise IOError(f"Could not write to file: {path}") + finally: + buffer.close = lambda: None # prevent recursion + buffer.close() + + buffer.close = close_with_writeback + return buffer + + @override + def makedirs(self, path: str) -> None: + if not self.shizuku.fs_mkdirs(path): + raise IOError(f"Could not create directories: {path}") + + @override + def set_mtime(self, path: str, mtime: int) -> None: + # This is a no-op as Shizuku does not provide a way to set mtime. + # Adb push uses this but it's okay to not implement it. + self.logger.warning(f"ShizukuFileSystem: set_mtime is not implemented for {path}") + pass + +class ShizukuShellService(LocalShellService): + def __init__(self, shizuku: 'ShizukuClient', logger: 'Baas_thread.Logger'): + self.shizuku = shizuku + self.logger = logger + + @override + async def _run_shell_command( + self, + cmd: str, + use_protocol: bool, + use_pty: bool + ): + session = g_session.get() + await session.send_okay(flush=True) + + if use_pty: + session.write(b"PTY mode is not supported by ShizukuShellService\n") + await session._flush() + return NOOP(action=ResponseAction.CLOSE) + + loop = asyncio.get_running_loop() + success, result = await loop.run_in_executor(None, self.shizuku.execute_command, ["/system/bin/sh", "-c", cmd]) + if not success: + error_msg = str(result) + if use_protocol: + packet = encode_shell_packet(ShellProtocolId.STDERR, error_msg.encode('utf-8')) + session.write(packet) + else: + session.write(error_msg.encode('utf-8')) + + # send exit code 1 + if use_protocol: + exit_data = struct.pack("B", 1) + exit_packet = encode_shell_packet(ShellProtocolId.EXIT, exit_data) + session.write(exit_packet) + else: + # result is a CommandResult + stdout = result.stdout or '' + stderr = result.stderr or '' + full_output = stdout + stderr + + if full_output: + data = full_output.encode('utf-8') + if use_protocol: + packet = encode_shell_packet(ShellProtocolId.STDOUT, data) + session.write(packet) + else: + session.write(data) + + exit_code = 0 + if use_protocol: + exit_data = struct.pack("B", exit_code & 0xFF) + exit_packet = encode_shell_packet(ShellProtocolId.EXIT, exit_data) + session.write(exit_packet) + + await session._flush() + return NOOP(action=ResponseAction.CLOSE) + +class HostService2(HostService): + @route('host:connect:') + async def host_connect_serial(self, serial: str): + return OK(data=b'1', action=ResponseAction.KEEP_ALIVE) + + @route('host:transport:') + async def host_transport_serial(self, serial: str): + return OK(action=ResponseAction.KEEP_ALIVE) + +async def server_main(shizuku: 'ShizukuClient', logger: 'Baas_thread.Logger'): + logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s") + logging.getLogger("pyadbserver").setLevel(logging.DEBUG) + ds = SingleDeviceService(device=Device( + id="baas", + serial="baas", + state="device", + properties={} + )) + app = App(device_manager=ds) + server = AdbServer( + port=ADB_PORT, + app=app, + ) + app.register(HostService2(server, ds)) + app.register(SyncV1Service(ShizukuFileSystem(shizuku, logger))) + app.register(ShizukuShellService(shizuku, logger)) + app.register(ForwardService()) + await server.start() + await server.serve_forever() + +def patch_adb(shizuku: 'ShizukuClient', logger: 'Baas_thread.Logger'): + """ + Apply patch to adbutils. + """ + # Patch adbutils + from adbutils._device import BaseDevice + BaseDevice.forward_port = lambda self, remote: int(remote) # prevent random port allocation + + # Start mock server + os.environ["ANDROID_ADB_SERVER_PORT"] = str(ADB_PORT) + thread = threading.Thread( + target=lambda: asyncio.run(server_main(shizuku, logger)), + name="adb-server-thread", + daemon=True, + ) + thread.start() \ No newline at end of file diff --git a/core/android/classes.py b/core/android/classes.py new file mode 100644 index 000000000..ccf36676e --- /dev/null +++ b/core/android/classes.py @@ -0,0 +1,32 @@ +"""This module contains all Java classes used in the Android implementation. + +.. NOTE:: + MUST be imported as soon as possible once Python **main thread** starts. + +""" +from jnius import autoclass + + +Log = autoclass('android.util.Log') +String = autoclass('java.lang.String') +Toast = autoclass('android.widget.Toast') +ByteBuffer = autoclass('java.nio.ByteBuffer') +Bitmap = autoclass('android.graphics.Bitmap') +ComponentName = autoclass('android.content.ComponentName') +BitmapConfig = autoclass('android.graphics.Bitmap$Config') +PackageManager = autoclass('android.content.pm.PackageManager') + +Shizuku = autoclass('rikka.shizuku.Shizuku') +Shizuku_UserServiceArgs = autoclass('rikka.shizuku.Shizuku$UserServiceArgs') +ShizukuBinderWrapper = autoclass('rikka.shizuku.ShizukuBinderWrapper') +SystemServiceHelper = autoclass('rikka.shizuku.SystemServiceHelper') +ListenerInterfaceClass = autoclass('rikka.shizuku.Shizuku$OnRequestPermissionResultListener') +JavaArray = autoclass('java.lang.reflect.Array') + +MainActivity = autoclass('org.baas.boa.MainActivity') +IUserService = autoclass('org.baas.boa.IUserService') +IUserService_Stub = autoclass('org.baas.boa.IUserService$Stub') +UserService = autoclass('org.baas.boa.UserService') +Java_CommandResult = autoclass('org.baas.boa.CommandResult') +Java_FsReadResult = autoclass('org.baas.boa.FsReadResult') +Java_FsStat = autoclass('org.baas.boa.FsStat') diff --git a/core/android/log.py b/core/android/log.py new file mode 100644 index 000000000..7d9bf6aba --- /dev/null +++ b/core/android/log.py @@ -0,0 +1,82 @@ +from typing import Optional +from datetime import datetime + +from .classes import Log as _Log + + +def logcat(message: str, level: str = 'INFO', tag: str = 'BAAS') -> None: + """ + Send message to Android logcat if available. Level is one of + 'DEBUG','INFO','WARNING','ERROR','CRITICAL','VERBOSE'. + Falls back to printing when logcat is not available. + """ + try: + txt = str(message) + if _Log is None: + # fallback to stdout with timestamp + print(f"{datetime.now().strftime('%Y-%m-%d %H:%M:%S')} {tag}/{level}: {txt}") + return + + lvl = (level or 'INFO').upper() + if lvl == 'DEBUG': + _Log.d(tag, txt) + elif lvl == 'INFO': + _Log.i(tag, txt) + elif lvl in ('WARNING', 'WARN'): + _Log.w(tag, txt) + elif lvl in ('ERROR', 'CRITICAL'): + _Log.e(tag, txt) + else: + # VERBOSE or others + try: + _Log.v(tag, txt) + except Exception: + _Log.i(tag, txt) + except Exception: + # ensure logging never raises in production + try: + print(f"{datetime.now().strftime('%Y-%m-%d %H:%M:%S')} {tag}/{level}: {message}") + except Exception: + pass + + +def d(msg: str, tag: str = 'BAAS') -> None: + logcat(msg, level='DEBUG', tag=tag) + + +def i(msg: str, tag: str = 'BAAS') -> None: + logcat(msg, level='INFO', tag=tag) + + +def w(msg: str, tag: str = 'BAAS') -> None: + logcat(msg, level='WARNING', tag=tag) + + +def e(msg: str, tag: str = 'BAAS') -> None: + logcat(msg, level='ERROR', tag=tag) + + +def v(msg: str, tag: str = 'BAAS') -> None: + logcat(msg, level='VERBOSE', tag=tag) + + +class Logger: + """Simple logger wrapper similar to android.util.Log methods.""" + + def __init__(self, tag: str = 'BAAS'): + self.tag = tag + + def d(self, msg: str): + d(msg, tag=self.tag) + + def i(self, msg: str): + i(msg, tag=self.tag) + + def w(self, msg: str): + w(msg, tag=self.tag) + + def e(self, msg: str): + e(msg, tag=self.tag) + + def v(self, msg: str): + v(msg, tag=self.tag) diff --git a/core/android/screen.py b/core/android/screen.py new file mode 100644 index 000000000..c7e875e14 --- /dev/null +++ b/core/android/screen.py @@ -0,0 +1,43 @@ +""" +This module provides functionality to extract virtual display information. + +There are two kinds of display IDs on Android: + +1. Logical Display ID, which is: + * self incrementing from 0 + * used by high-level APIs like ActivityManager +2. Stable Display ID, which is: + * calculated based on hardware properties + * used by low-level APIs + +.. NOTE: + On Android 10 and above, screencap accepts stable display IDs. + On Android versions below 10, screencap accepts logical display IDs. +""" + +import re +from typing import NamedTuple + +class DisplayInfo(NamedTuple): + logical_id: str + stable_id: str + +def extract_virtual_displays(dump_content: str): + """ + Extract virtual display information from the given dumpsys SurfaceFlinger log content. + """ + # Target:Display 11529... (virtual, "") ... layerStack=25 + pattern = re.compile( + r'^Display\s+(\d+)\s+\(virtual,\s+"([^"]+)"\)' + r'(?:.|\n)*?' + r'layerStack=(\d+)', + re.MULTILINE + ) + + for match in pattern.finditer(dump_content): + sf_id = match.group(1) + # name = match.group(2) + layer_stack = match.group(3) + return DisplayInfo(logical_id=layer_stack, stable_id=sf_id) + + return None \ No newline at end of file diff --git a/core/android/shizuku.py b/core/android/shizuku.py new file mode 100644 index 000000000..8b8d20f7b --- /dev/null +++ b/core/android/shizuku.py @@ -0,0 +1,570 @@ +import base64 +from typing import TYPE_CHECKING, Union, Any, NamedTuple + +from jnius import PythonJavaClass +from jnius import java_method # type: ignore + +from .util import main_activity +from .classes import ( + Shizuku, Shizuku_UserServiceArgs, String, + PackageManager, IUserService_Stub, ComponentName, JavaArray +) + +if TYPE_CHECKING: + from core import Baas_thread + + +class CommandResult(NamedTuple): + exitCode: int + stdout: str + stderr: str + + +class FsStatResult(NamedTuple): + exists: bool + isDir: bool + size: int + mtime: int + mode: int + uid: int + gid: int + + +_listeners = set() +_stream_callbacks = set() +PERMISSION_CODE_DEFAULT = 1024 +_LISTENER_IFACE = 'rikka.shizuku.Shizuku$OnRequestPermissionResultListener' +_LISTENER_IFACE_JNI = _LISTENER_IFACE.replace('.', '/') + + +def to_java_str_array(seq): + """Convert a Python sequence to a java.lang.String[] using `String` and `JavaArray`. + + Args: + seq: iterable of items convertible to str + + Returns: + A Java array of `java.lang.String` instances. + """ + seq = list(seq) + arr = JavaArray.newInstance(String, len(seq)) + for i, item in enumerate(seq): + arr[i] = String(str(item)) + return arr + +def is_available() -> bool: + """Check if Shizuku class is available. + + Returns: + bool: True if Shizuku class is available, False otherwise. + """ + return Shizuku is not None + + +def is_pre_v11() -> bool: + """Check if this is a pre-v11 version of Shizuku (does not support dynamic permission requests). + + Returns: + bool: True if this is a pre-v11 version of Shizuku, False otherwise. + """ + try: + return bool(Shizuku.isPreV11()) + except Exception: + # If method does not exist, default to supporting dynamic requests + return False + + +def check_permission() -> bool: + """Check if Shizuku adb shell permission is granted. + + Returns: + bool: True if permission is granted, False otherwise. + """ + try: + return Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED + except Exception: + return False + + +def request_permission(activity=None, request_code: int = PERMISSION_CODE_DEFAULT): + """Request Shizuku permission dynamically. + + - If already authorized, return immediately. + - If Shizuku version is too old (pre v11), return immediately. + - Otherwise, initiate permission request. + + Args: + activity: The activity context. + request_code (int): The request code for the permission request. + """ + try: + from .classes import MainActivity + MainActivity.requestShizukuPermission() + except Exception: + # Defer all error handling to Java side; avoid crashing Python entry. + pass + + +class _PermissionResultListener(PythonJavaClass): + """ + Proxy for Shizuku permission result listener. + Corresponds to Java interface: Shizuku.OnRequestPermissionResultListener + """ + + __javainterfaces__ = [_LISTENER_IFACE_JNI] + __javacontext__ = 'app' + + def __init__(self, callback): + super().__init__() + self._callback = callback + + # Signature: void onRequestPermissionResult(int requestCode, int grantResult) + @java_method('(II)V') + def onRequestPermissionResult(self, requestCode, grantResult): + try: + if callable(self._callback): + self._callback(int(requestCode), int(grantResult)) + except Exception: + # Callback exceptions should not affect the main flow + pass + + +def add_request_permission_result_listener(callback): + """Add permission request result listener. + + Args: + callback: The callback function to be called when permission request result is received. + + Returns: + The listener instance for later removal. + """ + listener = _PermissionResultListener(callback) + Shizuku.addRequestPermissionResultListener(listener) + _listeners.add(listener) + return listener + + +def remove_request_permission_result_listener(listener) -> None: + """Remove the specified permission request result listener. + + Args: + listener: The listener instance to remove. + """ + try: + Shizuku.removeRequestPermissionResultListener(listener) + finally: + if listener in _listeners: + _listeners.remove(listener) + + +class ShizukuClient: + """Client for interacting with Shizuku service. + + This class provides methods to connect to the Shizuku service and execute + various operations such as running commands, file system operations, and + package management. + """ + + def __init__(self, logger: 'Baas_thread.Logger'): + """Initialize the ShizukuClient. + + Args: + logger (Baas_thread.Logger): The logger instance for logging messages. + """ + activity = main_activity() + if not activity: + logger.error("ShuzikuClient: cannot load main activity.") + return + self.context = activity.getApplicationContext() + self.i_user_service: Any = None + self.logger = logger + + # 创建 ServiceConnection 的实现类 + class ShizukuServiceConnection(PythonJavaClass): + __javainterfaces__ = ['android/content/ServiceConnection'] + __javacontext__ = 'app' + + def __init__(self, client): + super().__init__() + self.client = client + + @java_method('(Landroid/content/ComponentName;Landroid/os/IBinder;)V') + def onServiceConnected(self, name, service_binder): + logger.info("ShizukuClient: Service connected") + # 将 IBinder 转换为我们的 AIDL 接口 + self.client.i_user_service = IUserService_Stub.asInterface(service_binder) + + @java_method('(Landroid/content/ComponentName;)V') + def onServiceDisconnected(self, name): + logger.info("ShizukuClient: Service disconnected") + self.client.i_user_service = None + + # 准备 Shizuku 连接所需的参数 + package_name = self.context.getPackageName() + service_class_name = "org.baas.boa.UserService" + component_name = ComponentName(package_name, service_class_name) + + self.user_service_args = Shizuku_UserServiceArgs(component_name) \ + .daemon(False) \ + .processNameSuffix("p4a_service") \ + .debuggable(True) \ + .version(1) + + self.service_connection = ShizukuServiceConnection(self) + + def connect(self): + """Connect to the Shizuku service. + + Returns: + bool: True if connection successful, False otherwise. + """ + if not Shizuku: + self.logger.error("ShizukuClient: Shizuku classes not found.") + return False + + # 检查 Shizuku 权限 + try: + if not check_permission(): + self.logger.warning("ShizukuClient: Shizuku permission not granted. Waiting for grant.") + return False + except Exception as e: + self.logger.error(f"ShizukuClient: Failed to check permission: {e}") + return False + + self.logger.info("ShizukuClient: Binding Shizuku user service...") + try: + Shizuku.bindUserService(self.user_service_args, self.service_connection) + return True + except Exception as e: + self.logger.error(f"ShizukuClient: Failed to bind service: {e}") + return False + + def disconnect(self): + """Disconnect from the Shizuku service. + + Returns: + bool: True if disconnection successful, False otherwise. + """ + if self.i_user_service: + self.logger.info("ShizukuClient: Unbinding Shizuku user service...") + try: + Shizuku.unbindUserService(self.user_service_args, self.service_connection, True) + self.i_user_service = None + return True + except Exception as e: + self.logger.error(f"ShizukuClient: Failed to unbind service: {e}") + return False + return True + + def execute_command(self, command) -> tuple[bool, Union[CommandResult, str]]: + """Execute a shell command via Shizuku service. + + Args: + command (str): The shell command to execute. + + Returns: + tuple[bool, Union[CommandResult, str]]: A tuple containing: + - bool: True if execution successful, False otherwise. + - Union[CommandResult, str]: CommandResult on success, error message on failure. + """ + if not self.i_user_service: + self.logger.error("ShizukuClient: Service not connected.") + return False, "Error: Service not connected." + try: + if isinstance(command, (list, tuple)): + args = to_java_str_array(command) + else: + args = to_java_str_array(["/system/bin/sh", "-c", command]) + + self.logger.info(f"ShizukuClient: Executing command (as java String[]): {args}") + res = self.i_user_service.exec(args) + result = CommandResult( + exitCode=int(res.exitCode), + stdout=str(res.stdout or ''), + stderr=str(res.stderr or ''), + ) + self.logger.info(f"ShizukuClient: Result: {result}") + return True, result + except Exception as e: + import traceback + traceback.print_exc() + self.logger.error(f"ShizukuClient: Command execution failed: {e}") + return False, f"Error: {e}" + + class _StreamCallback(PythonJavaClass): + __javainterfaces__ = ['org/baas/boa/IStreamCallback'] + __javacontext__ = 'app' + + def __init__(self, on_stdout=None, on_stderr=None, on_done=None): + super().__init__() + self._on_stdout = on_stdout + self._on_stderr = on_stderr + self._on_done = on_done + + @java_method('(Ljava/lang/String;)V') + def onStdout(self, line): + if callable(self._on_stdout): + try: + self._on_stdout(line) + except Exception: + pass + + @java_method('(Ljava/lang/String;)V') + def onStderr(self, line): + if callable(self._on_stderr): + try: + self._on_stderr(line) + except Exception: + pass + + @java_method('(I)V') + def onDone(self, exitCode): + if callable(self._on_done): + try: + self._on_done(int(exitCode)) + except Exception: + pass + + def exec_stream(self, command, on_stdout=None, on_stderr=None, on_done=None): + """Execute a shell command with streaming output via Shizuku service. + + Args: + command (str): The shell command to execute. + on_stdout (callable, optional): Callback for stdout data. + on_stderr (callable, optional): Callback for stderr data. + on_done (callable, optional): Callback when command execution finishes. + + Returns: + bool: True if execution started successfully, False otherwise. + """ + if not self.i_user_service: + self.logger.error("ShizukuClient: Service not connected.") + return False + try: + cb = ShizukuClient._StreamCallback(on_stdout, on_stderr, on_done) + _stream_callbacks.add(cb) + + if isinstance(command, (list, tuple)): + args = to_java_str_array(command) + else: + args = to_java_str_array(["/system/bin/sh", "-c", command]) + + self.i_user_service.execStream(args, cb) + return True + except Exception as e: + self.logger.error(f"ShizukuClient: exec_stream failed") + self.logger.error(e) + return False + + def fs_stat(self, path: str): + """Get file/directory statistics. + + Args: + path (str): The file or directory path. + + Returns: + FsStatResult or None: A FsStatResult object containing file statistics if successful, None otherwise. + """ + if not self.i_user_service: + return None + try: + st = self.i_user_service.fsStat(path) + if not st: + return None + return FsStatResult( + exists=bool(st.exists), + isDir=bool(st.isDir), + size=int(st.size or 0), + mtime=int(st.mtime or 0), + mode=st.mode, + uid=st.uid, + gid=st.gid, + ) + except Exception as e: + self.logger.error(f"fs_stat error: {e}") + return None + + def fs_list(self, path: str) -> list[str]: + """List files and directories in the given path. + + Args: + path (str): The directory path to list. + + Returns: + list[str]: A list of file/directory names in the path, empty list if error or path not accessible. + """ + if not self.i_user_service: + return [] + try: + arr = self.i_user_service.fsList(path) + if arr is not None: + return [str(s) for s in arr] + return [] + except Exception as e: + self.logger.error(f"fs_list error: {e}") + return [] + + def fs_read(self, path: str, decode_b64: bool = True): + """Read file content from the given path. + + Args: + path (str): The file path to read. + decode_b64 (bool): Whether to decode base64 content. Defaults to True. + + Returns: + bytes or str or None: File content as bytes if base64 and decoded, + as base64 string if base64 and not decoded, as text if not base64, + or None if error. + """ + if not self.i_user_service: + return None + try: + res = self.i_user_service.fsRead(path) + if res is None: + return None + is_b64 = bool(res.isBase64) + if is_b64: + data = bytes(res.bytes or b'') + if decode_b64: + return data + return 'b64:' + base64.b64encode(data).decode('ascii') + return res.text + except Exception as e: + self.logger.error(f"fs_read error: {e}") + return None + + def fs_write(self, path: str, data, append: bool = False) -> bool: + """Write data to a file. + + Args: + path (str): The file path to write to. + data (str or bytes): The data to write. + append (bool): Whether to append to existing file. Defaults to False. + + Returns: + bool: True if write successful, False otherwise. + """ + if not self.i_user_service: + return False + try: + # Binder 对单次事务有大小限制,统一按二进制分块写入(base64 包裹) + BIN_CHUNK_BYTES = 256 * 1024 # 256KB 原始二进制,base64 后约 ~342KB + + if isinstance(data, (bytes, bytearray)): + bytes_data = bytes(data) + else: + # 将任意可写入的数据转为 UTF-8 字节 + bytes_data = str(data).encode('utf-8') + + total_len = len(bytes_data) + if total_len <= BIN_CHUNK_BYTES: + payload = 'b64:' + base64.b64encode(bytes_data).decode('ascii') + self.i_user_service.fsWrite(path, payload, bool(append)) + return True + + # 分块写入:首块使用传入的 append,其余强制追加 + first_append = bool(append) + offset = 0 + while offset < total_len: + chunk = bytes_data[offset:offset + BIN_CHUNK_BYTES] + payload = 'b64:' + base64.b64encode(chunk).decode('ascii') + self.i_user_service.fsWrite(path, payload, first_append) + first_append = True + offset += len(chunk) + return True + except Exception as e: + self.logger.error(f"fs_write error: {e}") + return False + + def fs_delete(self, path: str, recursive: bool = False) -> bool: + """Delete a file or directory. + + Args: + path (str): The file or directory path to delete. + recursive (bool): Whether to delete directories recursively. Defaults to False. + + Returns: + bool: True if deletion successful, False otherwise. + """ + if not self.i_user_service: + return False + try: + self.i_user_service.fsDelete(path, bool(recursive)) + return True + except Exception as e: + self.logger.error(f"fs_delete error: {e}") + return False + + def fs_mkdirs(self, path: str) -> bool: + """Create directories recursively. + + Args: + path (str): The directory path to create. + + Returns: + bool: True if creation successful, False otherwise. + """ + if not self.i_user_service: + return False + try: + self.i_user_service.fsMkdirs(path) + return True + except Exception as e: + self.logger.error(f"fs_mkdirs error: {e}") + return False + + def fs_move(self, src: str, dst: str, replace: bool = False) -> bool: + """Move or rename a file/directory. + + Args: + src (str): The source file/directory path. + dst (str): The destination file/directory path. + replace (bool): Whether to replace existing destination. Defaults to False. + + Returns: + bool: True if move successful, False otherwise. + """ + if not self.i_user_service: + return False + try: + self.i_user_service.fsMove(src, dst, bool(replace)) + return True + except Exception as e: + self.logger.error(f"fs_move error: {e}") + return False + + def pm_install(self, apk_path: str) -> bool: + """Install an APK package. + + Args: + apk_path (str): The path to the APK file to install. + + Returns: + bool: True if installation successful, False otherwise. + """ + if not self.i_user_service: + return False + try: + self.i_user_service.pmInstall(apk_path) + return True + except Exception as e: + self.logger.error(f"pm_install error: {e}") + return False + + def pm_uninstall(self, package_name: str) -> bool: + """Uninstall an APK package. + + Args: + package_name (str): The package name to uninstall. + + Returns: + bool: True if uninstallation successful, False otherwise. + """ + if not self.i_user_service: + return False + try: + self.i_user_service.pmUninstall(package_name) + return True + except Exception as e: + self.logger.error(f"pm_uninstall error: {e}") + return False diff --git a/core/android/util.py b/core/android/util.py new file mode 100644 index 000000000..ba516cc01 --- /dev/null +++ b/core/android/util.py @@ -0,0 +1,41 @@ +from traceback import print_exc + +from . import classes as android_classes + +_main = None + +def main_activity(): + """ + 获取主 Activity。 + + !! 注意,必须在 Python 入口侧至少调用一次这个函数 !! + 因为 Python 侧非主线程无法获取到任何 APK 内的类 + + :return: 如果获取失败,返回 None + """ + global _main + if _main is None: + try: + MainActivity = android_classes.MainActivity + if MainActivity is None: + return None + _main = MainActivity.mActivity + except Exception: + print_exc() + return _main + + +def show_toast(message: str, activity=None): + """ + 显示 Toast 消息。 + + :param message: 要显示的消息 + :param activity: Activity 实例,如果为 None 则自动获取主 Activity + """ + ctx = activity or main_activity() + if ctx is None: + return + Toast = android_classes.Toast + if Toast is None: + return + Toast.makeText(ctx, message, Toast.LENGTH_SHORT).show() diff --git a/core/color.py b/core/color.py index 47085c0fe..b8ece9f7d 100644 --- a/core/color.py +++ b/core/color.py @@ -23,16 +23,19 @@ def wait_loading(self: Baas_thread) -> None: def rgb_in_range(self, x: int, y: int, r_min: int, r_max: int, g_min: int, g_max: int, b_min: int, b_max: int, check_nearby=False, nearby_range=1): - if r_min <= self.latest_img_array[int(y * self.ratio)][int(x * self.ratio)][2] <= r_max and \ - g_min <= self.latest_img_array[int(y * self.ratio)][int(x * self.ratio)][1] <= g_max and \ - b_min <= self.latest_img_array[int(y * self.ratio)][int(x * self.ratio)][0] <= b_max: + if self.ratio != 1.0: + y = int(y * self.ratio) + x = int(x * self.ratio) + if r_min <= self.latest_img_array[y][x][2] <= r_max and \ + g_min <= self.latest_img_array[y][x][1] <= g_max and \ + b_min <= self.latest_img_array[y][x][0] <= b_max: return True if check_nearby: for i in range(nearby_range * -1, nearby_range + 1): for j in range(nearby_range * -1, nearby_range + 1): - if r_min <= self.latest_img_array[int(y * self.ratio) + i][int(x * self.ratio) + j][2] <= r_max and \ - g_min <= self.latest_img_array[int(y * self.ratio) + i][int(x * self.ratio) + j][1] <= g_max and \ - b_min <= self.latest_img_array[int(y * self.ratio) + i][int(x * self.ratio) + j][0] <= b_max: + if r_min <= self.latest_img_array[y + i][x + j][2] <= r_max and \ + g_min <= self.latest_img_array[y + i][x + j][1] <= g_max and \ + b_min <= self.latest_img_array[y + i][x + j][0] <= b_max: return True return False @@ -145,3 +148,4 @@ def create_rgb_feature(self, name, pos_list, rgb_list): def remove_rgb_feature(self, name): if name in self.rgb_feature: del self.rgb_feature[name] + diff --git a/core/config/default_config.py b/core/config/default_config.py index 8297e3154..798e166f6 100644 --- a/core/config/default_config.py +++ b/core/config/default_config.py @@ -228,6 +228,18 @@ "pre_task": [], "post_task": [] }, + { + "enabled": true, + "priority": 13, + "interval": 0, + "daily_reset": [[20, 0, 0]], + "next_tick": 0, + "event_name": "无限制决战", + "func_name": "final_restriction_rls", + "disabled_time_range": [], + "pre_task": [], + "post_task": [] + }, { "enabled": true, "priority": 14, @@ -448,6 +460,9 @@ "activity_sweep_task_number": 1, "activity_sweep_times": "0", "TacticalChallengeShopRefreshTime": "0", + "final_restriction_rls_employ_formation_method": "default", + "final_restriction_rls_employ_formation_copy_clear_unit_max_unavailable_student_count": 0, + "final_restriction_rls_employ_formation_copy_clear_unit_max_refresh_count": 10, "TacticalChallengeShopList": [ 0, 0, @@ -484,6 +499,9 @@ 0 ], "clear_friend_white_list": [], + "clear_friend_level_limit": -1, + "clear_friend_last_login_time_days": -1, + "clear_friend_last_total_assault_rank_limit": -1, "drill_difficulty_list": [1,1,1], "drill_fight_formation_list": [1,2,3], "drill_enable_sweep": true, @@ -518,6 +536,13 @@ "max_weekly_point": -1, "time": 0 }, + "final_restriction_rls": { + "open": "", + "next_open_time": -1, + "start_time": -1, + "end_time": -1, + "passed_stage": -1 + }, "assetsVisibility": true, "hotkey_run": "Ctrl+Shift+R" } diff --git a/core/config/generated_user_config.py b/core/config/generated_user_config.py index f9eb53c73..0039a2bda 100644 --- a/core/config/generated_user_config.py +++ b/core/config/generated_user_config.py @@ -86,10 +86,16 @@ class Config: activity_sweep_task_number: int activity_sweep_times: str TacticalChallengeShopRefreshTime: str + final_restriction_rls_employ_formation_method: str + final_restriction_rls_employ_formation_copy_clear_unit_max_unavailable_student_count: int + final_restriction_rls_employ_formation_copy_clear_unit_max_refresh_count: int TacticalChallengeShopList: list CommonShopRefreshTime: str CommonShopList: list clear_friend_white_list: list + clear_friend_level_limit: int + clear_friend_last_login_time_days: int + clear_friend_last_total_assault_rank_limit: int drill_difficulty_list: list drill_fight_formation_list: list drill_enable_sweep: bool @@ -100,5 +106,6 @@ class Config: tactical_challenge_coin: dict bounty_coin: dict _pass: dict + final_restriction_rls: dict assetsVisibility: bool hotkey_run: str diff --git a/core/device/Control.py b/core/device/Control.py index 3ad991cd1..5fbe4fb31 100644 --- a/core/device/Control.py +++ b/core/device/Control.py @@ -52,3 +52,7 @@ def long_click(self, x, y, duration): def scroll(self, x, y, clicks): self.control_instance.scroll(x, y, clicks) + + def set_display_id(self, display_id): + if hasattr(self.control_instance, 'set_display_id'): + self.control_instance.set_display_id(display_id) diff --git a/core/device/Screenshot.py b/core/device/Screenshot.py index b981b9130..705a27f43 100644 --- a/core/device/Screenshot.py +++ b/core/device/Screenshot.py @@ -56,6 +56,10 @@ def screenshot(self): self.last_screenshot_time = time.time() return image + def set_display_id(self, display_id): + if hasattr(self.screenshot_instance, 'set_display_id'): + self.screenshot_instance.set_display_id(display_id) + def set_screenshot_interval(self, interval): if interval < 0.3: self.logger.warning("screenshot_interval must be greater than 0.3") diff --git a/core/device/connection.py b/core/device/connection.py index c98740c6d..ae56fa1f9 100644 --- a/core/device/connection.py +++ b/core/device/connection.py @@ -42,6 +42,8 @@ def _init_android_device(self): self.set_serial(self.serial) self.check_serial() self.detect_device() + # HACK: + self.serial = 'baas' self.adb_connect() self.detect_package() self.check_mumu_keep_alive() diff --git a/core/device/control/adb.py b/core/device/control/adb.py index 214f4ca48..2dcf21389 100644 --- a/core/device/control/adb.py +++ b/core/device/control/adb.py @@ -7,17 +7,30 @@ class AdbControl: def __init__(self, conn): self.serial = conn.serial self.adb = adb.device(self.serial) + self.display_id = None + + def set_display_id(self, display_id): + self.display_id = display_id + + def _build_input_cmd(self, action_cmd: str) -> str: + if self.display_id: + return f"input -d {self.display_id} {action_cmd}" + else: + return f"input {action_cmd}" def click(self, x, y): start_t = time.time() - self.adb.shell(f'input tap {x} {y}') + cmd = self._build_input_cmd(f'tap {x} {y}') + self.adb.shell(cmd) if time.time() - start_t < 0.05: time.sleep(0.05) def swipe(self, x1, y1, x2, y2, duration): duration = int(duration * 1000) - self.adb.shell(f'input swipe {x1} {y1} {x2} {y2} {duration}') + cmd = self._build_input_cmd(f'swipe {x1} {y1} {x2} {y2} {duration}') + self.adb.shell(cmd) def long_click(self, x, y, duration): duration = int(duration * 1000) - self.adb.shell(f'input swipe {x} {y} {x} {y} {duration}') + cmd = self._build_input_cmd(f'swipe {x} {y} {x} {y} {duration}') + self.adb.shell(cmd) diff --git a/core/device/screenshot/adb.py b/core/device/screenshot/adb.py index 8576053ac..485f05596 100644 --- a/core/device/screenshot/adb.py +++ b/core/device/screenshot/adb.py @@ -1,6 +1,10 @@ from adbutils import adb import cv2 import numpy as np +import socket +import threading + +from core.utils import host_platform_is_android class AdbScreenshot: @@ -9,9 +13,62 @@ def __init__(self, conn): self.logger = conn.logger self.adb = adb.device(self.serial) + self.display_id = None + + # Create a localhost listener on a random free port + if host_platform_is_android(): + self._server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self._server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self._server.bind(('127.0.0.1', 0)) + self._server.listen(1) + self._host, self._port = self._server.getsockname() + + def __del__(self): + if self._server: + try: + self._server.close() + except Exception: + pass + + def set_display_id(self, display_id): + self.display_id = display_id + + def _accept_and_read(self, out_container): + # Blocking accept and read all bytes until EOF + conn, addr = self._server.accept() + try: + chunks = [] + while True: + data = conn.recv(8192) + if not data: + break + chunks.append(data) + out_container.append(b''.join(chunks)) + finally: + try: + conn.close() + except Exception: + pass def screenshot(self): - data = self.adb.shell(['screencap', '-p'], stream=False, encoding=None) + if host_platform_is_android() and self.display_id: + # Binder IPC on Android cannot pass data larger than 500 KB, + # so we have to pipe the screenshot data through a socket. + # Thus we have to use sh to pipe the output of screencap to nc. + cmd = f'screencap -p -d {self.display_id} | nc 127.0.0.1 {self._port}' + out = [] + th = threading.Thread(target=self._accept_and_read, args=(out,), daemon=True) + th.start() + self.adb.shell(cmd, stream=False, encoding=None, timeout=10) + # Wait for accept thread to finish reading + th.join(10) + if th.is_alive(): + raise TimeoutError('Timed out waiting for screenshot data') + data = out[0] if out else b'' + else: + cmd = ['screencap', '-p'] + data = self.adb.shell(cmd, stream=False, encoding=None) + if len(data) < 500: self.logger.warning(f'Unexpected screenshot: {data}') image = np.frombuffer(data, np.uint8) diff --git a/core/geometry/parallelogram.py b/core/geometry/parallelogram.py index 8a9b5f0ca..b01e92576 100644 --- a/core/geometry/parallelogram.py +++ b/core/geometry/parallelogram.py @@ -34,4 +34,3 @@ def pixels(self): return y_min, x_bound_min, x_bound_max - diff --git a/core/geometry/triangle.py b/core/geometry/triangle.py index 8be64a545..9b0e9eb76 100644 --- a/core/geometry/triangle.py +++ b/core/geometry/triangle.py @@ -61,33 +61,5 @@ def _check_single_horizontal_line(self, v0, v1, x_min_list, x_max_list): x_min_list[idx] = min(x_min_list[idx], min(v0[0], v1[0])) x_max_list[idx] = max(x_max_list[idx], max(v0[0], v1[0])) -if __name__ == "__main__": - triangle = Triangle([(0, 0), (100, 100), (100, -100)]) - - x_min_list, x_max_list = triangle.x_bounds() - - print("y坐标 | 最小x | 最大x") - print("-------------------") - for i in range(len(x_min_list)): - y = i + int(triangle.v0[1]) - min_x = x_min_list[i] - max_x = x_max_list[i] - - - print(f"{y:4d} | {min_x:6.2f} | {max_x:6.2f}") - - # # draw pixels with opencv - # import cv2 - # import numpy as np - # img = np.zeros((1280, 720, 3), dtype=np.uint8) - # for y in range(len(x_min_list)): - # min_x = x_min_list[y] - # max_x = x_max_list[y] - # if min_x == float('inf') or max_x == float('-inf'): - # continue - # for x in range(int(min_x), int(max_x) + 1): - # img[y, x] = [255, 255, 255] # white pixel - # - # cv2.imshow("Triangle Pixels", img) - # cv2.waitKey(0) - + def pixels(self): + return self.v0[1], self.x_bounds() diff --git a/core/image.py b/core/image.py index 7e4d797a5..a613e3acf 100644 --- a/core/image.py +++ b/core/image.py @@ -25,8 +25,9 @@ def compare_image(self, name, threshold=0.8, rgb_diff=20): area = position.get_area(self.identifier, name) template_img = position.image_dic[self.identifier][name] ss_img = screenshot_cut(self, area) - if not compare_image_rgb(template_img, ss_img, rgb_diff=rgb_diff): - return False + if type(rgb_diff) is int: + if not compare_image_rgb(template_img, ss_img, rgb_diff=rgb_diff): + return False ss_img = cv2.resize(ss_img, (template_img.shape[1], template_img.shape[0]), interpolation=cv2.INTER_AREA) # ss_img and template_img have the same size, similarity is a float similarity = cv2.matchTemplate(ss_img, template_img, cv2.TM_CCOEFF_NORMED)[0][0] @@ -36,7 +37,6 @@ def compare_image(self, name, threshold=0.8, rgb_diff=20): def getImageByName(self, name): return position.image_dic[self.identifier][name] - def search_in_area(self, name, area=(0, 0, 1280, 720), threshold=0.8, rgb_diff=20, ret_max_val=False): # search image "name" in area, return upper left point of template image if found, else return False if name not in position.image_dic[self.identifier]: @@ -54,12 +54,12 @@ def search_in_area(self, name, area=(0, 0, 1280, 720), threshold=0.8, rgb_diff=2 else: return False - ss_img = img_cut(ss_img, - (max_loc[0], max_loc[1], max_loc[0] + template_img.shape[1], max_loc[1] + template_img.shape[0])) - if not compare_image_rgb(template_img, ss_img, rgb_diff=rgb_diff): - if ret_max_val: - return False, 0 # rgb diff not match, assume not found - return False + if type(rgb_diff) == int: + matched_area = img_cut(ss_img, (max_loc[0], max_loc[1], max_loc[0] + template_img.shape[1], max_loc[1] + template_img.shape[0])) + if not compare_image_rgb(template_img, matched_area, rgb_diff=rgb_diff): + if ret_max_val: + return False, 0 # rgb diff not match, assume not found + return False if max_val < threshold and ret_max_val: return False, max_val @@ -88,10 +88,11 @@ def search_image_in_area(self, image, area=(0, 0, 1280, 720), threshold=0.8, rgb _, max_val, _, max_loc = cv2.minMaxLoc(similarity) if max_val < threshold: return False - ss_img = img_cut(ss_img, - (max_loc[0], max_loc[1], max_loc[0] + template_img.shape[1], max_loc[1] + template_img.shape[0])) - if not compare_image_rgb(template_img, ss_img, rgb_diff=rgb_diff): - return False + + if type(rgb_diff) == int: + matched_area = img_cut(ss_img, (max_loc[0], max_loc[1], max_loc[0] + template_img.shape[1], max_loc[1] + template_img.shape[0])) + if not compare_image_rgb(template_img, matched_area, rgb_diff=rgb_diff): + return False upper_left = (int(max_loc[0] / self.ratio) + area[0], int(max_loc[1] / self.ratio) + area[1]) return upper_left @@ -135,7 +136,17 @@ def click_until_template_disappear(self, name, x, y, threshold=0.8, rgb_diff=20, self.update_screenshot_array() -def get_image_all_appear_position(self, image_template_name, search_area=(0, 0, 1280, 720), threshold=0.8): +def get_image_all_appear_position( + self, + image_template_name, + search_area=(0, 0, 1280, 720), + threshold=0.8, + rgb_diff=20, + deduplication_pixels = (5, 5) +): + """ + Get template image all appear position in search_area + """ if image_template_name not in position.image_dic[self.identifier]: return [] template_img = position.image_dic[self.identifier][image_template_name] # template image @@ -143,9 +154,17 @@ def get_image_all_appear_position(self, image_template_name, search_area=(0, 0, similarity = cv2.matchTemplate(ss_img, template_img, cv2.TM_CCOEFF_NORMED) loc = np.where(similarity >= threshold) res = list(zip(*loc[::-1])) + res = merge_nearby_coordinates(res, deduplication_pixels[0], deduplication_pixels[1]) ret = [] - for pt in res: - ret.append((pt[0] + search_area[0], pt[1] + search_area[1])) + for pos in res: + x_coords = [coord[0] for coord in pos] + y_coords = [coord[1] for coord in pos] + p = (int(median(x_coords)), int(median(y_coords))) + if type(rgb_diff) is int: + matched_area = img_cut(ss_img, (p[0], p[1], p[0] + template_img.shape[1], p[1] + template_img.shape[0])) + if not compare_image_rgb(template_img, matched_area, rgb_diff): + continue + ret.append((p[0] + search_area[0], p[1] + search_area[1])) return ret def resize_ss_image(self, area, interpolation=cv2.INTER_AREA): @@ -170,7 +189,8 @@ def swipe_search_target_str( ocr_candidates="", ocr_filter_score=0.2, first_retry_dir=0, - deduplication_pixels=(5, 5) + deduplication_pixels=(5, 5), + rgb_diff=20 ): temp = len(swipe_params) if temp < 4: @@ -205,7 +225,7 @@ def swipe_search_target_str( for time in range(max_swipe_times): if time != 0: # skip first screenshot self.update_screenshot_array() - all_positions = get_image_all_appear_position(self, name, search_area, threshold) + all_positions = get_image_all_appear_position(self, name, search_area, threshold, rgb_diff, deduplication_pixels) if len(all_positions) == 0: self.logger.warning("Didn't find target image, try swipe.") if retry_swipe_dir == 0: @@ -214,14 +234,10 @@ def swipe_search_target_str( self.swipe(*reversed_swipe_params) retry_swipe_dir ^= 1 continue - all_positions = merge_nearby_coordinates(all_positions, deduplication_pixels[0], deduplication_pixels[1]) max_idx = -1 # impossible value min_idx = len(possible_strs) all_strs = [] - for pos in all_positions: - x_coords = [coord[0] for coord in pos] - y_coords = [coord[1] for coord in pos] - p = (median(x_coords), median(y_coords)) + for p in all_positions: ocr_region = ( p[0] + ocr_region_offsets[0], p[1] + ocr_region_offsets[1], @@ -276,3 +292,32 @@ def swipe_search_target_str( else: self.swipe(*reversed_swipe_params) retry_swipe_dir ^= 1 + +def check_geometry_pixels(img, geometry, color_range, threshold=50): + """ + Check if the pixels in the geometry are within the color range. + + Returns: + True if no more than threshold pixels are outside the color range, False otherwise. + """ + y_min, x_min_list, x_max_list = geometry.pixels() + + cnt = 0 + + r_min, r_max, g_min, g_max, b_min, b_max = color_range + + for i in range(len(x_min_list)): + y = y_min + i + x0 = x_min_list[i] + x1 = x_max_list[i] + 1 + row = img[y, x0:x1] + valid = ( + (row[:, 0] >= r_min) & (row[:, 0] <= r_max) & + (row[:, 1] >= g_min) & (row[:, 1] <= g_max) & + (row[:, 2] >= b_min) & (row[:, 2] <= b_max) + ) + cnt += np.count_nonzero(~valid) + if cnt >= threshold: + return False + + return True diff --git a/core/ipc_manager.py b/core/ipc_manager.py index de0cfa049..c8bed6194 100644 --- a/core/ipc_manager.py +++ b/core/ipc_manager.py @@ -1,9 +1,12 @@ import cv2 import numpy as np import os -from multiprocessing import shared_memory +from core.utils import host_platform_is_android from core.exception import SharedMemoryError - +if host_platform_is_android(): + shared_memory = None +else: + from multiprocessing import shared_memory class SharedMemory: shm_map = {} diff --git a/core/ocr/baas_ocr_client/Client.py b/core/ocr/baas_ocr_client/Client.py index 7ba3e1467..be79ec077 100644 --- a/core/ocr/baas_ocr_client/Client.py +++ b/core/ocr/baas_ocr_client/Client.py @@ -10,12 +10,14 @@ from core.ipc_manager import SharedMemory from core.exception import SharedMemoryError, OcrInternalError +from core.ocr.baas_ocr_client.server_installer import SERVER_BIN_DIR, arch +from core.utils import host_platform_is_android class ServerConfig: def __init__(self): self.config = None - self.config_path = os.path.join(BaasOcrClient.server_folder_path, "config", "global_setting.json") + self.config_path = os.path.join(SERVER_BIN_DIR, "config", "global_setting.json") self.host = None self.port = None self.server_is_remote = False @@ -25,7 +27,7 @@ def __init__(self): def __init_config(self): if not os.path.exists(self.config_path): - default_config_file_path = os.path.join(BaasOcrClient.server_folder_path, "resource", "global_setting.json") + default_config_file_path = os.path.join(SERVER_BIN_DIR, "resource", "global_setting.json") if not os.path.exists(default_config_file_path): raise FileNotFoundError("Didn't find default config file.") os.mkdir(os.path.dirname(self.config_path)) @@ -46,22 +48,34 @@ def save(self): json.dump(self.config, f, indent=4) class BaasOcrClient: - server_folder_path = os.path.join(os.path.dirname(__file__), "bin") - executable_name = "BAAS_ocr_server" - if sys.platform == "win32": - executable_name += ".exe" - def __init__(self): - self.exe_path = os.path.join(self.server_folder_path, self.executable_name) - if not os.path.exists(self.exe_path): - raise FileNotFoundError("Didn't find ocr server executable.") + # android start from dll + if host_platform_is_android(): + import ctypes + self.dll_path = os.path.join(SERVER_BIN_DIR, "lib", arch) + if not os.path.exists(self.dll_path): + raise FileNotFoundError("Didn't find ocr server library dir. Expected at " + self.exe_path) + self.lib_cpp_shared = ctypes.CDLL(os.path.join(self.dll_path, "libc++_shared.so")) + self.lib_onnx = ctypes.CDLL(os.path.join(self.dll_path, "libonnxruntime.so")) + self.lib_opencv = ctypes.CDLL(os.path.join(self.dll_path, "libopencv_java4.so")) + self.lib_baas_ocr_server = ctypes.CDLL(os.path.join(self.dll_path, "libBAAS_ocr_server.so")) + self.started = False + # win / linux / mac start as executable + else: + executable_name = "BAAS_ocr_server" + if sys.platform == "win32": + executable_name += ".exe" + self.exe_path = os.path.join(SERVER_BIN_DIR, executable_name) + if not os.path.exists(self.exe_path): + raise FileNotFoundError("Didn't find ocr server executable. Expected at " + self.exe_path) self.config = ServerConfig() self.server_process = None self.clear_log() # clear log since time_distance days ago - def clear_log(self, time_distance=7): - log_folder_path = os.path.join(self.server_folder_path, "output") + @staticmethod + def clear_log(time_distance=7): + log_folder_path = os.path.join(SERVER_BIN_DIR, "output") if not os.path.exists(log_folder_path): return for name in os.listdir(log_folder_path): @@ -107,6 +121,27 @@ def disable_thread_pool(self): return requests.post(url) def start_server(self): + if host_platform_is_android(): + self.start_server_android() + else: + self.start_server_normal() + # wait for server start + for _ in range(0, 30): + try: + requests.get(self.config.base_url) + break + except requests.exceptions.ConnectionError as e: + if _ == 29: + raise RuntimeError("Fail to start ocr server. " + e.__str__()) + time.sleep(0.1) + + def start_server_android(self): + if self.started: + return + self.lib_baas_ocr_server.start_server(SERVER_BIN_DIR.encode("utf-8")) + self.started = True + + def start_server_normal(self): if self.server_process is not None: return # chmod +x BAAS_ocr_server @@ -115,7 +150,7 @@ def start_server(self): try: self.server_process = subprocess.Popen( self.exe_path, - cwd=self.server_folder_path, + cwd=SERVER_BIN_DIR, stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0, @@ -124,23 +159,23 @@ def start_server(self): except Exception: self.server_process = subprocess.Popen( [self.exe_path], - cwd=self.server_folder_path, + cwd=SERVER_BIN_DIR, stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0, text=True ) - # wait for server start - for _ in range(0, 30): - try: - requests.get(self.config.base_url) - break - except requests.exceptions.ConnectionError as e: - if _ == 29: - raise RuntimeError("Fail to start ocr server. " + e.__str__()) - time.sleep(0.1) def stop_server(self): + if host_platform_is_android(): + self.stop_server_android() + else: + self.stop_server_normal() + + def stop_server_android(self): + self.lib_baas_ocr_server.stop_server() + + def stop_server_normal(self): self.server_process.stdin.write("exit\n") self.server_process.stdin.flush() return_code = self.server_process.wait(10) diff --git a/core/ocr/baas_ocr_client/server_installer.py b/core/ocr/baas_ocr_client/server_installer.py index fb8f8cb44..c2b77691c 100644 --- a/core/ocr/baas_ocr_client/server_installer.py +++ b/core/ocr/baas_ocr_client/server_installer.py @@ -11,21 +11,23 @@ sys.stdout = io.TextIOWrapper(io.BytesIO()) # ================================ -import shutil import os +import shutil +import platform + from core.exception import OcrInternalError from dulwich import porcelain from dulwich.repo import Repo -import platform +from core.utils import host_platform_is_android -if sys.platform not in ['win32', 'linux', 'darwin']: - raise Exception("Ocr Unsupported platform " + sys.platform) +# [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate (_ssl.c:1006) +if host_platform_is_android(): + import certifi + os.environ['SSL_CERT_FILE'] = certifi.where() +SERVER_INSTALLER_DIR_PATH = None +SERVER_BIN_DIR = None OCR_SERVER_PREBUILD_URL = "https://gitee.com/pur1fy/baas_-cpp_prebuild.git" - -SERVER_INSTALLER_DIR_PATH = os.path.dirname(os.path.abspath(__file__)) -SERVER_BIN_DIR = os.path.join(SERVER_INSTALLER_DIR_PATH, 'bin') - branch = { 'win32': { 'amd64': 'windows-x64', @@ -36,14 +38,43 @@ 'darwin': { 'arm64': 'macos-arm64', }, + 'android': { + 'arm64-v8a': 'android-arm64-v8a', + 'x86_64': 'android-x86_64', + } } -branch = branch[sys.platform] -arch = platform.machine().lower() + +arch = None + +if not host_platform_is_android(): + if sys.platform not in ['win32', 'linux', 'darwin']: + raise Exception("Ocr Unsupported platform : " + sys.platform) + SERVER_INSTALLER_DIR_PATH = os.path.dirname(os.path.abspath(__file__)) + branch = branch[sys.platform] + arch = platform.machine().lower() + +# Android install config +else: + branch = branch['android'] + try: + from jnius import autoclass + PythonActivity = autoclass('org.kivy.android.PythonActivity') + activity = PythonActivity.mActivity + SERVER_INSTALLER_DIR_PATH = activity.getFilesDir().getAbsolutePath() + except Exception as e: + raise Exception("Failed to get Baas_ocr_server install path in android :" + e.__str__()) + + Build = autoclass('android.os.Build') + if Build.SUPPORTED_ABIS and len(Build.SUPPORTED_ABIS) > 0: + arch = Build.SUPPORTED_ABIS[0] + else: + arch = Build.CPU_ABI + +SERVER_BIN_DIR = os.path.join(SERVER_INSTALLER_DIR_PATH, 'bin') if arch not in branch: raise Exception("Unsupported machine architecture " + arch) branch = branch[arch] - def check_git(logger): if not os.path.exists(SERVER_BIN_DIR + '/.git'): clone_repo(logger) diff --git a/core/ocr/ocr.py b/core/ocr/ocr.py index 10d2434d9..dfe025bef 100644 --- a/core/ocr/ocr.py +++ b/core/ocr/ocr.py @@ -4,6 +4,7 @@ from core.exception import OcrInternalError from core.ocr.baas_ocr_client import Client +from core.ocr.baas_ocr_client.server_installer import SERVER_BIN_DIR class Baas_ocr: @@ -71,8 +72,7 @@ def get_region_res(self, baas, region, language='zh-cn', log_info="", candidates def get_region_raw_res(self, img, region, language='CN', ratio=1.0, candidates=""): img = self.get_area_img(img, region, ratio) - res = self.ocr(language, img, candidates, 1) - return res + return self.ocr(language, img, candidates, 1) @staticmethod def is_chinese_char(char): @@ -180,7 +180,7 @@ def test_models(self, language: list[str], _logger=None): logger.info("Test Ocr.") for lang in language: path = os.path.join( - Client.BaasOcrClient.server_folder_path, + SERVER_BIN_DIR, "resource", "ocr_models", "test_images", @@ -247,7 +247,7 @@ def ocr(self, shared_memory_name ) if response.status_code == 200: - return response.text + return json.loads(response.text) else: logger.error("Ocr Error: " + response.text) raise OcrInternalError("Ocr Error: " + response.text) diff --git a/core/picture.py b/core/picture.py index 19da290aa..bc75ce460 100644 --- a/core/picture.py +++ b/core/picture.py @@ -122,8 +122,8 @@ def co_detect( # avoid duplicated clicks break - self.logger.info(f"RGB feature: {rgb_feature} -> Click @ ({click[0]},{click[1]})") if click[0] >= 0 and click[1] >= 0: + self.logger.info(f"RGB feature: {rgb_feature} -> Click @ ({click[0]},{click[1]})") self.last_click_time = current_time self.click(click[0], click[1]) self.last_click_position = (click[0], click[1]) @@ -140,8 +140,8 @@ def co_detect( and self.last_click_position[0] == click[0] and self.last_click_position[1] == click[1] and self.last_click_name == img_feature): break - self.logger.info(f"Image feature: {img_feature} -> Click @ ({click[0]},{click[1]})") if click[0] >= 0 and click[1] >= 0: + self.logger.info(f"Image feature: {img_feature} -> Click @ ({click[0]},{click[1]})") self.last_click_time = feature_last_appear_time self.click(click[0], click[1]) self.last_click_position = (click[0], click[1]) diff --git a/core/utils.py b/core/utils.py index 62bf11825..4cf7a4477 100644 --- a/core/utils.py +++ b/core/utils.py @@ -1,14 +1,21 @@ import logging import sys import threading -from datetime import datetime, timedelta, timezone +import platform from typing import Union +from datetime import datetime, timedelta, timezone -from rich.console import Console -from rich.markup import escape +host_is_android = platform.system() == 'Android' or hasattr(sys, 'getandroidapilevel') -console = Console() +def host_platform_is_android(): + return host_is_android +if not host_platform_is_android(): + from rich.console import Console + from rich.markup import escape + console = Console() +else: + console = None def delay(wait=1): def decorator(func): @@ -51,7 +58,7 @@ def __init__(self, logger_signal): # logger signal is used to output log to logger box or other output self.logs = "" self.logger_signal = logger_signal - if not self.logger_signal: + if not self.logger_signal and not host_platform_is_android(): # if the logger signal is not configured, we use rich traceback then # to better display error messages in console from rich.traceback import install @@ -70,6 +77,9 @@ def __out__(self, message: str, level: int = 1, raw_print=False) -> None: :param level: log level(1: INFO, 2: WARNING, 3: ERROR, 4: CRITICAL) :return: None """ + # Keep original message for additional sinks (e.g. logcat) + raw_message = message + # If raw_print is True, output log to logger box if level < 1 or level > 4: raise ValueError("Invalid log level") @@ -78,6 +88,13 @@ def __out__(self, message: str, level: int = 1, raw_print=False) -> None: self.logs += message if self.logger_signal: self.logger_signal.emit(level, message) + # also send to logcat if on Android + try: + if host_platform_is_android(): + from core.android.log import logcat + logcat(str(raw_message), level='INFO') + except Exception: + pass return while len(logging.root.handlers) > 0: @@ -90,12 +107,32 @@ def __out__(self, message: str, level: int = 1, raw_print=False) -> None: if self.logger_signal is not None: self.logs += f"{levels_str[level - 1]} | {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} | {message}" self.logger_signal.emit(level, message) - else: + elif not host_platform_is_android(): console.print(f'[{levels_color[level - 1]}]' f'{levels_str[level - 1]} |' f' {datetime.now().strftime("%Y-%m-%d %H:%M:%S")} |' f' {escape(message)}[/]', soft_wrap=True) + # If running on Android, also send a plain-text copy to logcat + try: + if host_platform_is_android(): + from core.android.log import logcat + level_map = {1: 'INFO', 2: 'WARNING', 3: 'ERROR', 4: 'CRITICAL'} + logcat(str(raw_message), level=level_map.get(level, 'INFO')) + except Exception: + # Do not let logcat failures affect normal logging + pass + + # If running on Android, also send a plain-text copy to logcat + try: + if host_platform_is_android(): + from core.android.log import logcat + level_map = {1: 'INFO', 2: 'WARNING', 3: 'ERROR', 4: 'CRITICAL'} + logcat(str(raw_message), level=level_map.get(level, 'INFO')) + except Exception: + # Do not let logcat failures affect normal logging + pass + def info(self, message: str) -> None: """ :param message: log message diff --git a/deploy/android/README.md b/deploy/android/README.md new file mode 100644 index 000000000..c35c2f89c --- /dev/null +++ b/deploy/android/README.md @@ -0,0 +1,119 @@ +# Android Deployment +This directory contains the deployment script and Android wrapper for BaasOnAndroid. + +## Setup +You'll need a Linux environment to build BaasOnAndroid. MacOS is not tested. +// WIP + +## Build + +Building includes 3 stages: +``` +graph TD + %% One-time Setup Section + subgraph Setup [One-time Setup] + A[PySide Android Setup] --> B[BoA] + B --> C[PythonForAndroid] + C --> D[Gradle] + end + + %% Deployment Process Section + subgraph Deployment [pyside-deploy-android Process] + E[Download Qt deps & Generate Recipes] --> F[Copy files & Save to Git] + F --> G[Generate buildozer.spec] + + subgraph BuildSteps [Internal Build Loop] + H1[Install & Build Python deps] --> H2[Compile Python Source Code] + H2 --> H3[Collect .pyd & Resource Files] + H3 --> H4[Generate Android Project] + H4 --> H5[Patch build.gradle
'done by hook of BoA'] + end + + G --> H1 + H5 --> I[Build Java] + end + + %% Connection between Setup and Deployment + D -.-> E +``` + +```shell +# Setup env +sh ./deploy/android/setup_devcontainer.sh +source .venv/bin/activate +# Build +python deploy/android/build.py all +# python deploy/android/build.py gradle clean assembleDebug # Build Android only +``` + +## Install +For Docker/Devcontainer users, adb has already beed set up correctly. +Simply start adb server on Windows side and connect to target device. +Then adb commands start to will on the Linux side. + +```shell +# Windows +adb devices + +# Docker +adb install ./boa-0.1-arm64-v8a-debug.apk +``` + +## Debugging +### Logcat +All stdout and stderr will be redirected to logcat. You can use `adb logcat` to view the logs. + +```bash +adb logcat -s boa +``` + +Or you can install Android Studio to get a better experience. + +### Debug Python Code +To debug the Python code, you need VSCode and Python extension. + +> **NOTE**: PyCharm is not tested. + +1. First build and install BoA + +2. Add the following code at the very beginning of `main.py`: +```python +# main.py +import debugpy +debugpy.listen(5678, in_process_debug_adapter=True) +print("Waiting for debugger attach...") +debugpy.wait_for_client() # Comment this line if you don't want to block here +``` + +3. Set breakpoints in VSCode. You can also set breakpoints using code: +```python +breakpoint() +``` + +4. Start debugging using `Sync and Start BoA` configuration, or `Attach to BoA` if you want push files and start BoA manually. + +### Debug Java Code +1. Android Studio -> File -> Profile or Debug APK +2. Open any decompiled smali file from the left side Project View +3. Then you will see this tip "Disassembled classes.dex file. To set up breakpoints for debugging, please attach Kotlin/Java source files." +4. Attach `${workspace}/deploy/android/src`. +5. Set breakpoints and start debug + +> **Note** +> +> For WSL2 users, UNC paths can help you access files in WSL2 from Windows directly. +> Run `explorer.exe .` in WSL2 to find out the actual working directory path. + +> **Note 2** +> +> If you keep getting errors like `Error running 'Android Java Debugger (pid: 17964, debug port: 53357)' Unable to open debugger port (localhost:53357): java.io.IOException`, go to Edit Configurations -> Debugger -> Debug type, and change it to "Java Only" + +## Hot patch +Hot patch means to modify the Python code without rebuilding&reinstalling the APK. This +is extremely useful when no Java code is modified or Python dependencies are changes. + +```shell +# On host/WSL2 +python3 deploy/android/sync.py +python3 deploy/android/sync.py --dry-run # list changes only +``` \ No newline at end of file diff --git a/deploy/android/build.py b/deploy/android/build.py new file mode 100644 index 000000000..f70743ac1 --- /dev/null +++ b/deploy/android/build.py @@ -0,0 +1,204 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import os +import jinja2 +import shutil +import typer +import subprocess +from typing import List + +ARCH_MAP = { + 'arm64-v8a': { + 'wheel': 'aarch64.whl', + }, + 'x86_64': { + 'wheel': 'x86_64.whl', + } +} + +ARCH = 'arm64-v8a' +ANDROID_SDK_PATH = './.pyside6_android_deploy/android-sdk' +ANDROID_NDK_PATH = './.pyside6_android_deploy/android-ndk/android-ndk-r26b' +ICON_PATH = 'gui/assets/logo.png' +BIN_DIR = './bin' +MIN_API = 24 +BUILD_DIR = 'build' +JARS_PATH = [ + 'deploy/android/jar/PySide6/jar/Qt6Android.jar', + 'deploy/android/jar/PySide6/jar/Qt6AndroidBindings.jar' +] +PYSIDE6_WHEEL_BASIC_URL = 'https://download.qt.io/official_releases/QtForPython/pyside6/PySide6-6.9.2-6.9.2-cp311-cp311-android_' +SHIBOKEN6_WHEEL_BASIC_URL = 'https://download.qt.io/official_releases/QtForPython/shiboken6/shiboken6-6.9.0-6.9.0-cp311-cp311-android_' +GRADLE_WRAPPER = '.buildozer/android/platform/build-arm64-v8a/dists/boa/gradlew' + +def cwd_path(path: str): + return os.path.abspath(os.path.join(os.getcwd(), path)) + +def self_path(path: str): + return os.path.abspath(os.path.join(os.path.dirname(__file__), path)) + +def proj_path(path: str): + return os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', path)) + +def build_path(path: str): + return os.path.abspath(os.path.join(proj_path(BUILD_DIR), path)) + +def log(msg: str): + print(f'[{os.path.basename(__file__)}] {msg}') + +def render(src: str, dst: str, ctx: dict): + """Copy and/or render a file to a destination file.""" + if os.path.isfile(src): + # Ensure destination directory exists + dst_dir = os.path.dirname(dst) + if dst_dir and not os.path.exists(dst_dir): + os.makedirs(dst_dir) + if '.templ.' in src: + log(f'Rendering {src} to {dst}') + with open(src, 'r') as f: + template = jinja2.Template(f.read()) + with open(dst, 'w') as f: + f.write(template.render(ctx)) + else: + log(f'Copying {src} to {dst}') + shutil.copy(src, dst) + elif os.path.isdir(src): + if not os.path.exists(dst): + os.makedirs(dst) + for root, dirs, files in os.walk(src): + # Compute destination relative to the source directory to avoid absolute path join issues + rel_root = os.path.relpath(root, src) + for file in files: + src_file = os.path.join(root, file) + dst_root = dst if rel_root == '.' else os.path.join(dst, rel_root) + dst_file = os.path.join(dst_root, file) + if '.templ.' in dst_file: + dst_file = dst_file.replace('.templ', '') + render(src_file, dst_file, ctx) + else: + raise FileNotFoundError(f'{src} not found') + + +def _configure(): + if ARCH not in ARCH_MAP: + raise typer.BadParameter(f'Unsupported arch: {ARCH}') + arch_cfg = ARCH_MAP[ARCH] + log('Reading requirements...') + with open(self_path('requirements.txt'), 'r') as f: + requirements = f.read() + requirements = [line.strip() for line in requirements.splitlines() if line.strip()] + log(f'Requirements: {requirements}') + + log('Generating buildozer.spec...') + render(self_path('buildozer.templ.spec'), proj_path('buildozer.spec'), { + 'android_ndk_path': proj_path(ANDROID_NDK_PATH), + 'android_sdk_path': proj_path(ANDROID_SDK_PATH), + 'local_recipes_path': build_path('recipes'), + 'requirements': ', '.join(requirements), + 'icon_path': proj_path(ICON_PATH), + 'bin_dir': proj_path(BIN_DIR), + 'min_api': MIN_API, + 'jars_path': ', '.join([proj_path(path) for path in JARS_PATH]), + 'p4a_hook_path': self_path('p4a_hook.py'), + 'arch': ARCH + }) + + # Ensure build directory exists before downloading wheels and generating recipes + os.makedirs(build_path(''), exist_ok=True) + ensure_pyside6_shiboken6(arch_cfg) + +def ensure_pyside6_shiboken6(arch): + log('Check and download PySide6 wheels...') + wheel_tag = arch['wheel'] + + # download resource + pyside6_path = build_path(f'PySide6-6.9.2-6.9.2-cp311-cp311-android_{wheel_tag}') + pyside6_url = PYSIDE6_WHEEL_BASIC_URL + wheel_tag + download_artifact(pyside6_path, pyside6_url) + + shiboken6_path = build_path(f'shiboken6-6.9.0-6.9.0-cp311-cp311-android_{wheel_tag}') + shiboken6_url = SHIBOKEN6_WHEEL_BASIC_URL + wheel_tag + download_artifact(shiboken6_path, shiboken6_url) + + log('Generating recipes...') + if os.path.exists(build_path('recipes')): + log(f'Removing existing recipes...') + shutil.rmtree(build_path('recipes')) + render(self_path('recipes'), build_path('recipes'), { + 'pyside6_wheel_path': pyside6_path, + 'shiboken6_wheel_path': shiboken6_path + }) + +def download_artifact(path: str, url: str): + os.makedirs(os.path.dirname(path), exist_ok=True) + if not os.path.exists(path): + log(f'Downloading artifact from to {path}...') + os.system(f'curl -L {url} -o {path}') + +def _build(): + os.environ['ANDROIDSDK'] = proj_path(ANDROID_SDK_PATH) + os.environ['ANDROIDNDK'] = proj_path(ANDROID_NDK_PATH) + os.system(f'buildozer android debug') + +app = typer.Typer(help="Build helper for Android deployment") + +@app.command() +def build(): + """Run the build step (calls buildozer).""" + _build() + +@app.command() +def gradle(args: List[str] = typer.Argument(None, help="Arguments passed to gradlew")): + """Run the Gradle wrapper for the Android distribution. + + Any arguments after the command are passed directly to the `gradlew` wrapper. + If no arguments are provided, `build` is used. + Examples: + python deploy/android/build.py gradle clean build + python deploy/android/build.py gradle assembleRelease + """ + gradle_path = proj_path(GRADLE_WRAPPER) + if not os.path.exists(gradle_path): + raise FileNotFoundError(f'Gradle wrapper not found: {gradle_path}') + # Ensure it's executable + try: + st = os.stat(gradle_path).st_mode + os.chmod(gradle_path, st | 0o111) + except Exception: + pass + # Build command: default to 'build' when no args provided + cmd = [gradle_path] + (list(args) if args else ['build']) + log(f'Running gradle wrapper: {" ".join(cmd)}') + cwd = os.path.dirname(gradle_path) or proj_path('.') + result = subprocess.run(cmd, cwd=cwd) + if result.returncode != 0: + raise SystemExit(result.returncode) + + # copy output if exists + output = '.buildozer/android/platform/build-arm64-v8a/dists/boa/build/outputs/apk/debug/boa-debug.apk' + if os.path.exists(output): + dst = proj_path('./boa-debug.apk') + log(f'Copying APK to {dst}') + shutil.copy(output, dst) + +@app.command("all") +def all_cmd( + arch: str = typer.Option(ARCH, help="Android architecture (arm64-v8a, armeabi-v7a, x86_64)"), + android_sdk_path: str = typer.Option(ANDROID_SDK_PATH, help="Android SDK path"), + android_ndk_path: str = typer.Option(ANDROID_NDK_PATH, help="Android NDK path"), + bin_dir: str = typer.Option(BIN_DIR, help="Output apk directory"), + min_api: int = typer.Option(MIN_API, help="Minimum Android API level"), +): + """Run configure then build.""" + global ANDROID_SDK_PATH, ANDROID_NDK_PATH, ARCH, BIN_DIR, MIN_API + ARCH = arch + ANDROID_SDK_PATH = android_sdk_path + ANDROID_NDK_PATH = android_ndk_path + BIN_DIR = bin_dir + MIN_API = min_api + _configure() + build() + +if __name__ == '__main__': + app() diff --git a/deploy/android/build_in_docker.sh b/deploy/android/build_in_docker.sh new file mode 100755 index 000000000..f66cbe899 --- /dev/null +++ b/deploy/android/build_in_docker.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash + +set -euo pipefail + +IMAGE_NAME="boa-android-build" +DOCKERFILE="deploy/android/dockerfile" +WORKDIR="/work" + +if ! command -v docker >/dev/null 2>&1; then + echo "Docker not found. Please install Docker first." >&2 + exit 1 +fi + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + +if [[ ! -f "$ROOT_DIR/$DOCKERFILE" ]]; then + echo "Dockerfile not found: $ROOT_DIR/$DOCKERFILE" >&2 + exit 1 +fi + +if [[ "${1:-}" == "--build-only" ]]; then + docker build -f "$ROOT_DIR/$DOCKERFILE" -t "$IMAGE_NAME" "$ROOT_DIR" + exit 0 +fi + +docker build -f "$ROOT_DIR/$DOCKERFILE" -t "$IMAGE_NAME" "$ROOT_DIR" + +docker run --rm -it \ + -v "$ROOT_DIR":"$WORKDIR" \ + -w "$WORKDIR" \ + "$IMAGE_NAME" \ + bash -lc "export JAVA_HOME=/opt/java/openjdk-17; export PATH=\$JAVA_HOME/bin:\$PATH; sh deploy/android/setup_devcontainer.sh && . .venv/bin/activate && python deploy/android/build.py all" diff --git a/deploy/android/buildozer.templ.spec b/deploy/android/buildozer.templ.spec new file mode 100644 index 000000000..2fefa3230 --- /dev/null +++ b/deploy/android/buildozer.templ.spec @@ -0,0 +1,41 @@ +[app] +title = Baas on Android +package.name = boa +package.domain = top.qwq123 +source.dir = . +source.include_exts = py, png, jpg, kv, atlas, qml, js, json, txt, apk +source.exclude_patterns = deploy, boa-*.apk +android.whitelist = *.apk +version = 0.1 +# NOTE : requirements should be written in on line +requirements = {{ requirements }} +orientation = landscape +fullscreen = 1 +android.archs = {{ arch }} +android.allow_backup = True +android.minapi = {{ min_api }} +android.ndk_path = {{ android_ndk_path }} +android.sdk_path = {{ android_sdk_path }} +p4a.url = https://github.com/XcantloadX/python-for-android.git +p4a.branch = develop +p4a.commit = 4838a0a2455783ad511478e44bc661ad5153bb42 +p4a.bootstrap = qt +p4a.local_recipes = {{ local_recipes_path }} +android.permissions = android.permission.WRITE_EXTERNAL_STORAGE, android.permission.INTERNET, android.permission.INTERACT_ACROSS_USERS_FULL +android.add_jars = {{ jars_path }} +p4a.extra_args = --qt-libs=Core,Gui,Widgets --load-local-libs=plugins_platforms_qtforandroid --init-classes= +icon.filename = {{ icon_path }} +android.enable_androidx = True +android.entrypoint = org.baas.boa.MainActivity + +######### Shizuku ######### +android.gradle_dependencies = dev.rikka.shizuku:api:13.1.5,dev.rikka.shizuku:provider:13.1.5,androidx.core:core:1.6.0,org.jetbrains.kotlin:kotlin-stdlib:1.9.22 +# ps: androidx is not required by shizuku +p4a.hook = {{ p4a_hook_path }} +android.no-byte-compile-python = True + +[buildozer] +log_level = 2 +warn_on_root = 1 +bin_dir = {{ bin_dir }} + diff --git a/deploy/android/crash_manifest.xml b/deploy/android/crash_manifest.xml new file mode 100644 index 000000000..73a346f84 --- /dev/null +++ b/deploy/android/crash_manifest.xml @@ -0,0 +1,15 @@ + + + + + \ No newline at end of file diff --git a/deploy/android/docker_build.sh b/deploy/android/docker_build.sh new file mode 100755 index 000000000..f66cbe899 --- /dev/null +++ b/deploy/android/docker_build.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash + +set -euo pipefail + +IMAGE_NAME="boa-android-build" +DOCKERFILE="deploy/android/dockerfile" +WORKDIR="/work" + +if ! command -v docker >/dev/null 2>&1; then + echo "Docker not found. Please install Docker first." >&2 + exit 1 +fi + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + +if [[ ! -f "$ROOT_DIR/$DOCKERFILE" ]]; then + echo "Dockerfile not found: $ROOT_DIR/$DOCKERFILE" >&2 + exit 1 +fi + +if [[ "${1:-}" == "--build-only" ]]; then + docker build -f "$ROOT_DIR/$DOCKERFILE" -t "$IMAGE_NAME" "$ROOT_DIR" + exit 0 +fi + +docker build -f "$ROOT_DIR/$DOCKERFILE" -t "$IMAGE_NAME" "$ROOT_DIR" + +docker run --rm -it \ + -v "$ROOT_DIR":"$WORKDIR" \ + -w "$WORKDIR" \ + "$IMAGE_NAME" \ + bash -lc "export JAVA_HOME=/opt/java/openjdk-17; export PATH=\$JAVA_HOME/bin:\$PATH; sh deploy/android/setup_devcontainer.sh && . .venv/bin/activate && python deploy/android/build.py all" diff --git a/deploy/android/dockerfile b/deploy/android/dockerfile new file mode 100644 index 000000000..f2bc74f2f --- /dev/null +++ b/deploy/android/dockerfile @@ -0,0 +1,61 @@ +FROM mcr.microsoft.com/devcontainers/python:3.9 + +# Switch to root user to install packages +USER root + +# Change apt source to Aliyun mirror +RUN curl -sSL https://linuxmirrors.cn/main.sh -o /tmp/main.sh && \ + bash /tmp/main.sh \ + --source mirrors.aliyun.com \ + --protocol http \ + --use-intranet-source false \ + --install-epel true \ + --backup true \ + --upgrade-software false \ + --clean-cache false \ + --ignore-backup-tips && \ + rm -f /tmp/main.sh + +# Install system dependencies for OpenCV and other packages +RUN apt-get update && apt-get install -y --no-install-recommends \ + ffmpeg \ + libsm6 \ + libxext6 \ + libegl1 \ + patchelf \ + ant \ + autoconf \ + automake \ + ccache \ + cmake \ + g++ \ + gcc \ + git \ + lbzip2 \ + libffi-dev \ + libltdl-dev \ + libtool \ + libssl-dev \ + make \ + patch \ + pkg-config \ + sudo \ + unzip \ + wget \ + zip \ + adb \ + && rm -rf /var/lib/apt/lists/* + +# Install OpenJDK 17 +ENV JAVA_HOME=/opt/java/openjdk-17 +RUN mkdir -p ${JAVA_HOME} && \ + curl -L "https://api.adoptium.net/v3/binary/latest/17/ga/linux/x64/jdk/hotspot/normal/adoptium" | \ + tar -xz --strip-components=1 -C ${JAVA_HOME} +ENV PATH=$JAVA_HOME/bin:$PATH + +# Preinstall build backends often needed by PEP517 sdists during p4a builds +RUN python -m pip install --no-cache-dir --upgrade pip && \ + python -m pip install --no-cache-dir scikit-build-core + +# Switch back to the default user +USER vscode diff --git a/deploy/android/jar/PySide6/jar/Qt6Android.jar b/deploy/android/jar/PySide6/jar/Qt6Android.jar new file mode 100644 index 000000000..f4fa863aa Binary files /dev/null and b/deploy/android/jar/PySide6/jar/Qt6Android.jar differ diff --git a/deploy/android/jar/PySide6/jar/Qt6AndroidBindings.jar b/deploy/android/jar/PySide6/jar/Qt6AndroidBindings.jar new file mode 100644 index 000000000..3bf0ff300 Binary files /dev/null and b/deploy/android/jar/PySide6/jar/Qt6AndroidBindings.jar differ diff --git a/deploy/android/jar/PySide6/jar/Qt6AndroidBluetooth.jar b/deploy/android/jar/PySide6/jar/Qt6AndroidBluetooth.jar new file mode 100644 index 000000000..33562d04f Binary files /dev/null and b/deploy/android/jar/PySide6/jar/Qt6AndroidBluetooth.jar differ diff --git a/deploy/android/jar/PySide6/jar/Qt6AndroidMultimedia.jar b/deploy/android/jar/PySide6/jar/Qt6AndroidMultimedia.jar new file mode 100644 index 000000000..44bb17561 Binary files /dev/null and b/deploy/android/jar/PySide6/jar/Qt6AndroidMultimedia.jar differ diff --git a/deploy/android/jar/PySide6/jar/Qt6AndroidNetwork.jar b/deploy/android/jar/PySide6/jar/Qt6AndroidNetwork.jar new file mode 100644 index 000000000..dea99ca14 Binary files /dev/null and b/deploy/android/jar/PySide6/jar/Qt6AndroidNetwork.jar differ diff --git a/deploy/android/jar/PySide6/jar/Qt6AndroidNetworkInformationBackend.jar b/deploy/android/jar/PySide6/jar/Qt6AndroidNetworkInformationBackend.jar new file mode 100644 index 000000000..0d21ebc08 Binary files /dev/null and b/deploy/android/jar/PySide6/jar/Qt6AndroidNetworkInformationBackend.jar differ diff --git a/deploy/android/jar/PySide6/jar/Qt6AndroidNfc.jar b/deploy/android/jar/PySide6/jar/Qt6AndroidNfc.jar new file mode 100644 index 000000000..8bef36115 Binary files /dev/null and b/deploy/android/jar/PySide6/jar/Qt6AndroidNfc.jar differ diff --git a/deploy/android/jar/PySide6/jar/Qt6AndroidQuick.jar b/deploy/android/jar/PySide6/jar/Qt6AndroidQuick.jar new file mode 100644 index 000000000..b5815fa63 Binary files /dev/null and b/deploy/android/jar/PySide6/jar/Qt6AndroidQuick.jar differ diff --git a/deploy/android/jar/PySide6/jar/QtAndroidPositioning.jar b/deploy/android/jar/PySide6/jar/QtAndroidPositioning.jar new file mode 100644 index 000000000..d7e939c0b Binary files /dev/null and b/deploy/android/jar/PySide6/jar/QtAndroidPositioning.jar differ diff --git a/deploy/android/jar/PySide6/jar/QtAndroidTextToSpeech.jar b/deploy/android/jar/PySide6/jar/QtAndroidTextToSpeech.jar new file mode 100644 index 000000000..67e68502d Binary files /dev/null and b/deploy/android/jar/PySide6/jar/QtAndroidTextToSpeech.jar differ diff --git a/deploy/android/jar/PySide6/jar/QtAndroidWebView.jar b/deploy/android/jar/PySide6/jar/QtAndroidWebView.jar new file mode 100644 index 000000000..0a843503e Binary files /dev/null and b/deploy/android/jar/PySide6/jar/QtAndroidWebView.jar differ diff --git a/deploy/android/p4a_hook.py b/deploy/android/p4a_hook.py new file mode 100644 index 000000000..5d2f3daee --- /dev/null +++ b/deploy/android/p4a_hook.py @@ -0,0 +1,82 @@ +import os +import shutil +from pythonforandroid.logger import info + +# 脚本所在目录 +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) + +PATCH_MANIFEST_PATH = os.path.join(BASE_DIR, 'shizuku_provider.xml') +FRAG_BUILD_GRADLE_PATH = os.path.join(BASE_DIR, 'patches', 'frag.build.gradle') +JAVA_SRC_PATH = os.path.join(BASE_DIR, 'src', 'main', 'java') +AIDL_SRC_PATH = os.path.join(BASE_DIR, 'src', 'main', 'aidl') +CRASH_MANIFEST_PATH = os.path.join(BASE_DIR, 'crash_manifest.xml') +PROVIDER_PATHS_SRC = os.path.join(BASE_DIR, 'provider_paths.xml') + +def _read(path: str) -> str: + with open(path, 'r', encoding='utf-8') as f: + return f.read() + +def _write(path: str, content: str): + with open(path, 'w', encoding='utf-8') as f: + f.write(content) + +def _patch_build_gradle(build_gradle_path: str): + info(f'Patching build.gradle: {build_gradle_path}') + content = _read(build_gradle_path) + build_gradle = _read(FRAG_BUILD_GRADLE_PATH) + content = content.replace('android {', 'android {\n' + build_gradle) + _write(build_gradle_path, content) + info(f'Successfully patched {build_gradle_path} with frag build.gradle.') + +def _patch_manifest(manifest_path: str): + info(f'Patching manifest: {manifest_path}') + content = _read(manifest_path) + + # 读取 Shizuku 配置 + inject_content = "" + inject_content += _read(PATCH_MANIFEST_PATH) + + # [NEW 2] 追加读取 CrashActivity 和 Provider 配置 + inject_content += "\n" + _read(CRASH_MANIFEST_PATH) + + marker = '' + if marker in content: + # 简单防重复 + if 'CrashActivity' not in content: + parts = content.rsplit(marker, 1) + content = f'{parts[0]}{inject_content}\n{marker}{parts[1]}' + else: + info("CrashActivity already present in manifest, skipping injection.") + else: + raise ValueError(f"Could not find '{marker}' in {manifest_path}") + + _write(manifest_path, content) + info(f'Successfully patched {manifest_path}.') + +def _copy_src(): + # 复制 Java + info(f'Copying java src...') + shutil.copytree(JAVA_SRC_PATH, './src/main/java', dirs_exist_ok=True) + + # 复制 AIDL + info(f'Copying aidl src...') + shutil.copytree(AIDL_SRC_PATH, './src/main/aidl', dirs_exist_ok=True) + + # [NEW 3] 复制 FileProvider 的资源文件 (关键修复步骤) + # 目标路径必须是 src/main/res/xml/ + target_res_xml = os.path.join(os.getcwd(), 'src', 'main', 'res', 'xml') + if not os.path.exists(target_res_xml): + os.makedirs(target_res_xml) + shutil.copy(PROVIDER_PATHS_SRC, target_res_xml) + info(f'Successfully copied provider_paths.xml to {target_res_xml}') + +def before_apk_assemble(toolchain): + info(f'pwd: {os.getcwd()}') + manifest1 = os.path.join('.', 'AndroidManifest.xml') + manifest2 = os.path.join('.', 'src', 'main', 'AndroidManifest.xml') + build_gradle = os.path.join('.', 'build.gradle') + + _patch_build_gradle(build_gradle) + _patch_manifest(manifest1) + _patch_manifest(manifest2) + _copy_src() \ No newline at end of file diff --git a/deploy/android/patches/android_config.py.patch b/deploy/android/patches/android_config.py.patch new file mode 100644 index 000000000..3b7273f0d --- /dev/null +++ b/deploy/android/patches/android_config.py.patch @@ -0,0 +1,45 @@ +# Patch for: pyside6-android-deploy +# This patch fixes a bug caused by unexpected gc. + +diff --git a/.venv/lib/python3.9/site-packages/PySide6/scripts/deploy_lib/android/android_config.py b/.venv/lib/python3.9/site-packages/PySide6/scripts/deploy_lib/android/android_config.py +--- a/.venv/lib/python3.9/site-packages/PySide6/scripts/deploy_lib/android/android_config.py ++++ b/.venv/lib/python3.9/site-packages/PySide6/scripts/deploy_lib/android/android_config.py +@@ -43,9 +43,10 @@ + if android_data.wheel_shiboken: + self.wheel_shiboken = android_data.wheel_shiboken + else: + wheel_shiboken_temp = self.get_value("android", "wheel_shiboken") + if not wheel_shiboken_temp: + raise RuntimeError("[DEPLOY] Unable to find shiboken6 Android wheel") + self.wheel_shiboken = Path(wheel_shiboken_temp).resolve() + ++ self._wheel_pyside_archive = zipfile.ZipFile(self.wheel_pyside) + self.ndk_path = None + if android_data.ndk_path: + # from cli + self.ndk_path = android_data.ndk_path + else: +@@ -109,8 +110,10 @@ + self._mode = self.get_value("buildozer", "mode") + +- self.qt_libs_path: zipfile.Path = find_qtlibs_in_wheel(wheel_pyside=self.wheel_pyside) ++ qt_libs_path_temp = find_qtlibs_in_wheel(wheel_pyside=self.wheel_pyside) ++ self.qt_libs_path: zipfile.Path = zipfile.Path(self._wheel_pyside_archive, ++ qt_libs_path_temp.at) + logging.info(f"[DEPLOY] Qt libs path inside wheel: {str(self.qt_libs_path)}") + + if self.get_value("qt", "modules"): + self.modules = self.get_value("qt", "modules").split(",") + else: +@@ -300,8 +303,8 @@ + if not llvm_readobj.exists(): + raise FileNotFoundError(f"[DEPLOY] {llvm_readobj} does not exist." + "Finding Qt dependencies failed") + +- archive = zipfile.ZipFile(self.wheel_pyside) ++ archive = self._wheel_pyside_archive + lib_path_suffix = Path(str(self.qt_libs_path)).relative_to(self.wheel_pyside) + + with tempfile.TemporaryDirectory() as tmpdir: + archive.extractall(tmpdir) + qt_libs_tmpdir = Path(tmpdir) / lib_path_suffix diff --git a/deploy/android/patches/android_deploy.py.patch b/deploy/android/patches/android_deploy.py.patch new file mode 100644 index 000000000..91d2faf19 --- /dev/null +++ b/deploy/android/patches/android_deploy.py.patch @@ -0,0 +1,36 @@ +# Patch for: pyside6-android-deploy +# This patch fixes a bug caused by incorrect usage of lru_cache. + +--- a/.venv/lib/python3.9/site-packages/PySide6/scripts/android_deploy.py 2025-10-01 06:06:05.892239333 +0000 ++++ b/.venv/lib/python3.9/site-packages/PySide6/scripts/android_deploy.py 2025-09-30 12:16:41.894541117 +0000 +@@ -1,3 +1,30 @@ ++import functools ++import collections ++# monkey patch 以禁用 lru_cache,因为代码中不正确使用了 lru_cache,导致抛出异常 ++def _lru_cache(maxsize=128, typed=False): ++ def decorating_function(user_function): ++ def wrapper(*args, **kwargs): ++ return user_function(*args, **kwargs) ++ def cache_clear(): ++ pass ++ CacheInfo = collections.namedtuple('CacheInfo', 'hits misses maxsize currsize') ++ def cache_info(): ++ return CacheInfo(0, 0, maxsize, 0) ++ def cache_parameters(): ++ return {'maxsize': maxsize, 'typed': typed} ++ wrapper.cache_clear = cache_clear ++ wrapper.cache_info = cache_info ++ wrapper.cache_parameters = cache_parameters ++ wrapper.__wrapped__ = user_function ++ return functools.update_wrapper(wrapper, user_function) ++ if callable(maxsize) and typed is False: ++ user_function = maxsize ++ maxsize = None ++ return decorating_function(user_function) ++ return decorating_function ++ ++functools.lru_cache = _lru_cache ++ + # Copyright (C) 2023 The Qt Company Ltd. + # SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only + diff --git a/deploy/android/patches/frag.build.gradle b/deploy/android/patches/frag.build.gradle new file mode 100644 index 000000000..222b82654 --- /dev/null +++ b/deploy/android/patches/frag.build.gradle @@ -0,0 +1,4 @@ +buildFeatures { + buildConfig true // 开启BuildConfig类的生成 + aidl true // 启用aidl +} \ No newline at end of file diff --git a/deploy/android/provider_paths.xml b/deploy/android/provider_paths.xml new file mode 100644 index 000000000..30b8d54ba --- /dev/null +++ b/deploy/android/provider_paths.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/deploy/android/recipes/PySide6/__init__.templ.py b/deploy/android/recipes/PySide6/__init__.templ.py new file mode 100644 index 000000000..78804e0f0 --- /dev/null +++ b/deploy/android/recipes/PySide6/__init__.templ.py @@ -0,0 +1,64 @@ +# Copyright (C) 2023 The Qt Company Ltd. +# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only + +import shutil +import zipfile +from pathlib import Path + +from pythonforandroid.logger import info +from pythonforandroid.recipe import PythonRecipe + + +class PySideRecipe(PythonRecipe): + version = '6.9.2' + wheel_path = '{{ pyside6_wheel_path }}' + depends = ["shiboken6"] + call_hostpython_via_targetpython = False + install_in_hostpython = False + + def build_arch(self, arch): + """Unzip the wheel and copy into site-packages of target""" + + info("Copying libc++_shared.so from SDK to be loaded on startup") + libcpp_path = f"{self.ctx.ndk.sysroot_lib_dir}/{arch.command_prefix}/libc++_shared.so" + shutil.copyfile(libcpp_path, Path(self.ctx.get_libs_dir(arch.arch)) / "libc++_shared.so") + + info(f"Installing {self.name} into site-packages") + with zipfile.ZipFile(self.wheel_path, "r") as zip_ref: + info("Unzip wheels and copy into {}".format(self.ctx.get_python_install_dir(arch.arch))) + zip_ref.extractall(self.ctx.get_python_install_dir(arch.arch)) + + lib_dir = Path(f"{self.ctx.get_python_install_dir(arch.arch)}/PySide6/Qt/lib") + + info("Copying Qt libraries to be loaded on startup") + shutil.copytree(lib_dir, self.ctx.get_libs_dir(arch.arch), dirs_exist_ok=True) + shutil.copyfile(lib_dir.parent.parent / "libpyside6.abi3.so", + Path(self.ctx.get_libs_dir(arch.arch)) / "libpyside6.abi3.so") + + # noqa: E999 + shutil.copyfile(lib_dir.parent.parent / f"QtCore.abi3.so", + Path(self.ctx.get_libs_dir(arch.arch)) / "QtCore.abi3.so") + # noqa: E999 + # noqa: E999 + shutil.copyfile(lib_dir.parent.parent / f"QtGui.abi3.so", + Path(self.ctx.get_libs_dir(arch.arch)) / "QtGui.abi3.so") + # noqa: E999 + # noqa: E999 + shutil.copyfile(lib_dir.parent.parent / f"QtWidgets.abi3.so", + Path(self.ctx.get_libs_dir(arch.arch)) / "QtWidgets.abi3.so") + # noqa: E999 + # noqa: E999 + + # noqa: E999 + + # noqa: E999 + plugin_path = (lib_dir.parent / "plugins" / "platforms" / + f"libplugins_platforms_qtforandroid_{arch.arch}.so") + if plugin_path.exists(): + shutil.copyfile(plugin_path, + (Path(self.ctx.get_libs_dir(arch.arch)) / + f"libplugins_platforms_qtforandroid_{arch.arch}.so")) + # noqa: E999 + + +recipe = PySideRecipe() \ No newline at end of file diff --git a/deploy/android/recipes/README.md b/deploy/android/recipes/README.md new file mode 100644 index 000000000..3d6ce586a --- /dev/null +++ b/deploy/android/recipes/README.md @@ -0,0 +1,2 @@ +# Android Deployment/Recipes +This directory contains the recipes for buildozer, which are used to cross-compile C/C++ dependencies. \ No newline at end of file diff --git a/deploy/android/recipes/decorator/__init__.py b/deploy/android/recipes/decorator/__init__.py new file mode 100644 index 000000000..b13c2e2a0 --- /dev/null +++ b/deploy/android/recipes/decorator/__init__.py @@ -0,0 +1,13 @@ +from pythonforandroid.recipe import PyProjectRecipe + + +class DecoratorPyRecipe(PyProjectRecipe): + version = '4.2.1' + url = 'https://pypi.python.org/packages/source/d/decorator/decorator-{version}.tar.gz' + url = 'https://github.com/micheles/decorator/archive/{version}.tar.gz' + depends = ['setuptools'] + site_packages_name = 'decorator' + call_hostpython_via_targetpython = False + + +recipe = DecoratorPyRecipe() diff --git a/deploy/android/recipes/numpy/__init__.py b/deploy/android/recipes/numpy/__init__.py new file mode 100644 index 000000000..9d71979e4 --- /dev/null +++ b/deploy/android/recipes/numpy/__init__.py @@ -0,0 +1,58 @@ +from pythonforandroid.recipe import Recipe, MesonRecipe +from pythonforandroid.logger import error +from os.path import join +import shutil + +NUMPY_NDK_MESSAGE = "In order to build numpy, you must set minimum ndk api (minapi) to `24`.\n" + + +class NumpyRecipe(MesonRecipe): + version = 'v1.26.5' + url = 'git+https://github.com/numpy/numpy' + hostpython_prerequisites = ["Cython>=3.0.6"] # meson does not detects venv's cython + extra_build_args = ['-Csetup-args=-Dblas=none', '-Csetup-args=-Dlapack=none'] + need_stl_shared = True + + def get_recipe_meson_options(self, arch): + options = super().get_recipe_meson_options(arch) + # Custom python is required, so that meson + # gets libs and config files properly + options["binaries"]["python"] = self.ctx.python_recipe.python_exe + options["binaries"]["python3"] = self.ctx.python_recipe.python_exe + options["properties"]["longdouble_format"] = "IEEE_DOUBLE_LE" if arch.arch in ["armeabi-v7a", "x86"] else "IEEE_QUAD_LE" + return options + + def get_recipe_env(self, arch, **kwargs): + env = super().get_recipe_env(arch, **kwargs) + + # _PYTHON_HOST_PLATFORM declares that we're cross-compiling + # and avoids issues when building on macOS for Android targets. + env["_PYTHON_HOST_PLATFORM"] = arch.command_prefix + + # NPY_DISABLE_SVML=1 allows numpy to build for non-AVX512 CPUs + # See: https://github.com/numpy/numpy/issues/21196 + env["NPY_DISABLE_SVML"] = "1" + env["TARGET_PYTHON_EXE"] = join(Recipe.get_recipe( + "python3", self.ctx).get_build_dir(arch.arch), "android-build", "python") + return env + + def download_if_necessary(self): + # NumPy requires complex math functions which were added in api 24 + if self.ctx.ndk_api < 24: + error(NUMPY_NDK_MESSAGE) + exit(1) + super().download_if_necessary() + + def build_arch(self, arch): + super().build_arch(arch) + self.restore_hostpython_prerequisites(["cython"]) + + def get_hostrecipe_env(self, arch): + env = super().get_hostrecipe_env(arch) + env['RANLIB'] = shutil.which('ranlib') + env.setdefault("LDFLAGS", "") + env["LDFLAGS"] += " -lm" + return env + + +recipe = NumpyRecipe() \ No newline at end of file diff --git a/deploy/android/recipes/opencv/__init__.py b/deploy/android/recipes/opencv/__init__.py new file mode 100644 index 000000000..da342da83 --- /dev/null +++ b/deploy/android/recipes/opencv/__init__.py @@ -0,0 +1,165 @@ +from os.path import join, exists, abspath +import sh +from pythonforandroid.recipe import NDKRecipe +from pythonforandroid.toolchain import ( + current_directory, + shprint, +) +from multiprocessing import cpu_count + + +class OpenCVRecipe(NDKRecipe): + ''' + .. versionchanged:: 0.7.1 + rewrote recipe to support the python bindings (cv2.so) and enable the + build of most of the libraries of the opencv's package, so we can + process images, videos, objects, photos... + ''' + # NOTE: https://github.com/kivy/python-for-android/issues/3203 + version = '4.5.1' + url = 'https://github.com/opencv/opencv/archive/{version}.zip' + depends = ['numpy'] + patches = ['patches/p4a_build.patch'] + generated_libraries = [ + 'libopencv_features2d.so', + 'libopencv_imgproc.so', + 'libopencv_stitching.so', + 'libopencv_calib3d.so', + 'libopencv_flann.so', + 'libopencv_ml.so', + 'libopencv_videoio.so', + 'libopencv_core.so', + 'libopencv_highgui.so', + 'libopencv_objdetect.so', + 'libopencv_video.so', + 'libopencv_dnn.so', + 'libopencv_imgcodecs.so', + 'libopencv_photo.so' + ] + + def get_lib_dir(self, arch): + return join(self.get_build_dir(arch.arch), 'build', 'lib', arch.arch) + + def get_recipe_env(self, arch): + env = super(OpenCVRecipe, self).get_recipe_env(arch) + env['ANDROID_NDK'] = self.ctx.ndk_dir + env['ANDROID_SDK'] = self.ctx.sdk_dir + return env + + def build_arch(self, arch): + build_dir = join(self.get_build_dir(arch.arch), 'build') + shprint(sh.mkdir, '-p', build_dir) + with current_directory(build_dir): + env = self.get_recipe_env(arch) + + python_major = self.ctx.python_recipe.version[0] + python_include_root = self.ctx.python_recipe.include_root(arch.arch) + python_site_packages = self.ctx.get_site_packages_dir(arch) + python_link_root = self.ctx.python_recipe.link_root(arch.arch) + python_link_version = self.ctx.python_recipe.major_minor_version_string + if 'python3' in self.ctx.python_recipe.name: + python_link_version += 'm' + python_library = join(python_link_root, + 'libpython{}.so'.format(python_link_version)) + python_include_numpy = join(python_site_packages, + 'numpy', 'core', 'include') + + # Compute linker flag that matches the actual libpython present + # Prefer libpython3.xm.so, fallback to libpython3.x.so + # Extract base version (e.g. 3.11 from 3.11m) + base_version = self.ctx.python_recipe.major_minor_version_string + lib_with_m = join(python_link_root, f'libpython{base_version}m.so') + lib_without_m = join(python_link_root, f'libpython{base_version}.so') + if exists(lib_with_m): + python_link_flag = f'-lpython{base_version}m' + elif exists(lib_without_m): + python_link_flag = f'-lpython{base_version}' + else: + # fallback to previously computed path/name + python_link_flag = f'-lpython{python_link_version}' + + shprint(sh.cmake, + # NOTE: https://github.com/kivy/python-for-android/issues/2302#issuecomment-769901090 + '-DANDROID_SDK_TOOLS_VERSION=34.0.0', + '-DANDROID_PROJECTS_SUPPORT_GRADLE=ON', + + '-DP4A=ON', + '-DANDROID_ABI={}'.format(arch.arch), + '-DANDROID_STANDALONE_TOOLCHAIN={}'.format(self.ctx.ndk_dir), + '-DANDROID_NATIVE_API_LEVEL={}'.format(self.ctx.ndk_api), + '-DANDROID_EXECUTABLE={}/tools/android'.format(env['ANDROID_SDK']), + + '-DCMAKE_TOOLCHAIN_FILE={}'.format( + join(self.ctx.ndk_dir, 'build', 'cmake', + 'android.toolchain.cmake')), + # Make the linkage with our python library, otherwise we + # will get dlopen error when trying to import cv2's module. + '-DCMAKE_SHARED_LINKER_FLAGS=-L{path} {link_flag}'.format( + path=python_link_root, + link_flag=python_link_flag), + + '-DBUILD_WITH_STANDALONE_TOOLCHAIN=ON', + # Force to build as shared libraries the cv2's dependant + # libs or we will not be able to link with our python + '-DBUILD_SHARED_LIBS=ON', + '-DBUILD_STATIC_LIBS=OFF', + + # Disable some opencv's features + '-DBUILD_opencv_java=OFF', + # '-DBUILD_opencv_imgproc=OFF', + # '-DBUILD_opencv_flann=OFF', + '-DBUILD_TESTS=OFF', + '-DBUILD_PERF_TESTS=OFF', + '-DENABLE_TESTING=OFF', + '-DBUILD_EXAMPLES=OFF', + '-DBUILD_ANDROID_EXAMPLES=OFF', + + # Force to only build our version of python + '-DBUILD_OPENCV_PYTHON{major}=ON'.format(major=python_major), + '-DBUILD_OPENCV_PYTHON{major}=OFF'.format( + major='2' if python_major == '3' else '3'), + + # Force to install the `cv2.so` library directly into + # python's site packages (otherwise the cv2's loader fails + # on finding the cv2.so library) + '-DOPENCV_SKIP_PYTHON_LOADER=ON', + '-DOPENCV_PYTHON{major}_INSTALL_PATH={site_packages}'.format( + major=python_major, site_packages=python_site_packages), + + # Define python's paths for: exe, lib, includes, numpy... + '-DPYTHON_DEFAULT_EXECUTABLE={}'.format(self.ctx.hostpython), + '-DPYTHON{major}_EXECUTABLE={host_python}'.format( + major=python_major, host_python=self.ctx.hostpython), + '-DPYTHON{major}_INCLUDE_PATH={include_path}'.format( + major=python_major, include_path=python_include_root), + '-DPYTHON{major}_LIBRARIES={python_lib}'.format( + major=python_major, python_lib=python_library), + '-DPYTHON{major}_NUMPY_INCLUDE_DIRS={numpy_include}'.format( + major=python_major, numpy_include=python_include_numpy), + '-DPYTHON{major}_PACKAGES_PATH={site_packages}'.format( + major=python_major, site_packages=python_site_packages), + + self.get_build_dir(arch.arch), + _env=env) + + # 在生成阶段后,修正 CMake 生成的链接命令中的无效标志 "-version" + link_txt = 'modules/python3/CMakeFiles/opencv_python3.dir/link.txt' + assert exists(link_txt) + with open(link_txt, 'r+', encoding='utf-8') as f: + content = f.read() + if '-version' in content: + # assert False, abspath(link_txt) + content = content.replace('-version', ' ') + f.seek(0) + f.write(content) + f.truncate() + + shprint(sh.make, '-j' + str(cpu_count()), 'opencv_python' + python_major) + # Install python bindings (cv2.so) + shprint(sh.cmake, '-DCOMPONENT=python', '-P', './cmake_install.cmake') + # Copy third party shared libs that we need in our final apk + sh.cp('-a', sh.glob('./lib/{}/lib*.so'.format(arch.arch)), + self.ctx.get_libs_dir(arch.arch)) + + +recipe = OpenCVRecipe() \ No newline at end of file diff --git a/deploy/android/recipes/opencv/patches/p4a_build.patch b/deploy/android/recipes/opencv/patches/p4a_build.patch new file mode 100644 index 000000000..afbb80d9d --- /dev/null +++ b/deploy/android/recipes/opencv/patches/p4a_build.patch @@ -0,0 +1,33 @@ +This patch allow that the opencv's build command correctly detects our version +of python, so we can successfully build the python bindings (cv2.so) +--- opencv-4.0.1/cmake/OpenCVDetectPython.cmake.orig 2018-12-22 08:03:30.000000000 +0100 ++++ opencv-4.0.1/cmake/OpenCVDetectPython.cmake 2019-01-31 11:33:10.896502978 +0100 +@@ -175,7 +175,7 @@ if(NOT ${found}) + endif() + endif() + +- if(NOT ANDROID AND NOT IOS) ++ if(P4A OR NOT ANDROID AND NOT IOS) + if(CMAKE_HOST_UNIX) + execute_process(COMMAND ${_executable} -c "from distutils.sysconfig import *; print(get_python_lib())" + RESULT_VARIABLE _cvpy_process +@@ -244,7 +244,7 @@ if(NOT ${found}) + OUTPUT_STRIP_TRAILING_WHITESPACE) + endif() + endif() +- endif(NOT ANDROID AND NOT IOS) ++ endif(P4A OR NOT ANDROID AND NOT IOS) + endif() + + # Export return values +--- opencv-4.0.1/modules/python/CMakeLists.txt.orig 2018-12-22 08:03:30.000000000 +0100 ++++ opencv-4.0.1/modules/python/CMakeLists.txt 2019-01-31 11:47:17.100494908 +0100 +@@ -3,7 +3,7 @@ + # ---------------------------------------------------------------------------- + if(DEFINED OPENCV_INITIAL_PASS) # OpenCV build + +-if(ANDROID OR APPLE_FRAMEWORK OR WINRT) ++if(ANDROID AND NOT P4A OR APPLE_FRAMEWORK OR WINRT) + ocv_module_disable_(python2) + ocv_module_disable_(python3) + return() \ No newline at end of file diff --git a/deploy/android/recipes/shiboken6/__init__.templ.py b/deploy/android/recipes/shiboken6/__init__.templ.py new file mode 100644 index 000000000..001c3372f --- /dev/null +++ b/deploy/android/recipes/shiboken6/__init__.templ.py @@ -0,0 +1,31 @@ +# Copyright (C) 2023 The Qt Company Ltd. +# SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only + +import shutil +import zipfile +from pathlib import Path + +from pythonforandroid.logger import info +from pythonforandroid.recipe import PythonRecipe + + +class ShibokenRecipe(PythonRecipe): + version = '6.9.2' + wheel_path = '{{ shiboken6_wheel_path }}' + + call_hostpython_via_targetpython = False + install_in_hostpython = False + + def build_arch(self, arch): + ''' Unzip the wheel and copy into site-packages of target''' + info('Installing {} into site-packages'.format(self.name)) + with zipfile.ZipFile(self.wheel_path, 'r') as zip_ref: + info('Unzip wheels and copy into {}'.format(self.ctx.get_python_install_dir(arch.arch))) + zip_ref.extractall(self.ctx.get_python_install_dir(arch.arch)) + + lib_dir = Path(f"{self.ctx.get_python_install_dir(arch.arch)}/shiboken6") + shutil.copyfile(lib_dir / "libshiboken6.abi3.so", + Path(self.ctx.get_libs_dir(arch.arch)) / "libshiboken6.abi3.so") + + +recipe = ShibokenRecipe() \ No newline at end of file diff --git a/deploy/android/requirements-build.txt b/deploy/android/requirements-build.txt new file mode 100644 index 000000000..ee22fde1e --- /dev/null +++ b/deploy/android/requirements-build.txt @@ -0,0 +1,15 @@ +# Runtime +# Runtime dependencies are listed in deploy/android/requirements.txt instead. + +# Development +# You will need these if you want code completion or debugging, +# otherwise no need to install them. +qtpy +typer + +# Packaging +# aqtinstall==3.3.0 # Qt downloader. Not used if you perfer pre-built qt wheels instead of compiling from source. +buildozer==1.5.0 +pyside6==6.7.3 # Contains .so files which will be used in buildozer +Jinja2==3 # For configuration files generation +cython diff --git a/deploy/android/requirements.txt b/deploy/android/requirements.txt new file mode 100644 index 000000000..3c74bc0dd --- /dev/null +++ b/deploy/android/requirements.txt @@ -0,0 +1,46 @@ +python3 +pyjnius +shiboken6 +PySide6 +numpy +opencv +debugpy +git+https://github.com/XcantloadX/pyadbserver.git@e5d20876a21f859209caf9cd5b29b22088499961 +typing_extensions +adbutils==2.2.1 +apkutils2==1.0.0 +cached-property==1.5.2 +certifi==2025.8.3 +charset-normalizer==3.4.3 +cigam==0.0.3 +colorama==0.4.6 +darkdetect==0.8.0 +decorator +deprecated==1.2.18 +deprecation==2.1.0 +dulwich==0.24.2 +filelock==3.19.1 +idna==3.10 +logzero==1.7.0 +lxml==6.0.2 +packaging==20.9 +pillow==11.3.0 +progress==1.6.1 +py==1.11.0 +pyelftools==0.32 +pyparsing==3.2.5 +pyside6-fluent-widgets==1.2.0 +pysidesix-frameless-window==0.7.3 +qtpy==2.4.3 +requests==2.32.5 +retry==0.9.2 +shiboken6==6.4.2 +six==1.17.0 +tomli==2.2.1 +tomli-w==1.2.0 +typing-extensions==4.15.0 +uiautomator2==2.16.23 +urllib3==2.5.0 +whichcraft==0.6.1 +wrapt==1.17.3 +xmltodict==1.0.2 diff --git a/deploy/android/setup_devcontainer.sh b/deploy/android/setup_devcontainer.sh new file mode 100644 index 000000000..0a9f8841f --- /dev/null +++ b/deploy/android/setup_devcontainer.sh @@ -0,0 +1,92 @@ +#!/bin/bash + +set -e +set -u +set -o pipefail + +# ########## Download SDK ########## +# # check if android-sdk installed +# if [ -d .sdk/android-sdk/cmdline-tools ]; then +# echo "Android SDK already installed." +# else +# # download sdk +# echo "Downloading Android SDK..." +# mkdir -p .sdk/android-sdk +# cd .sdk/android-sdk +# wget https://dl.google.com/android/repository/commandlinetools-linux-13114758_latest.zip +# unzip commandlinetools-linux-13114758_latest.zip +# rm commandlinetools-linux-13114758_latest.zip +# cd ../.. +# # accept sdk license +# yes | .sdk/android-sdk/cmdline-tools/bin/sdkmanager --licenses +# fi + +# ########## Download NDK ########## +# # check if ndk installed +# if [ -d .sdk/android-ndk ]; then +# echo "Android NDK already installed." +# else +# # download ndk +# echo "Downloading Android NDK..." +# mkdir -p .sdk/android-ndk +# cd .sdk/android-ndk +# wget https://dl.google.com/android/repository/android-ndk-r25c-linux.zip +# unzip android-ndk-r25c-linux.zip +# rm android-ndk-r25c-linux.zip +# cd ../.. +# fi + +########## Setup PATH ########## +export ANDROIDSDK="$(pwd)/.sdk/android-sdk/cmdline-tools/bin" +export ANDROIDNDK="$(pwd)/.sdk/android-ndk/android-ndk-r25b" +# Link cache directory to workspace to avoid re-downloading +mkdir -p .pyside6_android_deploy +ln -sfn "$(pwd)/.pyside6_android_deploy" ~/.pyside6_android_deploy + +########## Create Python virtual environment ########## + +echo "Creating Python virtual environment..." +python -m venv .venv +echo "Activating virtual environment..." +. .venv/bin/activate +echo "Upgrading pip..." +python -m pip install --upgrade pip + +if [ -f requirements-android.txt ]; then + echo "Installing dependencies from requirements-android.txt..." + pip install -r requirements-android.txt + echo "Dependencies installed." +else + echo "requirements-android.txt not found, skipping dependency installation." +fi + +########## Setup pyside6-android-deploy ########## +# if [ -d ~/.pyside6-android-deploy ]; then +# echo "pyside6-android-deploy already installed." +# else +# git clone https://code.qt.io/pyside/pyside-setup +# cd pyside-setup +# git checkout 6.7 +# pip install -r requirements.txt +# pip install -r tools/cross_compile_android/requirements.txt +# python tools/cross_compile_android/main.py --download-only --skip-update --auto-accept-license +# cd .. +# fi + +# check pyside wheels +cd .pyside6_android_deploy +if [ ! -f pyside6-*.whl ]; then + echo "pyside wheels not found, downloading..." + wget https://download.qt.io/official_releases/QtForPython/pyside6/PySide6-6.9.2-6.9.2-cp311-cp311-android_aarch64.whl +fi +if [ ! -f shiboken6-*.whl ]; then + echo "shiboken6 wheels not found, downloading..." + wget https://download.qt.io/official_releases/QtForPython/shiboken6/shiboken6-6.9.0-6.9.0-cp311-cp311-android_aarch64.whl +fi +cd .. + +echo "Environment setup complete." + +########## Setup ADB ########## +# Prioritize IPv4 over IPv6 for ADB connection +sudo sed -i 's/#precedence ::ffff:0:0\/96 100/precedence ::ffff:0:0\/96 100/' /etc/gai.conf diff --git a/deploy/android/shizuku_provider.xml b/deploy/android/shizuku_provider.xml new file mode 100644 index 000000000..9b76e1159 --- /dev/null +++ b/deploy/android/shizuku_provider.xml @@ -0,0 +1,7 @@ + \ No newline at end of file diff --git a/deploy/android/src/main/aidl/org/baas/boa/CommandResult.aidl b/deploy/android/src/main/aidl/org/baas/boa/CommandResult.aidl new file mode 100644 index 000000000..babfad5f4 --- /dev/null +++ b/deploy/android/src/main/aidl/org/baas/boa/CommandResult.aidl @@ -0,0 +1,5 @@ +package org.baas.boa; + +parcelable CommandResult; + + diff --git a/deploy/android/src/main/aidl/org/baas/boa/FsReadResult.aidl b/deploy/android/src/main/aidl/org/baas/boa/FsReadResult.aidl new file mode 100644 index 000000000..f74d34da0 --- /dev/null +++ b/deploy/android/src/main/aidl/org/baas/boa/FsReadResult.aidl @@ -0,0 +1,5 @@ +package org.baas.boa; + +parcelable FsReadResult; + + diff --git a/deploy/android/src/main/aidl/org/baas/boa/FsStat.aidl b/deploy/android/src/main/aidl/org/baas/boa/FsStat.aidl new file mode 100644 index 000000000..dbc2f882e --- /dev/null +++ b/deploy/android/src/main/aidl/org/baas/boa/FsStat.aidl @@ -0,0 +1,5 @@ +package org.baas.boa; + +parcelable FsStat; + + diff --git a/deploy/android/src/main/aidl/org/baas/boa/IStreamCallback.aidl b/deploy/android/src/main/aidl/org/baas/boa/IStreamCallback.aidl new file mode 100644 index 000000000..0f6922411 --- /dev/null +++ b/deploy/android/src/main/aidl/org/baas/boa/IStreamCallback.aidl @@ -0,0 +1,17 @@ +package org.baas.boa; + +/** + * 用于接收 execStream 的流式输出回调。 + */ +interface IStreamCallback { + /** 标准输出每行 */ + void onStdout(String line); + + /** 标准错误每行 */ + void onStderr(String line); + + /** 进程结束,返回退出码 */ + void onDone(int exitCode); +} + + diff --git a/deploy/android/src/main/aidl/org/baas/boa/IUserService.aidl b/deploy/android/src/main/aidl/org/baas/boa/IUserService.aidl new file mode 100644 index 000000000..5da7178d8 --- /dev/null +++ b/deploy/android/src/main/aidl/org/baas/boa/IUserService.aidl @@ -0,0 +1,41 @@ +package org.baas.boa; +import org.baas.boa.CommandResult; +import org.baas.boa.IStreamCallback; +import org.baas.boa.FsStat; +import org.baas.boa.FsReadResult; + +interface IUserService { + + /** + * Shizuku服务端定义的销毁方法 + */ + void destroy() = 16777114; + + /** + * 自定义的退出方法 + */ + void exit() = 1; + + /** + * 执行命令 + */ + CommandResult exec(in String[] command) = 2; + + /** + * 以流式输出方式执行命令 + */ + void execStream(in String[] command, IStreamCallback callback) = 3; + + // ========= 文件系统 ========= + FsStat fsStat(String path) = 10; + String[] fsList(String path) = 11; + FsReadResult fsRead(String path) = 12; + void fsWrite(String path, String content, boolean append) = 13; + void fsDelete(String path, boolean recursive) = 14; + void fsMkdirs(String path) = 15; + void fsMove(String src, String dst, boolean replace) = 16; + + // ========= 包管理 ========= + void pmInstall(String apkPath) = 20; // 通过 pm install -r + void pmUninstall(String packageName) = 21; +} diff --git a/deploy/android/src/main/java/org/baas/boa/CommandResult.java b/deploy/android/src/main/java/org/baas/boa/CommandResult.java new file mode 100644 index 000000000..ad7699206 --- /dev/null +++ b/deploy/android/src/main/java/org/baas/boa/CommandResult.java @@ -0,0 +1,37 @@ +package org.baas.boa; + +import android.os.Parcel; +import android.os.Parcelable; + +public class CommandResult implements Parcelable { + public int exitCode; + public String stdout; + public String stderr; + + public CommandResult() {} + + protected CommandResult(Parcel in) { + exitCode = in.readInt(); + stdout = in.readString(); + stderr = in.readString(); + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeInt(exitCode); + dest.writeString(stdout); + dest.writeString(stderr); + } + + @Override + public int describeContents() { return 0; } + + public static final Creator CREATOR = new Creator() { + @Override + public CommandResult createFromParcel(Parcel in) { return new CommandResult(in); } + @Override + public CommandResult[] newArray(int size) { return new CommandResult[size]; } + }; +} + + diff --git a/deploy/android/src/main/java/org/baas/boa/FsReadResult.java b/deploy/android/src/main/java/org/baas/boa/FsReadResult.java new file mode 100644 index 000000000..c0de18364 --- /dev/null +++ b/deploy/android/src/main/java/org/baas/boa/FsReadResult.java @@ -0,0 +1,37 @@ +package org.baas.boa; + +import android.os.Parcel; +import android.os.Parcelable; + +public class FsReadResult implements Parcelable { + public boolean isBase64; + public String text; // 当 isBase64=false 时有效 + public byte[] bytes; // 当 isBase64=true 时有效 + + public FsReadResult() {} + + protected FsReadResult(Parcel in) { + isBase64 = in.readByte() != 0; + text = in.readString(); + bytes = in.createByteArray(); + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeByte((byte) (isBase64 ? 1 : 0)); + dest.writeString(text); + dest.writeByteArray(bytes); + } + + @Override + public int describeContents() { return 0; } + + public static final Creator CREATOR = new Creator() { + @Override + public FsReadResult createFromParcel(Parcel in) { return new FsReadResult(in); } + @Override + public FsReadResult[] newArray(int size) { return new FsReadResult[size]; } + }; +} + + diff --git a/deploy/android/src/main/java/org/baas/boa/FsStat.java b/deploy/android/src/main/java/org/baas/boa/FsStat.java new file mode 100644 index 000000000..fea62b82e --- /dev/null +++ b/deploy/android/src/main/java/org/baas/boa/FsStat.java @@ -0,0 +1,49 @@ +package org.baas.boa; + +import android.os.Parcel; +import android.os.Parcelable; + +public class FsStat implements Parcelable { + public boolean exists; + public boolean isDir; + public long size; + public long mtime; + public Integer mode; // Optional + public Integer uid; // Optional + public Integer gid; // Optional + + public FsStat() {} + + protected FsStat(Parcel in) { + exists = in.readByte() != 0; + isDir = in.readByte() != 0; + size = in.readLong(); + mtime = in.readLong(); + mode = (Integer) (in.readByte() == 0 ? null : in.readInt()); + uid = (Integer) (in.readByte() == 0 ? null : in.readInt()); + gid = (Integer) (in.readByte() == 0 ? null : in.readInt()); + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeByte((byte) (exists ? 1 : 0)); + dest.writeByte((byte) (isDir ? 1 : 0)); + dest.writeLong(size); + dest.writeLong(mtime); + if (mode == null) { dest.writeByte((byte)0); } else { dest.writeByte((byte)1); dest.writeInt(mode); } + if (uid == null) { dest.writeByte((byte)0); } else { dest.writeByte((byte)1); dest.writeInt(uid); } + if (gid == null) { dest.writeByte((byte)0); } else { dest.writeByte((byte)1); dest.writeInt(gid); } + } + + @Override + public int describeContents() { return 0; } + + public static final Creator CREATOR = new Creator() { + @Override + public FsStat createFromParcel(Parcel in) { return new FsStat(in); } + @Override + public FsStat[] newArray(int size) { return new FsStat[size]; } + }; +} + + diff --git a/deploy/android/src/main/java/org/baas/boa/MainActivity.java b/deploy/android/src/main/java/org/baas/boa/MainActivity.java new file mode 100644 index 000000000..85ffebbc7 --- /dev/null +++ b/deploy/android/src/main/java/org/baas/boa/MainActivity.java @@ -0,0 +1,147 @@ +package org.baas.boa; + +import android.app.Activity; +import android.app.AlertDialog; +import android.content.DialogInterface; +import android.content.pm.PackageManager; +import android.os.Bundle; +import android.util.Log; +import android.view.Gravity; +import android.view.ViewGroup; +import android.widget.FrameLayout; +import android.widget.TextView; +import java.io.File; +import java.util.List; +import java.util.ArrayList; + +import org.kivy.android.PythonActivity; +import org.baas.boa.crash.CustomCrashHandler; +import rikka.shizuku.Shizuku; + +public class MainActivity extends PythonActivity { + + private static final String TAG = "MainActivity"; + private static final int SHIZUKU_PERMISSION_CODE = 1024; + private boolean isDebugMode = false; + private String mDebugRoot = null; + private static Shizuku.OnRequestPermissionResultListener shizukuListener = null; + + @Override + public void onCreate(Bundle savedInstanceState) { + Thread.setDefaultUncaughtExceptionHandler(new CustomCrashHandler(this)); + super.onCreate(savedInstanceState); + } + + @Override + public String getEntryPoint(String search_dir) { + /* Get the main file (.pyc|.py) depending on if we + * have a compiled version or not. + */ + List entryPoints = new ArrayList(); + entryPoints.add("android_main.pyc"); // python 3 compiled files + for (String value : entryPoints) { + File mainFile = new File(search_dir + "/" + value); + if (mainFile.exists()) { + return value; + } + } + return "android_main.py"; + } + + public static void requestShizukuPermission() { + requestShizukuPermission(PythonActivity.mActivity); + } + + private static void requestShizukuPermission(final Activity activity) { + if (activity == null) { + return; + } + + try { + if (Shizuku.checkSelfPermission() == PackageManager.PERMISSION_GRANTED) { + clearShizukuListener(); + return; + } + + if (Shizuku.isPreV11()) { + return; + } + + if (shizukuListener == null) { + shizukuListener = new Shizuku.OnRequestPermissionResultListener() { + @Override + public void onRequestPermissionResult(int requestCode, int grantResult) { + if (grantResult == PackageManager.PERMISSION_GRANTED) { + clearShizukuListener(); + return; + } + showAllowDialog(activity); + } + }; + Shizuku.addRequestPermissionResultListener(shizukuListener); + } + + Shizuku.requestPermission(SHIZUKU_PERMISSION_CODE); + } catch (Exception e) { + showFatalDialog(activity, e); + } + } + + private static void clearShizukuListener() { + if (shizukuListener != null) { + try { + Shizuku.removeRequestPermissionResultListener(shizukuListener); + } catch (Exception ignored) { + } + shizukuListener = null; + } + } + + private static void showAllowDialog(final Activity activity) { + activity.runOnUiThread(() -> { + try { + new AlertDialog.Builder(activity) + .setMessage("请允许 Shizuku 权限申请。") + .setCancelable(false) + .setPositiveButton("确定", new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + dialog.dismiss(); + try { + Shizuku.requestPermission(SHIZUKU_PERMISSION_CODE); + } catch (Exception e) { + showFatalDialog(activity, e); + } + } + }) + .show(); + } catch (Exception e) { + showFatalDialog(activity, e); + } + }); + } + + private static void showFatalDialog(final Activity activity, final Exception error) { + activity.runOnUiThread(() -> { + try { + new AlertDialog.Builder(activity) + .setMessage(error.getMessage() + "\n请检查 Shizuku 服务是否正常运行。") + .setCancelable(false) + .setPositiveButton("确定", new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + dialog.dismiss(); + clearShizukuListener(); + activity.finishAffinity(); + System.exit(1); + } + }) + .show(); + } catch (Exception e) { + clearShizukuListener(); + activity.finishAffinity(); + System.exit(1); + } + }); + } +} diff --git a/deploy/android/src/main/java/org/baas/boa/UserService.java b/deploy/android/src/main/java/org/baas/boa/UserService.java new file mode 100644 index 000000000..62733fcdd --- /dev/null +++ b/deploy/android/src/main/java/org/baas/boa/UserService.java @@ -0,0 +1,261 @@ +package org.baas.boa; + +import android.os.RemoteException; +import android.util.Base64; + +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.OutputStreamWriter; +import java.nio.charset.StandardCharsets; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +public class UserService extends IUserService.Stub { + + @Override + public void destroy() throws RemoteException { + System.exit(0); + } + + @Override + public void exit() throws RemoteException { + destroy(); + } + + @Override + public CommandResult exec(String[] command) throws RemoteException { + final StringBuilder out = new StringBuilder(); + final StringBuilder err = new StringBuilder(); + int exit = -1; + try { + Process process = Runtime.getRuntime().exec(command); + + Thread tOut = new Thread(() -> { + try (BufferedReader br = new BufferedReader(new InputStreamReader(process.getInputStream()))) { + String line; + while ((line = br.readLine()) != null) { + out.append(line).append("\n"); + } + } catch (IOException ignored) {} + }); + Thread tErr = new Thread(() -> { + try (BufferedReader br = new BufferedReader(new InputStreamReader(process.getErrorStream()))) { + String line; + while ((line = br.readLine()) != null) { + err.append(line).append("\n"); + } + } catch (IOException ignored) {} + }); + tOut.start(); + tErr.start(); + exit = process.waitFor(); + tOut.join(); + tErr.join(); + } catch (Exception e) { + err.append("exec error: ").append(e.getMessage()); + } + CommandResult res = new CommandResult(); + res.exitCode = exit; + res.stdout = out.toString(); + res.stderr = err.toString(); + return res; + } + + @Override + public void execStream(String[] command, IStreamCallback callback) throws RemoteException { + new Thread(() -> { + int exit = -1; + try { + Process process = Runtime.getRuntime().exec(command); + + Thread tOut = new Thread(() -> { + try (BufferedReader br = new BufferedReader(new InputStreamReader(process.getInputStream()))) { + String line; + while ((line = br.readLine()) != null) { + try { callback.onStdout(line); } catch (RemoteException ignored) {} + } + } catch (IOException ignored) {} + }); + Thread tErr = new Thread(() -> { + try (BufferedReader br = new BufferedReader(new InputStreamReader(process.getErrorStream()))) { + String line; + while ((line = br.readLine()) != null) { + try { callback.onStderr(line); } catch (RemoteException ignored) {} + } + } catch (IOException ignored) {} + }); + tOut.start(); + tErr.start(); + exit = process.waitFor(); + tOut.join(); + tErr.join(); + } catch (Exception e) { + try { callback.onStderr("execStream error: " + e.getMessage()); } catch (RemoteException ignored) {} + } finally { + try { callback.onDone(exit); } catch (RemoteException ignored) {} + } + }).start(); + } + + // ================= File System ================= + @Override + public FsStat fsStat(String path) throws RemoteException { + File f = new File(path); + FsStat st = new FsStat(); + st.exists = f.exists(); + st.isDir = f.isDirectory(); + st.size = f.exists() ? f.length() : 0; + st.mtime = f.exists() ? f.lastModified() : 0; + st.mode = null; // Cannot get directly + st.uid = null; + st.gid = null; + return st; + } + + @Override + public String[] fsList(String path) throws RemoteException { + File dir = new File(path); + if (!dir.isDirectory()) return new String[0]; + String[] names = dir.list(); + return names != null ? names : new String[0]; + } + + @Override + public FsReadResult fsRead(String path) throws RemoteException { + try { + File file = new File(path); + if (!file.exists()) throw new RemoteException("File not exists: " + path); + byte[] data = new byte[(int)Math.min(file.length(), Integer.MAX_VALUE)]; + try (FileInputStream fis = new FileInputStream(file)) { + int offset = 0; + int read; + while ((read = fis.read(data, offset, data.length - offset)) > 0) { + offset += read; + if (offset == data.length) break; + } + if (offset < data.length) { + byte[] resized = new byte[offset]; + System.arraycopy(data, 0, resized, 0, offset); + data = resized; + } + } + String text = new String(data, StandardCharsets.UTF_8); + boolean looksText = true; + for (int i = 0; i < text.length(); i++) { + int ch = text.charAt(i); + if (!(ch == 9 || ch == 10 || ch == 13 || (ch >= 32 && ch < 0xD800) || (ch >= 0xE000 && ch <= 0xFFFD))) { + looksText = false; break; + } + } + FsReadResult res = new FsReadResult(); + if (looksText) { + res.isBase64 = false; + res.text = text; + res.bytes = null; + } else { + res.isBase64 = true; + res.text = null; + res.bytes = data; + } + return res; + } catch (IOException e) { + throw new RemoteException(e.getMessage()); + } + } + + @Override + public void fsWrite(String path, String content, boolean append) throws RemoteException { + try { + boolean isB64 = content != null && content.startsWith("b64:"); + if (isB64) { + byte[] bytes = Base64.decode(content.substring(4), Base64.DEFAULT); + try (FileOutputStream fos = new FileOutputStream(path, append)) { + fos.write(bytes); + } + } else { + try (BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(path, append), StandardCharsets.UTF_8))) { + bw.write(content == null ? "" : content); + } + } + } catch (IOException e) { + throw new RemoteException(e.getMessage()); + } + } + + @Override + public void fsDelete(String path, boolean recursive) throws RemoteException { + File f = new File(path); + if (!f.exists()) return; + if (f.isDirectory() && recursive) { + deleteRecursive(f); + } else if (!f.delete()) { + throw new RemoteException("Failed to delete: " + path); + } + } + + private void deleteRecursive(File f) throws RemoteException { + File[] list = f.listFiles(); + if (list != null) { + for (File c : list) { + if (c.isDirectory()) deleteRecursive(c); + else if (!c.delete()) throw new RemoteException("Failed to delete: " + c.getAbsolutePath()); + } + } + if (!f.delete()) throw new RemoteException("Failed to delete: " + f.getAbsolutePath()); + } + + @Override + public void fsMkdirs(String path) throws RemoteException { + File d = new File(path); + if (!d.exists() && !d.mkdirs()) { + throw new RemoteException("Failed to mkdirs: " + path); + } + } + + @Override + public void fsMove(String src, String dst, boolean replace) throws RemoteException { + File s = new File(src); + File d = new File(dst); + if (!s.exists()) throw new RemoteException("Source not exists: " + src); + if (d.exists()) { + if (!replace) throw new RemoteException("Dest exists: " + dst); + if (d.isDirectory()) { + deleteRecursive(d); + } else if (!d.delete()) { + throw new RemoteException("Failed to delete dest: " + dst); + } + } + boolean renamed = s.renameTo(d); + if (!renamed) { + throw new RemoteException("Failed to move: " + src + " -> " + dst); + } + } + + // ================= Package Manager ================= + @Override + public void pmInstall(String apkPath) throws RemoteException { + String cmd = "pm install -r \"" + apkPath.replace("\"", "\\\"") + "\""; + CommandResult res = exec(new String[]{"/system/bin/sh", "-c", cmd}); + String out = (res.stdout == null ? "" : res.stdout); + String err = (res.stderr == null ? "" : res.stderr); + boolean ok = (out.toLowerCase().contains("success") || err.toLowerCase().contains("success")); + if (!ok) throw new RemoteException("pm install failed: " + out + (err.isEmpty()?"":"\n"+err)); + } + + @Override + public void pmUninstall(String packageName) throws RemoteException { + String cmd = "pm uninstall " + packageName; + CommandResult res = exec(new String[]{"/system/bin/sh", "-c", cmd}); + String out = (res.stdout == null ? "" : res.stdout); + String err = (res.stderr == null ? "" : res.stderr); + boolean ok = (out.toLowerCase().contains("success") || err.toLowerCase().contains("success")); + if (!ok) throw new RemoteException("pm uninstall failed: " + out + (err.isEmpty()?"":"\n"+err)); + } +} diff --git a/deploy/android/src/main/java/org/baas/boa/crash/CrashActivity.java b/deploy/android/src/main/java/org/baas/boa/crash/CrashActivity.java new file mode 100644 index 000000000..b76b15b38 --- /dev/null +++ b/deploy/android/src/main/java/org/baas/boa/crash/CrashActivity.java @@ -0,0 +1,117 @@ +package org.baas.boa.crash; + +import android.app.Activity; +import android.content.Intent; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.util.Log; +import android.view.View; +import android.widget.Button; +import android.widget.ScrollView; +import android.widget.TextView; +import android.widget.Toast; +import androidx.core.content.FileProvider; // 需要在 build.gradle 引入 androidx + +import java.io.*; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +public class CrashActivity extends Activity { + public static final String EXTRA_CRASH_INFO = "crashInfo"; + private String crashLogContent = ""; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + // 简单的 UI 构建 (也可以用 layout xml) + ScrollView scrollView = new ScrollView(this); + android.widget.LinearLayout layout = new android.widget.LinearLayout(this); + layout.setOrientation(android.widget.LinearLayout.VERTICAL); + scrollView.addView(layout); + + Button btnShare = new Button(this); + btnShare.setText("Share Crash Report (ZIP)"); + + TextView tvLog = new TextView(this); + tvLog.setTextSize(12); + + layout.addView(btnShare); + layout.addView(tvLog); + setContentView(scrollView); + + // 获取传递过来的崩溃信息 + crashLogContent = getIntent().getStringExtra(EXTRA_CRASH_INFO); + if (crashLogContent == null) crashLogContent = "No crash data found."; + tvLog.setText(crashLogContent); + + btnShare.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + shareCrashZip(); + } + }); + } + + private void shareCrashZip() { + try { + // 1. 创建临时文件目录 + File cacheDir = new File(getExternalCacheDir(), "crash_reports"); + if (!cacheDir.exists()) cacheDir.mkdirs(); + + String timestamp = new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(new Date()); + File txtFile = new File(cacheDir, "crash_" + timestamp + ".txt"); + File zipFile = new File(cacheDir, "crash_report_" + timestamp + ".zip"); + + // 2. 写入文本文件 + FileWriter writer = new FileWriter(txtFile); + writer.write(crashLogContent); + writer.close(); + + // 3. 打包成 ZIP + zipFile(txtFile, zipFile); + + // 4. 调用系统分享 + Intent shareIntent = new Intent(Intent.ACTION_SEND); + shareIntent.setType("application/zip"); + + // 注意:需要 FileProvider (见下文配置) + Uri fileUri = FileProvider.getUriForFile( + this, + getApplicationContext().getPackageName() + ".fileprovider", + zipFile); + + shareIntent.putExtra(Intent.EXTRA_STREAM, fileUri); + shareIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + startActivity(Intent.createChooser(shareIntent, "Share Crash Report")); + + } catch (Exception e) { + e.printStackTrace(); + Toast.makeText(this, "Failed to create zip: " + e.getMessage(), Toast.LENGTH_SHORT).show(); + } + } + + private void zipFile(File srcFile, File zipFile) throws IOException { + FileOutputStream fos = new FileOutputStream(zipFile); + ZipOutputStream zos = new ZipOutputStream(fos); + FileInputStream fis = new FileInputStream(srcFile); + + ZipEntry zipEntry = new ZipEntry(srcFile.getName()); + zos.putNextEntry(zipEntry); + + byte[] bytes = new byte[1024]; + int length; + while ((length = fis.read(bytes)) >= 0) { + zos.write(bytes, 0, length); + } + + zos.closeEntry(); + fis.close(); + zos.close(); + fos.close(); + } +} \ No newline at end of file diff --git a/deploy/android/src/main/java/org/baas/boa/crash/CustomCrashHandler.java b/deploy/android/src/main/java/org/baas/boa/crash/CustomCrashHandler.java new file mode 100644 index 000000000..5c1f5e197 --- /dev/null +++ b/deploy/android/src/main/java/org/baas/boa/crash/CustomCrashHandler.java @@ -0,0 +1,77 @@ +package org.baas.boa.crash; + +import android.app.Application; +import android.content.Context; +import android.content.Intent; +import android.os.Build; +import android.os.Process; +import android.util.Log; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.io.PrintWriter; +import java.io.StringWriter; + +public class CustomCrashHandler implements Thread.UncaughtExceptionHandler { + private final Thread.UncaughtExceptionHandler defaultHandler; + private final Context context; + + public CustomCrashHandler(Context context) { + this.context = context; + this.defaultHandler = Thread.getDefaultUncaughtExceptionHandler(); + } + + @Override + public void uncaughtException(Thread thread, Throwable ex) { + try { + StringBuilder report = new StringBuilder(); + + // 1. 基本信息 + report.append("------- DEVICE INFO -------\n"); + report.append("Model: ").append(Build.MODEL).append("\n"); + report.append("Brand: ").append(Build.BRAND).append("\n"); + report.append("Android Ver: ").append(Build.VERSION.RELEASE).append("\n"); + report.append("SDK: ").append(Build.VERSION.SDK_INT).append("\n"); + + // 2. Java Stack Trace + report.append("\n------- JAVA STACK TRACE -------\n"); + StringWriter sw = new StringWriter(); + PrintWriter pw = new PrintWriter(sw); + ex.printStackTrace(pw); + report.append(sw.toString()); + + // 3. Logcat (这是捕获 Python traceback 的关键) + report.append("\n------- LOGCAT TAIL (Includes Python Logs) -------\n"); + try { + // 获取最近的 500 行日志 + java.lang.Process process = Runtime.getRuntime().exec("logcat -d -t 500 -v threadtime"); + BufferedReader bufferedReader = new BufferedReader( + new InputStreamReader(process.getInputStream())); + + String line; + while ((line = bufferedReader.readLine()) != null) { + report.append(line).append("\n"); + } + } catch (Exception e) { + report.append("Failed to capture logcat: ").append(e.getMessage()); + } + + // 4. 启动 CrashActivity + Intent intent = new Intent(context, CrashActivity.class); + intent.putExtra(CrashActivity.EXTRA_CRASH_INFO, report.toString()); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); + context.startActivity(intent); + + // 必须杀掉当前进程,否则可能处于假死状态 + android.os.Process.killProcess(android.os.Process.myPid()); + System.exit(10); + + } catch (Exception e) { + Log.e("CustomCrashHandler", "Error in handler", e); + // 如果处理 crash 的逻辑也崩了,回退到系统默认 + if (defaultHandler != null) { + defaultHandler.uncaughtException(thread, ex); + } + } + } +} \ No newline at end of file diff --git a/deploy/android/sync.py b/deploy/android/sync.py new file mode 100644 index 000000000..3316bbac4 --- /dev/null +++ b/deploy/android/sync.py @@ -0,0 +1,366 @@ +#!/usr/bin/env python3 +import os +import sys +import subprocess +import platform +import shutil +import tempfile +import argparse +import fnmatch + +# ================= CONFIGURATION ================= +PACKAGE_NAME = "top.qwq123.boa" +INTERNAL_SUB_DIR = "files/app" # 相对于 /data/data/top.qwq123.boa/ +TEMP_DIR = "/data/local/tmp/" + +# --- 1. 白名单 (SYNC_WHITELIST) --- +# 只有匹配这些规则的文件才会被扫描和同步。 +# 语法参考 gitignore: +# - "dir/" : 匹配目录及其子内容 +# - "*.py" : 匹配后缀 +# - "path/to/f" : 匹配具体文件 +SYNC_WHITELIST = [ + "/core/", + "/gui/", + "/module/", + "/src/", + "/main.py", + "/android_main.py", + "/window.py", +] + +# --- 2. 黑名单 (SYNC_BLACKLIST) --- +# 任何匹配这些规则的文件都会被强制排除。 +# (既不会被推送到手机,远程存在的也不会被删除,视为“隐形”文件) +SYNC_BLACKLIST = [ + "__pycache__/", + "*.pyc", + ".git/", + ".github/", + "tests/", + "*.log", + ".DS_Store", + "/core/ocr/baas_ocr_client/bin/" +] +# ================================================= + +def get_adb_command(): + """Detect WSL and return the appropriate adb command.""" + if "microsoft" in platform.uname().release.lower(): + if shutil.which("adb.exe"): + return "adb.exe" + return "adb" + +ADB_CMD = get_adb_command() + +def match_path(path, patterns): + """ + 检查路径是否匹配给定的模式列表。 + 支持以 "/" 开头锚定根目录,支持以 "/" 结尾匹配目录及其子内容。 + """ + # 统一路径分隔符为 Linux 风格 + path = path.replace("\\", "/") + path_parts = path.split("/") + filename = os.path.basename(path) + + for pat in patterns: + if not pat or pat.startswith('#'): continue + + # === 情况 A: 根目录锚定 (以 / 开头) === + if pat.startswith("/"): + # 去掉开头的 /,变成 "core/" 或 "main.py" + clean_pat = pat[1:] + + # 1. 如果是目录规则 (例如 "/core/") + if clean_pat.endswith("/"): + # 逻辑:只要当前文件路径是以 "core/" 开头的, + # 无论它多深 (例如 core/sub/a.py),startswith 都会返回 True + if path.startswith(clean_pat): + return True + + # 2. 如果是文件规则 (例如 "/main.py") + else: + # 精确全路径匹配 + if path == clean_pat: + return True + # 或者处理通配符 (例如 "/build/*.txt") + if fnmatch.fnmatch(path, clean_pat): + return True + + # === 情况 B: 宽松匹配 (不以 / 开头) === + else: + # 1. 目录匹配 (例如 "assets/") -> 匹配任意深度的 assets 文件夹 + if pat.endswith("/"): + clean_pat = pat.rstrip("/") + if clean_pat in path_parts: + return True + + # 2. 普通文件/通配符匹配 (例如 "*.py") + else: + if "/" in pat: + if fnmatch.fnmatch(path, pat): + return True + else: + if fnmatch.fnmatch(filename, pat): + return True + return False + +def human_readable_size(size, decimal_places=2): + for unit in ['B', 'KB', 'MB', 'GB', 'TB']: + if size < 1024.0: + return f"{size:.{decimal_places}f} {unit}" + size /= 1024.0 + return f"{size:.{decimal_places}f} PB" + +def run_cmd_capture(args, shell=False): + try: + res = subprocess.run(args, capture_output=True, text=True, check=True, shell=shell) + return res.stdout.strip() + except subprocess.CalledProcessError as e: + print(f"[!] Command failed: {args}\n {e.stderr}") + sys.exit(1) + +def run_cmd_direct(args): + try: + subprocess.run(args, check=True) + except subprocess.CalledProcessError: + print(f"[!] Failed to execute: {' '.join(args)}") + sys.exit(1) + +def check_run_as_access(): + cmd = [ADB_CMD, "shell", f"run-as {PACKAGE_NAME} id"] + try: + res = subprocess.run(cmd, capture_output=True, text=True) + if res.returncode != 0: + print(f"[!] Error: 'run-as {PACKAGE_NAME}' failed.") + print(" Ensure the app is installed and debuggable=true in AndroidManifest.xml") + sys.exit(1) + except Exception as e: + print(f"[!] ADB Error: {e}") + sys.exit(1) + +def get_local_files(): + """Get all local files filtered by inline whitelist and blacklist.""" + print("[1/6] Scanning local git files and filtering...") + files_map = {} + + # 1. Main repo (Git files) + cmd_main = ["git", "ls-files", "-c", "-o", "--exclude-standard"] + out_main = run_cmd_capture(cmd_main) + + # 2. Submodules (recursive) + cmd_sub = "git submodule foreach --quiet --recursive 'git ls-files -c -o --exclude-standard | sed \"s|^|$path/|\"'" + try: + out_sub = run_cmd_capture(cmd_sub, shell=True) + except Exception: + out_sub = "" + + all_lines = out_main.splitlines() + out_sub.splitlines() + + for f in all_lines: + f = f.strip() + if not f: continue + + # === 核心过滤逻辑 === + # 1. 必须在白名单中 + if not match_path(f, SYNC_WHITELIST): + continue + + # 2. 必须不在黑名单中 + if match_path(f, SYNC_BLACKLIST): + continue + # =================== + + if os.path.exists(f) and os.path.isfile(f): + files_map[f] = int(os.path.getmtime(f)) + + return files_map + +def get_remote_files(): + """Get remote files via run-as {relative_path: mtime(int)}.""" + print("[2/6] Fetching remote file status (via run-as)...") + remote_map = {} + + find_cmd = f"cd {INTERNAL_SUB_DIR} && find . -type f -exec stat -c '%n|%Y' {{}} +" + adb_args = [ADB_CMD, "shell", f"run-as {PACKAGE_NAME} sh -c \"{find_cmd}\""] + + try: + res = subprocess.run(adb_args, capture_output=True, text=True) + if res.returncode != 0: + print(" -> Remote directory might not exist. Attempting to create...") + subprocess.run([ADB_CMD, "shell", f"run-as {PACKAGE_NAME} mkdir -p {INTERNAL_SUB_DIR}"], stdout=subprocess.DEVNULL) + return {} + + for line in res.stdout.splitlines(): + if "|" not in line: continue + path, mtime = line.split("|") + if path.startswith("./"): path = path[2:] + clean_path = path.replace("\\", "/") + + # === [FIX] 远程文件过滤逻辑修正 === + + # 1. 必须不在黑名单中 (保持原逻辑:保护黑名单文件不被操作) + if match_path(clean_path, SYNC_BLACKLIST): + continue + + # 2. [新增] 必须在白名单中 + # 如果远程文件根本不在白名单范围内(例如 build/ 产物),我们假装没看见它。 + # 这样它就不会进入 remote_map,从而不会触发“本地无此文件 -> 删除”的逻辑。 + if not match_path(clean_path, SYNC_WHITELIST): + continue + + # =============================== + + try: + remote_map[clean_path] = int(float(mtime)) + except ValueError: pass + + except Exception as e: + print(f" -> Error fetching remote list: {e}") + return {} + + return remote_map + +def execute_delete_script(files_to_delete): + """Generate script, push to tmp, exec via run-as.""" + if not files_to_delete: return + + print(f"[6/6] Executing deletion script ({len(files_to_delete)} files)...") + + script_name = "sync_delete_exec.sh" + tmp_script_path = f"{TEMP_DIR}{script_name}" + + with open(script_name, "w", newline='\n', encoding='utf-8') as f: + f.write("#!/bin/sh\n") + f.write(f"cd {INTERNAL_SUB_DIR} || exit 1\n") + for file_path in files_to_delete: + f.write(f'rm -f "{file_path}"\n') + + try: + run_cmd_direct([ADB_CMD, "push", script_name, tmp_script_path]) + print(" -> Running deletion inside app sandbox...") + + target_script = f"{INTERNAL_SUB_DIR}/{script_name}" + + cmd_exec = ( + f"cp {tmp_script_path} {target_script} && " + f"chmod 777 {target_script} && " + f"sh {target_script} && " + f"rm {target_script}" + ) + + run_cmd_direct([ADB_CMD, "shell", f"run-as {PACKAGE_NAME} sh -c \"{cmd_exec}\""]) + run_cmd_capture([ADB_CMD, "shell", f"rm -f {tmp_script_path}"]) + + finally: + if os.path.exists(script_name): + os.remove(script_name) + +def print_changes(to_push, to_delete): + print("\n" + "="*40) + print(" SYNC PLAN (RUN-AS MODE)") + print("="*40) + if to_push: + print(f"Files to PUSH ({len(to_push)}):") + for f in to_push: + print(f" [+] {f}") + else: + print("Files to PUSH: None") + print("-" * 20) + if to_delete: + print(f"Files to DELETE ({len(to_delete)}):") + for f in to_delete: + print(f" [-] {f}") + else: + print("Files to DELETE: None") + print("="*40 + "\n") + +def sync(dry_run=False): + check_run_as_access() + + if not SYNC_WHITELIST: + print("[!] Warning: SYNC_WHITELIST is empty. No files will be synced.") + + # 1. Get filtered local and remote files + local_map = get_local_files() + remote_map = get_remote_files() + + to_push = [] + to_delete = [] + + # 2. Diff Logic + # PUSH + for f_path, l_mtime in local_map.items(): + remote_path = f_path.replace("\\", "/") + if remote_path not in remote_map: + to_push.append(f_path) + else: + r_mtime = remote_map[remote_path] + if l_mtime != r_mtime: + to_push.append(f_path) + + # DELETE + # remote_map 现在只包含“白名单内”且“非黑名单”的文件 + # 如果它在 remote_map 中,但在 local_map 中找不到,说明它在本地被删除了(或者是旧的残留文件),应该删除。 + local_paths_linux = set(p.replace("\\", "/") for p in local_map.keys()) + for r_path in remote_map.keys(): + if r_path not in local_paths_linux: + to_delete.append(r_path) + + print_changes(to_push, to_delete) + + if dry_run: + print("[DRY RUN] No changes were applied.") + return + + if not to_push and not to_delete: + print("[√] Already up to date.") + return + + # Push + if to_push: + print("[3/6] Packing files...") + with tempfile.NamedTemporaryFile(mode='w+', delete=False, encoding='utf-8') as tmp_list: + tmp_list.write('\n'.join(to_push)) + tmp_list_path = tmp_list.name + + tar_name = "sync_update.tar" + tar_tmp_path = f"{TEMP_DIR}{tar_name}" + + try: + # -T takes the file list + run_cmd_capture(["tar", "-cf", tar_name, "-T", tmp_list_path]) + tar_size = os.path.getsize(tar_name) + readable_size = human_readable_size(tar_size) + + print(f"[4/6] Pushing to temp: {tar_name} ({readable_size})...") + run_cmd_direct([ADB_CMD, "push", tar_name, tar_tmp_path]) + + print(" -> Extracting into App Data (via run-as)...") + cmd_extract = ( + f"mkdir -p {INTERNAL_SUB_DIR} && " + f"tar -xf {tar_tmp_path} -C {INTERNAL_SUB_DIR}" + ) + run_cmd_direct([ADB_CMD, "shell", f"run-as {PACKAGE_NAME} sh -c \"{cmd_extract}\""]) + run_cmd_capture([ADB_CMD, "shell", f"rm -f {tar_tmp_path}"]) + + finally: + if os.path.exists(tmp_list_path): os.remove(tmp_list_path) + if os.path.exists(tar_name): os.remove(tar_name) + else: + print("[3/6] No files to update (Push skipped).") + + # Delete + execute_delete_script(to_delete) + + print("\n[√] Sync Complete!") + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Sync whitelist-based local files to Android App Data") + parser.add_argument("--dry-run", action="store_true", help="List changes without executing them") + args = parser.parse_args() + + if ADB_CMD == "adb.exe": + print("[*] WSL Environment Detected: Using host adb.exe") + + sync(dry_run=args.dry_run) \ No newline at end of file diff --git a/docs/develop_doc/script/config.md b/docs/develop_doc/script/config.md index 2c8d621bf..29a2fc222 100644 --- a/docs/develop_doc/script/config.md +++ b/docs/develop_doc/script/config.md @@ -567,6 +567,27 @@ - **type** : `List[str]` - **description** : 自动删好友白名单, 每一项为好友码 +## `clear_friend_level_limit` + +- **type** : `int` +- **description** : 自动清好友等级下限。 + 好友等级**低于** `clear_friend_level_limit` 的将被清理。 +- **note** : 设为`-1`则不启用该条件 + +## `clear_friend_last_login_time_days` + +- **type** : `int` +- **description** : 自动清好友登录时间限制。 + 好友**上次登录时间超过** `clear_friend_last_login_time_days` 天的将被清理。 +- **note** : 设为`-1`则不启用该条件 + +## `clear_friend_last_total_assault_rank_limit` + +- **type** : `int` +- **description** : 自动清好友总力战排名限制。 + 好友**上次总力战排名在** `clear_friend_last_total_assault_rank_limit` 之外的将被清理。 +- **note** : 设为`-1`则不启用该条件 + ---
diff --git a/gui/fragments/home.py b/gui/fragments/home.py index b46f32a7c..07896862b 100644 --- a/gui/fragments/home.py +++ b/gui/fragments/home.py @@ -67,7 +67,8 @@ def __init__(self, parent: Window = None, config=None): self.banner.size(), Qt.KeepAspectRatioByExpanding, Qt.SmoothTransformation) self.banner.setPixmap(pixmap) self.banner.setScaledContents(True) - self.banner.setVisible(self.banner_visible) + # HACK: + self.banner.setVisible(False) self.startup_card = PrimaryPushSettingCard( self.tr('启动'), @@ -101,8 +102,10 @@ def __init__(self, parent: Window = None, config=None): self.assets_status = AssetsWidget(config, self) handler_for_logger.addWidget(self.assets_status) self.assets_status.start_patch() + from core.utils import host_platform_is_android - self.assets_status.show() if self.config.get("assetsVisibility") else self.assets_status.hide() + # HACK: + self.assets_status.show() if not host_platform_is_android() and self.config.get("assetsVisibility") else self.assets_status.hide() self.column_2.addLayout(handler_for_logger) self.column_2.addWidget(self.logger_box) diff --git a/gui/fragments/process.py b/gui/fragments/process.py index 843e63e65..33f5d2b6a 100644 --- a/gui/fragments/process.py +++ b/gui/fragments/process.py @@ -11,6 +11,7 @@ from gui.components import expand from gui.util.style_sheet import StyleSheet from gui.util.translator import baasTranslator as bt +from core.utils import host_platform_is_android lock = threading.Lock() DISPLAY_CONFIG_PATH = './config/display.json' @@ -82,6 +83,13 @@ def __init__(self, parent, config): self.VBoxLayout.addLayout(self.HBoxLayout) self.displayWidget.setLayout(self.VBoxLayout) + if host_platform_is_android(): + self.label_running.setVisible(False) + self.on_status.setVisible(False) + self.label_queuing.setVisible(False) + self.listWidget.setVisible(False) + self.displayWidget.setFixedHeight(0) + feature_panel = expand.__dict__['featureSwitch'].Layout(config=config) self.VBoxWrapperLayout.addWidget(self.displayWidget) self.VBoxWrapperLayout.addWidget(feature_panel) diff --git a/module/__init__.py b/module/__init__.py index a2c133972..5208d5ff9 100644 --- a/module/__init__.py +++ b/module/__init__.py @@ -28,3 +28,4 @@ from module import total_assault from module import collect_pass_reward from module import collect_daily_free_power +from module import final_restriction_rls diff --git a/module/cafe_reward.py b/module/cafe_reward.py index 65f77e74a..17aa91440 100644 --- a/module/cafe_reward.py +++ b/module/cafe_reward.py @@ -411,17 +411,15 @@ def invite_girl(self, no=1): self, 'cafe_invite-student-button', search_button_region, - threshold=0.8 + 0.8, + None, + (5, 5) ) if len(all_position) == 0: self.logger.warning("Can't Find Any Invite Student Button.") break - all_position = merge_nearby_coordinates(all_position, 10, 10) detected_name = [] - for pos in all_position: - x_coords = [coord[0] for coord in pos] - y_coords = [coord[1] for coord in pos] - p = (median(x_coords), median(y_coords)) + for p in all_position: ocr_region = ( p[0] + ocr_region_offsets[0], p[1] + ocr_region_offsets[1], diff --git a/module/final_restriction_rls.py b/module/final_restriction_rls.py new file mode 100644 index 000000000..9a6d81f7a --- /dev/null +++ b/module/final_restriction_rls.py @@ -0,0 +1,678 @@ +import time +import numpy as np +from math import floor, ceil +from datetime import datetime, timedelta + + +from core.image import search_in_area, resize_ss_image, check_geometry_pixels, swipe_search_target_str +from core.color import rgb_in_range +from core.geometry.parallelogram import Parallelogram +from core.picture import GAME_ONE_TIME_POP_UPS, co_detect +from module.main_story import set_acc_and_auto + +FINAL_RESTRICTION_RLS_CLEAR_UNIT_ROW_COUNT = 4 +FINAL_RESTRICTION_RLS_FRONT_STUDENT_CNT = 6 +FINAL_RESTRICTION_RLS_BACK_STUDENT_CNT = 4 +FINAL_RESTRICTION_RLS_MAX_CHARACTER_COUNT = FINAL_RESTRICTION_RLS_FRONT_STUDENT_CNT + FINAL_RESTRICTION_RLS_BACK_STUDENT_CNT +FINAL_RESTRICTION_RLS_MAX_STAGE = 124 +FINAL_RESTRICTION_RLS_MAX_STAGE_LIST = [24, 49, 74, 99, 124] +FINAL_RESTRICTION_RLS_PIXEL_STAGE_NOT_SELECTED = [66, 126, 60, 146, 128, 188] +FINAL_RESTRICTION_RLS_PIXEL_STAGE_SELECTED = [184, 224, 178, 218, 204, 255] + +def implement(self): + self.to_main_page() + to_page_select_stage_menu(self) + open_ = True if get_open_state(self) == "Open" else False + curr_stage = get_highest_passed_stage(self) + if open_ : + _next = detect_battle_open_time(self) + timedelta(minutes=1) + if curr_stage == FINAL_RESTRICTION_RLS_MAX_STAGE: + self.logger.info("Already Passed Highest Stage, Quit") + else: + push_stage(self, curr_stage) + else: + _next = detect_battle_next_open_time(self) + + now = datetime.now() + self.next_time = ceil((_next - now).total_seconds()) + return True + +def detect_battle_next_open_time(self): + self.logger.info("Detect Battle Next Open Time") + ocr_region = (221, 653, 274, 680) + text = self.ocr.get_region_res( + self, + ocr_region, + "en-us", + "Final Restriction Rls Battle Next Open Time", + "0123456789/" + ) + fmt = { + "CN": "%Y/%m/%d", + "Global_zh-tw": "%Y/%m-%d", + "Global_en-us": "%Y/%m/%d", + "Global_ko-kr": "%Y/%m-%d", + "JP": "%Y/%m/%d" + }[self.identifier] + now = datetime.now() + year = now.year + next_open = datetime.strptime(f"{year}/{text.strip()}", fmt) + next_open = next_open.replace(hour=10 if self.server in ["JP", "Global"] else 11) + if next_open < now: + next_open = next_open.replace(year=year + 1) + self.logger.info(f"Next Open Time : {next_open}") + set_final_restriction_rls_config_info(self, "next_open_time", next_open.timestamp()) + return next_open + +def detect_battle_open_time(self): + self.logger.info("Detect Battle Open Time") + + ocr_region = (138, 653, 410, 680) + text = self.ocr.get_region_res( + self, + ocr_region, + "en-us", + "Final Restriction Rls Battle Open Time", + "0123456789/:~- " + ) + + fmt = { + "CN": "%Y/%m/%d %H:%M", + "Global_en-us": "%Y/%m/%d %H:%M", + "Global_ko-kr": "%Y/%m-%d %H:%M", + "Global_zh-tw": "%Y/%m-%d %H:%M", + "JP": "%Y/%m/%d %H:%M" + }[self.identifier] + + now = datetime.now() + year = now.year + + pos = text.find('~') + if pos == -1: + pos = text.find('-') + if pos == -1: + self.logger.info(f"Failed to detect battle open time from text [{text}], quit") + return + start_str, end_str= text[:pos], text[pos+1:] + start_str = start_str.strip() + end_str = end_str.strip() + + start = datetime.strptime(f"{year}/{start_str}", fmt) + end = datetime.strptime(f"{year}/{end_str}", fmt) + + if end < start: + if now >= start: + end = end.replace(year=year + 1) + else: + start = start.replace(year=year - 1) + + self.logger.info(f"Start : {start}") + self.logger.info(f"End : {end}") + + set_final_restriction_rls_config_info(self, "start_time", start.timestamp()) + set_final_restriction_rls_config_info(self, "end_time", end.timestamp()) + return end + +def push_stage(self, start): + """ + Push final restriction rls to the highest stage + """ + index = get_next_max_stage_index(start) + _need_select_stage = True + while index is not None: + stage_index = FINAL_RESTRICTION_RLS_MAX_STAGE_LIST[index] + self.logger.info(f"<<< Stage {stage_index} >>>") + if _need_select_stage: + to_page_select_stage(self, True) + select_stage(self, index) + _need_select_stage = False + else: + to_page_select_stage(self, False) + to_page_select_accurate_stage(self) + if not select_accurate_stage(self, stage_index, index): + return + if complete_battle(self): + set_final_restriction_rls_config_info(self, "passed_stage", stage_index) + index = get_next_max_stage_index(stage_index) + start = stage_index + if index is None: + self.logger.info("Passed Highest Stage, Quit") + return + else: + break + + l = start + 1 + r = FINAL_RESTRICTION_RLS_MAX_STAGE_LIST[index] + while l + 1 <= r: + mid = floor((l + r) / 2) + self.logger.info(f"<<< Stage {mid} >>>") + if not select_accurate_stage(self, mid, index): + return False + if complete_battle(self): + set_final_restriction_rls_config_info(self, "passed_stage", True) + l = mid + 1 + else: + r = mid + return l + +def set_final_restriction_rls_config_info(self, key, value): + data = self.config.final_restriction_rls + data[key] = value + self.config_set.set("final_restriction_rls", data) + +def complete_battle(self): + """ + Returns: + True if battle succeeded else False + """ + self.logger.info("Start Battle") + method = self.config.final_restriction_rls_employ_formation_method + self.logger.info(f"Employ Formation Method : [ {method} ]") + if method == "copy_clear_unit": + ret = select_and_copy_clear_unit(self) + if ret == 0: + employ_team_from_preset(self, 1, 1) + elif ret in [-1, 1]: # empty unit or failed to find a unit available + return False + elif method == "default": + pass + enter_battle(self) + set_acc_and_auto(self) + ret = wait_battle_result(self) + log = "Battle " + ("SUCCEEDED" if ret else "FAILED") + self.logger.info(log) + + self.logger.info("Return to page Select Accurate Stage.") + to_page_select_accurate_stage(self) + return ret + + +def enter_battle(self): + img_possibles = { + "final_restriction_rls_button-enter-stage": (1134, 650), + "final_restriction_rls_formation-menu": (1175, 665), + "main_page_skip-notice": (769, 503), + 'plot_menu': (1202, 37), + 'plot_skip-plot-button': (1208, 116), + 'plot_skip-plot-notice': (770, 519) + } + img_ends = [ + "normal_task_fail-confirm", + "normal_task_fight-confirm" + ] + rgb_end = "final-restriction-rls-fighting_feature" + co_detect(self, rgb_end, None, img_ends, img_possibles, tentative_click=True, tentative_x=640, tentative_y=360, max_fail_cnt=5) + +def wait_battle_result(self): + self.logger.info("Wait Battle Result") + rgb_possibles = { + "final-restriction-rls-fighting_feature": (-1, -1) + } + img_possibles = { + "main_page_skip-notice": (769, 503) + } + img_ends = [ + "normal_task_fail-confirm", + "normal_task_fight-confirm" + ] + if "normal_task_fail-confirm" == co_detect(self, None, rgb_possibles, img_ends, img_possibles, tentative_click=True, tentative_x=640, tentative_y=360, max_fail_cnt=5) : + return False + return True + +def select_and_copy_clear_unit(self): + """ + Returns: + 0 : Successfully choose a team + 1 : Failed to choose a team with passed characters + -1 : All team empty, skip this fight + """ + self.logger.info("Select Clear Unit") + _max_absent = self.config.final_restriction_rls_employ_formation_copy_clear_unit_max_unavailable_student_count + _max_refresh = self.config.final_restriction_rls_employ_formation_copy_clear_unit_max_refresh_count + self.logger.info(f"Max Absent Character : {_max_absent}") + self.logger.info(f"Max Refresh Time : {_max_refresh}") + + to_page_clear_unit(self) + + _refresh_cnt = 0 + _ret = 1 + while _refresh_cnt < _max_refresh: + _t_start = time.time() + state = get_clear_unit_state(self) + _t_end = time.time() + ret = log_clear_unit_info(self, state, _max_absent, _t_end - _t_start) + if ret == -1: + self.logger.info("Empty unit detected assume no one ever passed this stage, Skip") + _ret = -1 + break + if ret is not None: + copy_clear_unit(self, ret) + _ret = 0 + break + _refresh_cnt += 1 + self.logger.info(f"Refresh Clear Unit List : {_refresh_cnt}") + self.click(248, 133, duration=1.0, wait_over=True) + self.update_screenshot_array() + + if _ret == 1: + self.logger.info("Failed to choose a clear unit with available characters, use default team") + + to_page_select_accurate_stage(self) + return _ret + +def copy_clear_unit(self, index): + # Copy clear unit to preset col 1 row 1 + button_copy_y = [228, 333, 442, 547][index] + img_possibles = { + "final_restriction_rls_clear-unit-menu": (1064, button_copy_y), + "final_restriction_rls_copy-clear-unit-student-unavailable-notice": (763, 498) + } + img_end = "final_restriction_rls_formation-overwrite-menu" + co_detect(self, None, None, img_end, img_possibles, skip_first_screenshot=True) + img_possibles = { + "final_restriction_rls_formation-overwrite-menu": (1123, 359), + "final_restriction_rls_formation-overwrite-notice": (763, 574), + "final_restriction_rls_formation-overwrite-success-notice": (640, 503), + "final_restriction_rls_clear-unit-menu": (1103, 135) + } + img_end = "final_restriction_rls_button-enter-stage" + co_detect(self, None, None, img_end, img_possibles, skip_first_screenshot=True) + +def employ_team_from_preset(self, col=1, row=1): + img_possibles = { + "final_restriction_rls_button-enter-stage" : (1143, 660) + } + img_ends = "final_restriction_rls_formation-menu" + co_detect(self, img_reactions=img_possibles, img_ends=img_ends, skip_first_screenshot=True) + + img_possibles = { + "final_restriction_rls_formation-menu": (1204, 486), + "normal_task_formation-preset": (178 + (col - 1) * 156, 153) + } + rgb_ends = "preset_choose" + str(col) + co_detect(self, img_reactions=img_possibles, rgb_ends=rgb_ends, skip_first_screenshot=True) + + offsets = { + 'CN': (-1103, 0, 16, 33), + 'Global_en-us': (-1048, -4, 20, 36), + 'Global_ko-kr': (-1105, -4, 20, 36), + 'Global_zh-tw': (-1105, -4, 20, 36), + 'JP': (-1103, 0, 16, 33) + } + + presetButtonPos = swipe_search_target_str( + self, + "normal_task_formation-edit-preset-name", + search_area=(1156, 201, 1229, 553), + threshold=0.8, + possible_strs=["1", "2", "3", "4", "5"], + target_str_index=row - 1, + swipe_params=(145, 578, 145, 273, 1.0, 0.5), + ocr_language="en-us", + ocr_region_offsets=offsets[self.identifier], + ocr_str_replace_func=None, + max_swipe_times=5, + ocr_candidates="12345", + ocr_filter_score=0.2 + ) + preset_y = presetButtonPos[1] + 144 + + img_possibles = { + "normal_task_formation-preset": (1151, preset_y), + "normal_task_formation-set-confirm": (761, 574), + } + + img_end = "final_restriction_rls_formation-menu" + co_detect(self, None, None, img_end, img_possibles, skip_first_screenshot=True) + +def log_clear_unit_info(self, state, _max_absent, _t): + ret = None + ms = int(_t * 1000) + self.logger.info(f"Clear Unit State | {ms}ms") + self.logger.info("Row Front Back State") + for i in range(FINAL_RESTRICTION_RLS_CLEAR_UNIT_ROW_COUNT): + st = str(i+1) + " : " + temp = ''.join(["EXO"[x] for x in state[i]]) + temp = temp[:FINAL_RESTRICTION_RLS_FRONT_STUDENT_CNT] + " | " + temp[FINAL_RESTRICTION_RLS_BACK_STUDENT_CNT*(-1):] + st += temp + if np.count_nonzero(state[i] == 1) <= _max_absent and \ + np.count_nonzero(state[i] == 0) < FINAL_RESTRICTION_RLS_MAX_CHARACTER_COUNT: # ensure not all empty + st += " <-- Valid" + if ret is None: + ret = i + else: + if np.count_nonzero(state[i] == 0) == FINAL_RESTRICTION_RLS_MAX_CHARACTER_COUNT: # all empty + st += " <-- Empty" + ret = -1 + else: + st += " <-- Invalid" + self.logger.info(st) + return ret + +def to_page_clear_unit(self): + img_possibles = { + "final_restriction_rls_menu": (923, 670) + } + img_end = "final_restriction_rls_clear-unit-menu" + co_detect(self, None, None, img_end, img_possibles, skip_first_screenshot=True) + +def get_stage_index_boxes(self): + ret = [] + colors = [ + FINAL_RESTRICTION_RLS_PIXEL_STAGE_NOT_SELECTED, + FINAL_RESTRICTION_RLS_PIXEL_STAGE_SELECTED + ] + start_x = 574 + end_x = 1223 + y = 480 + i = start_x + cnt = 0 + max_pixel_cnt = 100 + last_valid_type = 0 + while i <= end_x: + temp = colors[last_valid_type] + if rgb_in_range(self, i, y, temp[0], temp[1], temp[2], temp[3], temp[4], temp[5]): + # match last color + cnt += 1 + if cnt == max_pixel_cnt: + ret.append((i-max_pixel_cnt, last_valid_type)) + else: + temp = colors[last_valid_type ^ 1] + if rgb_in_range(self, i, y, temp[0], temp[1], temp[2], temp[3], temp[4], temp[5]): + # match another color + cnt = 1 + last_valid_type ^= 1 + else: + # do not match any color + cnt = 0 + i += 1 + return ret + +def select_accurate_stage(self, target_stage_index, stage_region_index): + """ + Detect stage numbers and find target_stage_index + """ + _min = (FINAL_RESTRICTION_RLS_MAX_STAGE_LIST[stage_region_index-1] + 1)if stage_region_index >= 1 else 1 + _max = FINAL_RESTRICTION_RLS_MAX_STAGE_LIST[stage_region_index] + assert(_min <= target_stage_index <= _max) + + self.logger.info(f"Select Accurate Stage [ {target_stage_index} ], in Range [{_min}, {_max}]") + + _retry_cnt = 7 + target_p = None + # Search and Swipe loop + for i in range(_retry_cnt): + boxes = get_stage_index_boxes(self) + if len(boxes) == 0: + self.logger.info("Failed to detect any stage number box") + self.update_screenshot_array() + continue + indexes = [] + for box in boxes: + number = self.ocr.recognize_int(self, (box[0], 476, box[0] + 80, 504), "Final Restriction Rls Stage Index") + if _min <= number <= _max: + indexes.append(number) + + self.logger.info("Detected Stage Indexes : ") + + for box, index in zip(boxes, indexes): + self.logger.info(f"Index [ {index} ], Type {box[1]}, X = {box[0]}") + + _d_min, _d_max = indexes[0], indexes[0] # detect minimum and maximum + for box, index in zip(boxes, indexes): + if index == target_stage_index: + self.logger.info(f"Find Target Index [ {index} ]") + target_p = box[0] + 50 + break + if index < _d_min: + _d_min = index + if index > _d_max: + _d_max = index + + if target_p is not None: + break + self.logger.info("Didn't find target stage index") + _s_x_st, _s_x_ed = 679, 1080 # swipe x start, x end + _s_y = 438 + if _d_min > target_stage_index: + pass + elif _d_max < target_stage_index: + _s_x_st, _s_x_ed = _s_x_ed, _s_x_st + else: + self.logger.info(f"Target Index [{target_stage_index}] should be in the range of [{_d_min}, {_d_max}], but not detected, retry") + self.update_screenshot_array() + continue + + self.swipe(_s_x_st, _s_y, _s_x_ed, _s_y, 1.0, 0.5) + self.update_screenshot_array() + + if target_p is None: + self.logger.info(f"Failed to find target stage index [{target_stage_index}] after {_retry_cnt} retries, quit") + return False + self.rgb_feature["final-restriction-rls-target-stage-not-selected"] = [[[target_p, 476]], [FINAL_RESTRICTION_RLS_PIXEL_STAGE_NOT_SELECTED]] + self.rgb_feature["final-restriction-rls-target-stage-selected"] = [[[target_p, 476]], [FINAL_RESTRICTION_RLS_PIXEL_STAGE_SELECTED]] + + rgb_possibles = { + "final-restriction-rls-target-stage-not-selected": (target_p, 476) + } + rgb_end = "final-restriction-rls-target-stage-selected" + co_detect(self, rgb_end, rgb_possibles, skip_first_screenshot=True) + return True + +def to_page_select_stage(self, click=True): + img_possibles = { + "final_restriction_rls_button-return-to-select-stage": (123, 123) if click else (-1, -1), + "final_restriction_rls_state-open": (981, 578), + "final_restriction_rls_best-record": (640, 537) + } + + rgb_end = "final-restriction-rls-select-stage" + co_detect(self, rgb_end, None, None,img_possibles, skip_first_screenshot=True) + +def select_stage(self, index): + def replace_100(text): + if text == "100": + text.replace(" ", "") + return "100-" + return text + + ret = swipe_search_target_str( + self, + name="final_restriction_rls_button-select-stage", + search_area=(0, 493, 1237, 600), + possible_strs=["01-24", "25-49", "50-74", "75-99", "100-"], + target_str_index=index, + swipe_params=(1035, 240, 525, 240, 1.0, 0.5), + ocr_language="en-us", + ocr_region_offsets=(-42, -151, 141, 39), + ocr_candidates="0123456789-", + ocr_str_replace_func=replace_100, + max_swipe_times=5 + ) + to_page_select_accurate_stage(self, ret[0]) + +def to_page_select_accurate_stage(self, x=None): + img_end = "final_restriction_rls_button-enter-stage" + rgb_possibles = { + "final-restriction-rls-select-stage": (x, 527) if x is not None else (-1, -1) + } + img_possibles = { + "final_restriction_rls_clear-unit-menu": (1104, 135), + "normal_task_fail-confirm": (640, 654), + "normal_task_fight-confirm": (1168, 659), + "final_restriction_rls_best-record": (640, 537), + } + co_detect(self, None, rgb_possibles, img_end, img_possibles, skip_first_screenshot=True) + + +def get_next_max_stage_index(integer): + for i in range(len(FINAL_RESTRICTION_RLS_MAX_STAGE_LIST)): + if integer < FINAL_RESTRICTION_RLS_MAX_STAGE_LIST[i]: + return i + return None + +def to_page_select_stage_menu(self): + img_possibles = { + "main_page_bus": (1118, 589), + "final_restriction_rls_reward-details": (1126, 109) + } + rgb_possibles = { + "main_page": (1190, 552) + } + + img_end = "final_restriction_rls_menu" + + img_possibles.update(GAME_ONE_TIME_POP_UPS[self.server]) + co_detect(self, None, rgb_possibles, img_end, img_possibles, skip_first_screenshot=True) + + +def get_open_state(self): + img_ends = [ + "final_restriction_rls_state-open", + "final_restriction_rls_state-close" + ] + + ret = co_detect(self, None, None, img_ends, None, skip_first_screenshot=True) + state = "Open" if ret == img_ends[0] else "Close" + self.logger.info(f"Final Restriction Rls State [ {state} ]") + set_final_restriction_rls_config_info(self,"open", state) + return state + +def to_page_reward_details(self): + img_possibles = { + "final_restriction_rls_menu": (500, 657) + } + img_end = "final_restriction_rls_reward-details" + co_detect(self, None, None, img_end, img_possibles, skip_first_screenshot=True) + +def get_highest_passed_stage(self): + to_page_reward_details(self) + region = { + "CN": (535, 549, 725, 575), + "Global_en-us": (559, 549, 805, 575), + "Global_ko-kr": (535, 549, 725, 575), + "Global_zh-tw": (538, 549, 725, 575), + "JP": (535, 549, 725, 575), + }[self.identifier] + text = self.ocr.get_region_res(self, region, "en-us", "Final Restriction Rls Highest Passed Stage", "0123456789():.",filter_score=0.8) + ret = 0 + if text.find('(') != -1: + text = text.split('(')[0].strip() + try: + ret = min(int(text), FINAL_RESTRICTION_RLS_MAX_STAGE) + except: + pass + self.logger.info(f"Passed Highest Stage : [ {ret} ]") + set_final_restriction_rls_config_info(self, "passed_stage", ret) + to_page_select_stage_menu(self) + return ret + + +def get_clear_unit_state(self): + """ + Returns: + 4 * 10 array recording the 4 clear formation character available state. + Element : + 0 : empty + 1 : character unavailable + 2 : character available + """ + ret = get_empty_position_in_clear_formation(self) + start_x = 179 + start_y = 212 + dx = 81.5 + dy = 107 + + img_resized = resize_ss_image(self, (0, 0, 1280, 720)) + + # front + for i in range(4): + curr_y = start_y + i * dy + for j in range(FINAL_RESTRICTION_RLS_FRONT_STUDENT_CNT): + if ret[i][j] == 0: # empty + continue + curr_x = int(start_x + j * dx) + ret[i][j] = region_student_is_available(img_resized, curr_x, curr_y) + # back + start_x = 673 + for i in range(4): + curr_y = start_y + i * dy + for j in range(FINAL_RESTRICTION_RLS_BACK_STUDENT_CNT): + if ret[i][j+6] == 0: # empty + continue + curr_x = int(start_x + j * dx) + ret[i][j+6] = region_student_is_available(img_resized, curr_x, curr_y) + + return ret + +def get_empty_position_in_clear_formation(self): + """ + Returns: + 4 * 10 array recording if the position in clear formation is empty. + Element : + 0 : empty + 1 : not empty + """ + ret = np.ones((4, 10), dtype=int) + start_x = 179 + start_y = 212 + + dx = 81.5 + dy = 107 + + window_dx_1 = -5 + window_dy_1 = -5 + window_dx_2 = 70 + window_dy_2 = 40 + + # front + for i in range(FINAL_RESTRICTION_RLS_CLEAR_UNIT_ROW_COUNT): + curr_y = start_y + i * dy + for j in range(FINAL_RESTRICTION_RLS_FRONT_STUDENT_CNT): + curr_x = int(start_x + j * dx) + region = ( + curr_x + window_dx_1, + curr_y + window_dy_1, + curr_x + window_dx_2, + curr_y + window_dy_2 + ) + if search_in_area( + self, + "final_restriction_rls_clear-unit-empty-student", + region + ): + ret[i][j] = 0 + # back + start_x = 673 + for i in range(FINAL_RESTRICTION_RLS_CLEAR_UNIT_ROW_COUNT): + curr_y = start_y + i * dy + for j in range(FINAL_RESTRICTION_RLS_BACK_STUDENT_CNT): + curr_x = int(start_x + j * dx) + region = ( + curr_x + window_dx_1, + curr_y + window_dy_1, + curr_x + window_dx_2, + curr_y + window_dy_2 + ) + if search_in_area( + self, + "final_restriction_rls_clear-unit-empty-student", + region + ): + ret[i][j + 6] = 0 + return ret + + +def region_student_is_available(img, x, y): + start_dx = -6 + start_dy = 51 + + k1 = 0 + dx1 = 68 + k2 = -5.3 + dx2 = 12 + pixel_threshold = 140 + para = Parallelogram(x+start_dx, y+start_dy, k1, dx1, k2, dx2) + if check_geometry_pixels(img, para, (0, pixel_threshold, 0, pixel_threshold, 0, pixel_threshold), 50): + return 1 # unavailable + return 2 # available diff --git a/module/friend.py b/module/friend.py index 7128d50d8..c721b2f7c 100644 --- a/module/friend.py +++ b/module/friend.py @@ -1,3 +1,5 @@ +import re + from core import picture, image from core.utils import merge_nearby_coordinates from statistics import median @@ -29,13 +31,11 @@ def implement(self): need_swipe = True for i in range(0, len(positions)): position = positions[i] - res = to_player_info(self, position) - if res == "friend_delete-friend-notice": - self.logger.info("UI AT delete friend notice, Skip") + data = get_friend_data(self, position) + if data is False: continue - in_white_list = check_name_in_white_list(self) - to_friend_management(self) - if in_white_list: + need_delete = judge_need_delete(self, data) + if not need_delete: if i == len(positions) - 1: if last_friend_id == self.last_friend_id: self.logger.info("Last Friend ID [ " + str(last_friend_id) + " ] remain same, Exit") @@ -66,10 +66,11 @@ def to_friend_management(self, skip_first_screenshot=False): def get_possible_friend_positions(self): - return image.get_image_all_appear_position(self, "friend_delete-friend", (1067, 155, 1196, 691), 0.8) + return image.get_image_all_appear_position(self, "friend_delete-friend", (1067, 155, 1217, 691), 0.8) def to_player_info(self, position): + self.rgb_feature["friend_already_deleted"] = [[[position[0]-100, position[1]]], [[164, 184, 183, 203, 193, 213]]] img_ends = [ "friend_player-info", "friend_delete-friend-notice" @@ -77,22 +78,132 @@ def to_player_info(self, position): img_possibles = { "friend_friend-management-menu": (position[0] - 608, position[1] + 20) } - return picture.co_detect(self, None, None, img_ends, img_possibles, skip_first_screenshot=True) + return picture.co_detect(self, "friend_already_deleted", None, img_ends, img_possibles, skip_first_screenshot=True) + +def select_player_info(self, tp): + self.logger.info(f"Player Info To Page [ {tp} ].") + p = { + "profile": (538, 160), + "progress": (718, 160), + "assistant": (900, 160), + } + p = p[tp] + img_ends = f"friend_player-info-{tp}-selected" + img_possibles = { + 'friend_player-info-profile-selected': p, + 'friend_player-info-progress-selected': p, + 'friend_player-info-assistant-selected': p, + } + img_possibles.pop(img_ends) + picture.co_detect(self, None, None, img_ends, img_possibles, skip_first_screenshot=True) -def check_name_in_white_list(self): +def get_friend_data(self, position): + self.logger.info("Get Friend Data") + d = { + 'id': None, + 'level': None, + 'last_login_days': None, + 'last_total_assault_rank': None + } + + # last_login_days + offset = { + 'CN': (-444, 14, -316, 40), + 'Global': (-444, 14, -316, 40), + 'JP': (-436, 14, -337, 40), + } + region = ( + position[0] + offset[self.server][0], + position[1] + offset[self.server][1], + position[0] + offset[self.server][2], + position[1] + offset[self.server][3] + ) + + d['last_login_days'] = self.ocr.recognize_int(self, region, "Last Login Days") + + if to_player_info(self, position) == "friend_already_deleted": + self.logger.info("Possibly Due to Network Delay, this friend has already been deleted, skip.") + return False + select_player_info(self, 'profile') + # level + ocr_region = { + 'CN': (607, 218, 637, 241), + 'Global': (591, 221, 662, 241), + 'JP': (594, 220, 662, 241), + } + d['level'] = self.ocr.recognize_int(self, ocr_region[self.server], "Level") + + # id ocr_region = { 'CN': (711, 390, 796, 416), - 'Global': (711, 394, 823, 419), - 'JP': (680, 385, 747, 409), + 'Global': (711, 394, 855, 419), + 'JP': (715, 394, 855, 419), + } + self.last_friend_id = self.ocr.get_region_res(self, ocr_region[self.server], 'en-us', "Friend ID") + d['id'] = self.last_friend_id + + select_player_info(self, 'progress') + # last_total_assault_rank + ocr_region = { + 'CN': (383, 462, 543, 498), + 'Global': (383, 462, 543, 498), + 'JP': (383, 462, 543, 498), } - white_list = self.config.clear_friend_white_list - friend_id = self.ocr.get_region_res(self, ocr_region[self.server], 'en-us', "Friend ID") - self.last_friend_id = friend_id - if friend_id in white_list: - self.logger.info("In White List") - return True - self.logger.info("Not In White List") + text = self.ocr.get_region_res(self, ocr_region[self.server], self.ocr_language, "Last Total Assault Rank") + match = re.search(r'(\d+)', text) + rank = 999999 + if match: + rank = int(match.group(1)) + d['last_total_assault_rank'] = rank + + to_friend_management(self) + + self.logger.info("ID : " + self.last_friend_id) + self.logger.info("Level : " + str(d['level'])) + self.logger.info("Last Login Days : " + str(d['last_login_days'])) + self.logger.info("Last Total Assault Rank : " + str(d['last_total_assault_rank'])) + return d + +def judge_need_delete(self, friend_data): + # Do not delete friend in white list + _id = friend_data['id'] + if _id is not None: + if _id in self.config.clear_friend_white_list: + self.logger.info(f"[ {_id} ] In White List") + return False + self.logger.info(f"[ {_id} ] Not In White List") + + # last_login_days + last_login_days = friend_data['last_login_days'] + _max = self.config.clear_friend_last_login_time_days + if _max != -1 and last_login_days is not None: + if last_login_days >= _max: + self.logger.info(f"Last Login Days [ {last_login_days} ] >= [ {_max} ], Delete") + return True + else: + self.logger.info(f"Last Login Days [ {last_login_days} ] < [ {_max} ]") + + # last_total_assault_rank + last_rank = friend_data['last_total_assault_rank'] + _min = self.config.clear_friend_last_total_assault_rank_limit + if _min != -1 and last_rank is not None: + if last_rank <= _min: + self.logger.info(f"Last Total Assault Rank [ {last_rank} ] <= [ {_min} ], Delete") + return True + else: + self.logger.info(f"Last Total Assault Rank [ {last_rank} ] > [ {_min} ]") + + # level + level = friend_data['level'] + _min_level = self.config.clear_friend_level_limit + if _min_level != -1 and level is not None: + if level <= _min_level: + self.logger.info(f"Level [ {level} ] <= [ {_min_level} ], Delete") + return True + else: + self.logger.info(f"Level [ {level} ] > [ {_min_level} ]") + return False diff --git a/module/lesson.py b/module/lesson.py index eb179e16d..6762c58e3 100644 --- a/module/lesson.py +++ b/module/lesson.py @@ -5,6 +5,7 @@ from core import color, picture, image from core.geometry.parallelogram import Parallelogram +from core.image import resize_ss_image, check_geometry_pixels from core.utils import build_possible_string_dict_and_length, most_similar_string, purchase_ticket_times_to_int @@ -384,9 +385,12 @@ def global_jp_get_lesson_each_region_status(self): [289, y_list[1]], [643, y_list[1]], [985, y_list[1]], [289, y_list[2]], [643, y_list[2]], [985, y_list[2]]] res = [] + + img_resized = resize_ss_image(self, (0, 0, 1280, 720)) + for i in range(0, 9): if color.rgb_in_range(self, pd_lo[i][0], pd_lo[i][1], 250, 255, 250, 255, 250, 255): - res.append(check_region_availability(self, i)) + res.append(check_region_availability(i, img_resized)) elif color.rgb_in_range(self, pd_lo[i][0], pd_lo[i][1], 31, 160, 31, 160, 31, 160): res.append("lock") elif color.rgb_in_range(self, pd_lo[i][0], pd_lo[i][1], 197, 217, 197, 217, 195,215): @@ -395,7 +399,7 @@ def global_jp_get_lesson_each_region_status(self): res.append("unknown") return res -def check_region_availability(self, region_cnt): +def check_region_availability(region_cnt, img): k1 = 0 dx1 = 33 k2 = -5.3 @@ -408,19 +412,12 @@ def check_region_availability(self, region_cnt): ] dx2 = dx2[int(region_cnt / 6)] start_p = region_start_p[region_cnt] - y_min, x_min_list, y_min_list = Parallelogram(start_p[0], start_p[1], k1, dx1, k2, dx2).pixels() - - unavailable_max_pixel = 140 - cnt = 0 - for i in range(0, len(x_min_list)): - for j in range(x_min_list[i], y_min_list[i] + 1): - if not color.rgb_in_range(self, j, y_min + i, 0, unavailable_max_pixel, 0, unavailable_max_pixel, 0, unavailable_max_pixel): - # self.latest_img_array[y_min + i, j] = [255, 0, 0] # mark unavailable area - cnt += 1 - if cnt >= 50: - return "available" - return "done" + para = Parallelogram(start_p[0], start_p[1], k1, dx1, k2, dx2) + pixel_threshold = 140 + if check_geometry_pixels(img, para, (0, pixel_threshold, 0, pixel_threshold, 0, pixel_threshold), 50): + return "done" + return "available" def out_lesson_status(self, res): diff --git a/module/restart.py b/module/restart.py index b2e36da74..ffa506d0a 100644 --- a/module/restart.py +++ b/module/restart.py @@ -1,6 +1,8 @@ import time from datetime import datetime +from core.utils import host_platform_is_android + def implement(self): if not self.is_android_device: @@ -24,10 +26,10 @@ def implement(self): def start(self): self.logger.info("-- START BLUE ARCHIVE --") - activity_name = self.activity_name - if self.server == 'CN': - activity_name = None - self.u2.app_start(self.package_name, activity_name) + if host_platform_is_android(): + self.u2.shell(f"am start --display {self.target_display.logical_id} -n {self.package_name}/{self.activity_name}") + else: + self.u2.app_start(self.package_name, self.activity_name) time.sleep(1) if self.server == 'Global': self.to_main_page() diff --git a/src/images/CN/final_restriction_rls/best-record.png b/src/images/CN/final_restriction_rls/best-record.png new file mode 100644 index 000000000..9cc01761e Binary files /dev/null and b/src/images/CN/final_restriction_rls/best-record.png differ diff --git a/src/images/CN/final_restriction_rls/button-enter-stage.png b/src/images/CN/final_restriction_rls/button-enter-stage.png new file mode 100644 index 000000000..d670c3c87 Binary files /dev/null and b/src/images/CN/final_restriction_rls/button-enter-stage.png differ diff --git a/src/images/CN/final_restriction_rls/button-return-to-select-stage.png b/src/images/CN/final_restriction_rls/button-return-to-select-stage.png new file mode 100644 index 000000000..08e3d69ca Binary files /dev/null and b/src/images/CN/final_restriction_rls/button-return-to-select-stage.png differ diff --git a/src/images/CN/final_restriction_rls/button-select-stage.png b/src/images/CN/final_restriction_rls/button-select-stage.png new file mode 100644 index 000000000..6f7735a0d Binary files /dev/null and b/src/images/CN/final_restriction_rls/button-select-stage.png differ diff --git a/src/images/CN/final_restriction_rls/clear-unit-empty-student.png b/src/images/CN/final_restriction_rls/clear-unit-empty-student.png new file mode 100644 index 000000000..5328b8565 Binary files /dev/null and b/src/images/CN/final_restriction_rls/clear-unit-empty-student.png differ diff --git a/src/images/CN/final_restriction_rls/clear-unit-menu.png b/src/images/CN/final_restriction_rls/clear-unit-menu.png new file mode 100644 index 000000000..df472b253 Binary files /dev/null and b/src/images/CN/final_restriction_rls/clear-unit-menu.png differ diff --git a/src/images/CN/final_restriction_rls/copy-clear-unit-student-unavailable-notice.png b/src/images/CN/final_restriction_rls/copy-clear-unit-student-unavailable-notice.png new file mode 100644 index 000000000..d72b66e5d Binary files /dev/null and b/src/images/CN/final_restriction_rls/copy-clear-unit-student-unavailable-notice.png differ diff --git a/src/images/CN/final_restriction_rls/formation-menu.png b/src/images/CN/final_restriction_rls/formation-menu.png new file mode 100644 index 000000000..d8eb5795c Binary files /dev/null and b/src/images/CN/final_restriction_rls/formation-menu.png differ diff --git a/src/images/CN/final_restriction_rls/formation-overwrite-menu.png b/src/images/CN/final_restriction_rls/formation-overwrite-menu.png new file mode 100644 index 000000000..6e03e35dd Binary files /dev/null and b/src/images/CN/final_restriction_rls/formation-overwrite-menu.png differ diff --git a/src/images/CN/final_restriction_rls/formation-overwrite-notice.png b/src/images/CN/final_restriction_rls/formation-overwrite-notice.png new file mode 100644 index 000000000..8227ab34b Binary files /dev/null and b/src/images/CN/final_restriction_rls/formation-overwrite-notice.png differ diff --git a/src/images/CN/final_restriction_rls/formation-overwrite-success-notice.png b/src/images/CN/final_restriction_rls/formation-overwrite-success-notice.png new file mode 100644 index 000000000..f3e471e4f Binary files /dev/null and b/src/images/CN/final_restriction_rls/formation-overwrite-success-notice.png differ diff --git a/src/images/CN/final_restriction_rls/menu.png b/src/images/CN/final_restriction_rls/menu.png new file mode 100644 index 000000000..3dc1442fa Binary files /dev/null and b/src/images/CN/final_restriction_rls/menu.png differ diff --git a/src/images/CN/final_restriction_rls/reward-details.png b/src/images/CN/final_restriction_rls/reward-details.png new file mode 100644 index 000000000..d5453feb9 Binary files /dev/null and b/src/images/CN/final_restriction_rls/reward-details.png differ diff --git a/src/images/CN/final_restriction_rls/state-open.png b/src/images/CN/final_restriction_rls/state-open.png new file mode 100644 index 000000000..9ce0c634d Binary files /dev/null and b/src/images/CN/final_restriction_rls/state-open.png differ diff --git a/src/images/CN/friend/player-info-assistant-selected.png b/src/images/CN/friend/player-info-assistant-selected.png new file mode 100644 index 000000000..4a375accf Binary files /dev/null and b/src/images/CN/friend/player-info-assistant-selected.png differ diff --git a/src/images/CN/friend/player-info-profile-selected.png b/src/images/CN/friend/player-info-profile-selected.png new file mode 100644 index 000000000..00d028a9f Binary files /dev/null and b/src/images/CN/friend/player-info-profile-selected.png differ diff --git a/src/images/CN/friend/player-info-progress-selected.png b/src/images/CN/friend/player-info-progress-selected.png new file mode 100644 index 000000000..e3d606162 Binary files /dev/null and b/src/images/CN/friend/player-info-progress-selected.png differ diff --git a/src/images/CN/x_y_range/final_restriction_rls.py b/src/images/CN/x_y_range/final_restriction_rls.py new file mode 100644 index 000000000..4695a247b --- /dev/null +++ b/src/images/CN/x_y_range/final_restriction_rls.py @@ -0,0 +1,25 @@ +prefix = "final_restriction_rls" +path = "final_restriction_rls" +x_y_range = { + "menu": (107, 9, 280, 36), + + "state-open": (956, 564, 1019, 598), + "state-close": (), + + "button-return-to-select-stage": (57, 105, 181, 135), + "button-select-stage": (157, 512, 206, 540), + "button-enter-stage": (1107, 640, 1171, 670), + + "clear-unit-menu": (566, 120, 718, 155), + "clear-unit-empty-student": (), + + "formation-menu": (107, 9, 338, 36), + + "formation-overwrite-menu": (602, 79, 721, 124), + "formation-overwrite-notice": (562, 79, 719, 124), + "formation-overwrite-success-notice": (525, 312, 743, 350), + "copy-clear-unit-student-unavailable-notice": (465, 300, 803, 370), + + "best-record": (556, 134, 721, 172), + "reward-details": (540, 90, 746, 124) +} diff --git a/src/images/CN/x_y_range/friend.py b/src/images/CN/x_y_range/friend.py index 0cd6a3acf..dc556ef17 100644 --- a/src/images/CN/x_y_range/friend.py +++ b/src/images/CN/x_y_range/friend.py @@ -4,5 +4,8 @@ 'friend-management-menu': (100, 8, 261, 37), 'player-info': (571, 82, 704, 121), 'delete-friend-notice': (487, 315, 772, 347), - 'delete-friend': (1100, 225, 1186, 258) + 'delete-friend': (1100, 225, 1186, 258), + 'player-info-profile-selected': (429, 162, 484, 193), + 'player-info-progress-selected': (595, 162, 688, 193), + 'player-info-assistant-selected': (784, 162, 859, 193) } diff --git a/src/images/Global_en-us/final_restriction_rls/best-record.png b/src/images/Global_en-us/final_restriction_rls/best-record.png new file mode 100644 index 000000000..695561054 Binary files /dev/null and b/src/images/Global_en-us/final_restriction_rls/best-record.png differ diff --git a/src/images/Global_en-us/final_restriction_rls/button-enter-stage.png b/src/images/Global_en-us/final_restriction_rls/button-enter-stage.png new file mode 100644 index 000000000..7a13d3dd7 Binary files /dev/null and b/src/images/Global_en-us/final_restriction_rls/button-enter-stage.png differ diff --git a/src/images/Global_en-us/final_restriction_rls/button-return-to-select-stage.png b/src/images/Global_en-us/final_restriction_rls/button-return-to-select-stage.png new file mode 100644 index 000000000..ebd8b31e6 Binary files /dev/null and b/src/images/Global_en-us/final_restriction_rls/button-return-to-select-stage.png differ diff --git a/src/images/Global_en-us/final_restriction_rls/button-select-stage.png b/src/images/Global_en-us/final_restriction_rls/button-select-stage.png new file mode 100644 index 000000000..3baa968cd Binary files /dev/null and b/src/images/Global_en-us/final_restriction_rls/button-select-stage.png differ diff --git a/src/images/Global_en-us/final_restriction_rls/clear-unit-empty-student.png b/src/images/Global_en-us/final_restriction_rls/clear-unit-empty-student.png new file mode 100644 index 000000000..43b8460fb Binary files /dev/null and b/src/images/Global_en-us/final_restriction_rls/clear-unit-empty-student.png differ diff --git a/src/images/Global_en-us/final_restriction_rls/clear-unit-menu.png b/src/images/Global_en-us/final_restriction_rls/clear-unit-menu.png new file mode 100644 index 000000000..60fd11889 Binary files /dev/null and b/src/images/Global_en-us/final_restriction_rls/clear-unit-menu.png differ diff --git a/src/images/Global_en-us/final_restriction_rls/copy-clear-unit-student-unavailable-notice.png b/src/images/Global_en-us/final_restriction_rls/copy-clear-unit-student-unavailable-notice.png new file mode 100644 index 000000000..7e0c7ba6d Binary files /dev/null and b/src/images/Global_en-us/final_restriction_rls/copy-clear-unit-student-unavailable-notice.png differ diff --git a/src/images/Global_en-us/final_restriction_rls/formation-menu.png b/src/images/Global_en-us/final_restriction_rls/formation-menu.png new file mode 100644 index 000000000..ae07f6f20 Binary files /dev/null and b/src/images/Global_en-us/final_restriction_rls/formation-menu.png differ diff --git a/src/images/Global_en-us/final_restriction_rls/formation-overwrite-menu.png b/src/images/Global_en-us/final_restriction_rls/formation-overwrite-menu.png new file mode 100644 index 000000000..bc646d83b Binary files /dev/null and b/src/images/Global_en-us/final_restriction_rls/formation-overwrite-menu.png differ diff --git a/src/images/Global_en-us/final_restriction_rls/formation-overwrite-notice.png b/src/images/Global_en-us/final_restriction_rls/formation-overwrite-notice.png new file mode 100644 index 000000000..9efadcc2c Binary files /dev/null and b/src/images/Global_en-us/final_restriction_rls/formation-overwrite-notice.png differ diff --git a/src/images/Global_en-us/final_restriction_rls/formation-overwrite-success-notice.png b/src/images/Global_en-us/final_restriction_rls/formation-overwrite-success-notice.png new file mode 100644 index 000000000..77e46b160 Binary files /dev/null and b/src/images/Global_en-us/final_restriction_rls/formation-overwrite-success-notice.png differ diff --git a/src/images/Global_en-us/final_restriction_rls/menu.png b/src/images/Global_en-us/final_restriction_rls/menu.png new file mode 100644 index 000000000..7ca8bdaa2 Binary files /dev/null and b/src/images/Global_en-us/final_restriction_rls/menu.png differ diff --git a/src/images/Global_en-us/final_restriction_rls/reward-details.png b/src/images/Global_en-us/final_restriction_rls/reward-details.png new file mode 100644 index 000000000..99ec24593 Binary files /dev/null and b/src/images/Global_en-us/final_restriction_rls/reward-details.png differ diff --git a/src/images/Global_en-us/final_restriction_rls/state-open.png b/src/images/Global_en-us/final_restriction_rls/state-open.png new file mode 100644 index 000000000..622774c73 Binary files /dev/null and b/src/images/Global_en-us/final_restriction_rls/state-open.png differ diff --git a/src/images/Global_en-us/friend/player-info-assistant-selected.png b/src/images/Global_en-us/friend/player-info-assistant-selected.png new file mode 100644 index 000000000..2ec7b03c9 Binary files /dev/null and b/src/images/Global_en-us/friend/player-info-assistant-selected.png differ diff --git a/src/images/Global_en-us/friend/player-info-profile-selected.png b/src/images/Global_en-us/friend/player-info-profile-selected.png new file mode 100644 index 000000000..447df7095 Binary files /dev/null and b/src/images/Global_en-us/friend/player-info-profile-selected.png differ diff --git a/src/images/Global_en-us/friend/player-info-progress-selected.png b/src/images/Global_en-us/friend/player-info-progress-selected.png new file mode 100644 index 000000000..47be953ca Binary files /dev/null and b/src/images/Global_en-us/friend/player-info-progress-selected.png differ diff --git a/src/images/Global_en-us/main_page/skip-notice.png b/src/images/Global_en-us/main_page/skip-notice.png index 0c429a889..e04ef2c55 100644 Binary files a/src/images/Global_en-us/main_page/skip-notice.png and b/src/images/Global_en-us/main_page/skip-notice.png differ diff --git a/src/images/Global_en-us/x_y_range/final_restriction_rls.py b/src/images/Global_en-us/x_y_range/final_restriction_rls.py new file mode 100644 index 000000000..a976b7d5c --- /dev/null +++ b/src/images/Global_en-us/x_y_range/final_restriction_rls.py @@ -0,0 +1,27 @@ +prefix = "final_restriction_rls" +path = "final_restriction_rls" +x_y_range = { + "menu": (107, 9, 414, 36), + + "state-open": (940, 564, 1030, 598), + "state-close": (), + + "button-return-to-select-stage": (57, 105, 181, 135), + "button-select-stage": (157, 512, 206, 540), + "button-enter-stage": (1107, 640, 1171, 670), + + "clear-unit-menu": (566, 120, 718, 155), + "clear-unit-empty-student": (), + + "formation-menu": (107, 9, 490, 36), + + "formation-overwrite-menu": (553, 79, 766, 124), + "formation-overwrite-notice": (527, 79, 756, 124), + "formation-overwrite-success-notice": (396, 317, 882, 354), + "copy-clear-unit-student-unavailable-notice": (395, 285, 881, 388), + + "best-record": (550, 134, 730, 172), + "reward-details": (569, 90, 717, 124) + +} + diff --git a/src/images/Global_en-us/x_y_range/friend.py b/src/images/Global_en-us/x_y_range/friend.py index 2f60991c1..3cd97b50c 100644 --- a/src/images/Global_en-us/x_y_range/friend.py +++ b/src/images/Global_en-us/x_y_range/friend.py @@ -4,5 +4,8 @@ 'friend-management-menu': (100, 8, 261, 37), 'player-info': (547, 86, 723, 121), 'delete-friend-notice': (487, 315, 772, 347), - 'delete-friend': (1100, 225, 1186, 258) + 'delete-friend': (1100, 225, 1186, 258), + 'player-info-profile-selected': (422, 162, 495, 193), + 'player-info-progress-selected': (562, 162, 720, 193), + 'player-info-assistant-selected': (771, 162, 877, 193) } diff --git a/src/images/Global_ko-kr/final_restriction_rls/best-record.png b/src/images/Global_ko-kr/final_restriction_rls/best-record.png new file mode 100644 index 000000000..0e111c6f7 Binary files /dev/null and b/src/images/Global_ko-kr/final_restriction_rls/best-record.png differ diff --git a/src/images/Global_ko-kr/final_restriction_rls/button-enter-stage.png b/src/images/Global_ko-kr/final_restriction_rls/button-enter-stage.png new file mode 100644 index 000000000..c5ddfd35a Binary files /dev/null and b/src/images/Global_ko-kr/final_restriction_rls/button-enter-stage.png differ diff --git a/src/images/Global_ko-kr/final_restriction_rls/button-return-to-select-stage.png b/src/images/Global_ko-kr/final_restriction_rls/button-return-to-select-stage.png new file mode 100644 index 000000000..476e76853 Binary files /dev/null and b/src/images/Global_ko-kr/final_restriction_rls/button-return-to-select-stage.png differ diff --git a/src/images/Global_ko-kr/final_restriction_rls/button-select-stage.png b/src/images/Global_ko-kr/final_restriction_rls/button-select-stage.png new file mode 100644 index 000000000..270de8a31 Binary files /dev/null and b/src/images/Global_ko-kr/final_restriction_rls/button-select-stage.png differ diff --git a/src/images/Global_ko-kr/final_restriction_rls/clear-unit-empty-student.png b/src/images/Global_ko-kr/final_restriction_rls/clear-unit-empty-student.png new file mode 100644 index 000000000..dcfd3934e Binary files /dev/null and b/src/images/Global_ko-kr/final_restriction_rls/clear-unit-empty-student.png differ diff --git a/src/images/Global_ko-kr/final_restriction_rls/clear-unit-menu.png b/src/images/Global_ko-kr/final_restriction_rls/clear-unit-menu.png new file mode 100644 index 000000000..d8d59e007 Binary files /dev/null and b/src/images/Global_ko-kr/final_restriction_rls/clear-unit-menu.png differ diff --git a/src/images/Global_ko-kr/final_restriction_rls/copy-clear-unit-student-unavailable-notice.png b/src/images/Global_ko-kr/final_restriction_rls/copy-clear-unit-student-unavailable-notice.png new file mode 100644 index 000000000..6877d308a Binary files /dev/null and b/src/images/Global_ko-kr/final_restriction_rls/copy-clear-unit-student-unavailable-notice.png differ diff --git a/src/images/Global_ko-kr/final_restriction_rls/formation-menu.png b/src/images/Global_ko-kr/final_restriction_rls/formation-menu.png new file mode 100644 index 000000000..47b03c653 Binary files /dev/null and b/src/images/Global_ko-kr/final_restriction_rls/formation-menu.png differ diff --git a/src/images/Global_ko-kr/final_restriction_rls/formation-overwrite-menu.png b/src/images/Global_ko-kr/final_restriction_rls/formation-overwrite-menu.png new file mode 100644 index 000000000..adcd73226 Binary files /dev/null and b/src/images/Global_ko-kr/final_restriction_rls/formation-overwrite-menu.png differ diff --git a/src/images/Global_ko-kr/final_restriction_rls/formation-overwrite-notice.png b/src/images/Global_ko-kr/final_restriction_rls/formation-overwrite-notice.png new file mode 100644 index 000000000..c7416167d Binary files /dev/null and b/src/images/Global_ko-kr/final_restriction_rls/formation-overwrite-notice.png differ diff --git a/src/images/Global_ko-kr/final_restriction_rls/formation-overwrite-success-notice.png b/src/images/Global_ko-kr/final_restriction_rls/formation-overwrite-success-notice.png new file mode 100644 index 000000000..d2f5a1be2 Binary files /dev/null and b/src/images/Global_ko-kr/final_restriction_rls/formation-overwrite-success-notice.png differ diff --git a/src/images/Global_ko-kr/final_restriction_rls/menu.png b/src/images/Global_ko-kr/final_restriction_rls/menu.png new file mode 100644 index 000000000..9c7330385 Binary files /dev/null and b/src/images/Global_ko-kr/final_restriction_rls/menu.png differ diff --git a/src/images/Global_ko-kr/final_restriction_rls/reward-details.png b/src/images/Global_ko-kr/final_restriction_rls/reward-details.png new file mode 100644 index 000000000..965e79e47 Binary files /dev/null and b/src/images/Global_ko-kr/final_restriction_rls/reward-details.png differ diff --git a/src/images/Global_ko-kr/final_restriction_rls/state-open.png b/src/images/Global_ko-kr/final_restriction_rls/state-open.png new file mode 100644 index 000000000..0e42295aa Binary files /dev/null and b/src/images/Global_ko-kr/final_restriction_rls/state-open.png differ diff --git a/src/images/Global_ko-kr/friend/player-info-assistant-selected.png b/src/images/Global_ko-kr/friend/player-info-assistant-selected.png new file mode 100644 index 000000000..ea15160ee Binary files /dev/null and b/src/images/Global_ko-kr/friend/player-info-assistant-selected.png differ diff --git a/src/images/Global_ko-kr/friend/player-info-profile-selected.png b/src/images/Global_ko-kr/friend/player-info-profile-selected.png new file mode 100644 index 000000000..5575c83dd Binary files /dev/null and b/src/images/Global_ko-kr/friend/player-info-profile-selected.png differ diff --git a/src/images/Global_ko-kr/friend/player-info-progress-selected.png b/src/images/Global_ko-kr/friend/player-info-progress-selected.png new file mode 100644 index 000000000..b470e5a99 Binary files /dev/null and b/src/images/Global_ko-kr/friend/player-info-progress-selected.png differ diff --git a/src/images/Global_ko-kr/main_page/skip-notice.png b/src/images/Global_ko-kr/main_page/skip-notice.png index 5653a7fbb..713bfc05c 100644 Binary files a/src/images/Global_ko-kr/main_page/skip-notice.png and b/src/images/Global_ko-kr/main_page/skip-notice.png differ diff --git a/src/images/Global_ko-kr/x_y_range/final_restriction_rls.py b/src/images/Global_ko-kr/x_y_range/final_restriction_rls.py new file mode 100644 index 000000000..64e2501ed --- /dev/null +++ b/src/images/Global_ko-kr/x_y_range/final_restriction_rls.py @@ -0,0 +1,26 @@ +prefix = "final_restriction_rls" +path = "final_restriction_rls" +x_y_range = { + "menu": (107, 9, 300, 36), + + "state-open": (956, 564, 1019, 598), + "state-close": (), + + "button-return-to-select-stage": (57, 105, 181, 135), + "button-select-stage": (157, 512, 206, 540), + "button-enter-stage": (1107, 640, 1171, 670), + + "clear-unit-menu": (566, 120, 718, 155), + "clear-unit-empty-student": (), + + "formation-menu": (107, 9, 350, 36), + + "formation-overwrite-menu": (590, 79, 736, 124), + "formation-overwrite-notice": (562, 79, 719, 124), + "formation-overwrite-success-notice": (424, 317, 842, 354), + "copy-clear-unit-student-unavailable-notice": (395, 300, 866, 370), + + "best-record": (550, 134, 730, 172), + "reward-details": (532, 90, 749, 124) + +} diff --git a/src/images/Global_ko-kr/x_y_range/friend.py b/src/images/Global_ko-kr/x_y_range/friend.py index c9fbf2fdc..273383b3e 100644 --- a/src/images/Global_ko-kr/x_y_range/friend.py +++ b/src/images/Global_ko-kr/x_y_range/friend.py @@ -4,5 +4,8 @@ 'friend-management-menu': (100, 8, 191, 37), 'player-info': (547, 86, 723, 121), 'delete-friend-notice': (457, 315, 812, 347), - 'delete-friend': (1100, 225, 1186, 258) + 'delete-friend': (1100, 225, 1186, 258), + 'player-info-profile-selected': (425, 162, 485, 193), + 'player-info-progress-selected': (593, 162, 685, 193), + 'player-info-assistant-selected': (782, 162, 852, 193) } diff --git a/src/images/Global_ko-kr/x_y_range/main_page.py b/src/images/Global_ko-kr/x_y_range/main_page.py index 3f184e32f..e6c8c5a8b 100644 --- a/src/images/Global_ko-kr/x_y_range/main_page.py +++ b/src/images/Global_ko-kr/x_y_range/main_page.py @@ -9,7 +9,7 @@ 'login-feature': (40, 625, 96, 649), 'news': (38, 85, 70, 114), 'daily-attendance': (540, 90, 1100, 174), - 'skip-notice': (589, 309, 701, 353), + 'skip-notice': (538, 309, 740, 353), 'full-notice': (407, 282, 873, 381), 'back-arrow': (37, 20, 80, 57), "enter-existing-fight": (466, 293, 805, 370), diff --git a/src/images/Global_zh-tw/final_restriction_rls/best-record.png b/src/images/Global_zh-tw/final_restriction_rls/best-record.png new file mode 100644 index 000000000..eeb5fde33 Binary files /dev/null and b/src/images/Global_zh-tw/final_restriction_rls/best-record.png differ diff --git a/src/images/Global_zh-tw/final_restriction_rls/button-enter-stage.png b/src/images/Global_zh-tw/final_restriction_rls/button-enter-stage.png new file mode 100644 index 000000000..c3269d854 Binary files /dev/null and b/src/images/Global_zh-tw/final_restriction_rls/button-enter-stage.png differ diff --git a/src/images/Global_zh-tw/final_restriction_rls/button-return-to-select-stage.png b/src/images/Global_zh-tw/final_restriction_rls/button-return-to-select-stage.png new file mode 100644 index 000000000..c6090b1fc Binary files /dev/null and b/src/images/Global_zh-tw/final_restriction_rls/button-return-to-select-stage.png differ diff --git a/src/images/Global_zh-tw/final_restriction_rls/button-select-stage.png b/src/images/Global_zh-tw/final_restriction_rls/button-select-stage.png new file mode 100644 index 000000000..51ca0ba23 Binary files /dev/null and b/src/images/Global_zh-tw/final_restriction_rls/button-select-stage.png differ diff --git a/src/images/Global_zh-tw/final_restriction_rls/clear-unit-menu.png b/src/images/Global_zh-tw/final_restriction_rls/clear-unit-menu.png new file mode 100644 index 000000000..2f3308c16 Binary files /dev/null and b/src/images/Global_zh-tw/final_restriction_rls/clear-unit-menu.png differ diff --git a/src/images/Global_zh-tw/final_restriction_rls/copy-clear-unit-student-unavailable-notice.png b/src/images/Global_zh-tw/final_restriction_rls/copy-clear-unit-student-unavailable-notice.png new file mode 100644 index 000000000..78bfaa7c0 Binary files /dev/null and b/src/images/Global_zh-tw/final_restriction_rls/copy-clear-unit-student-unavailable-notice.png differ diff --git a/src/images/Global_zh-tw/final_restriction_rls/formation-menu.png b/src/images/Global_zh-tw/final_restriction_rls/formation-menu.png new file mode 100644 index 000000000..ff9a284fd Binary files /dev/null and b/src/images/Global_zh-tw/final_restriction_rls/formation-menu.png differ diff --git a/src/images/Global_zh-tw/final_restriction_rls/formation-overwrite-menu.png b/src/images/Global_zh-tw/final_restriction_rls/formation-overwrite-menu.png new file mode 100644 index 000000000..0884318e3 Binary files /dev/null and b/src/images/Global_zh-tw/final_restriction_rls/formation-overwrite-menu.png differ diff --git a/src/images/Global_zh-tw/final_restriction_rls/formation-overwrite-notice.png b/src/images/Global_zh-tw/final_restriction_rls/formation-overwrite-notice.png new file mode 100644 index 000000000..4967bce28 Binary files /dev/null and b/src/images/Global_zh-tw/final_restriction_rls/formation-overwrite-notice.png differ diff --git a/src/images/Global_zh-tw/final_restriction_rls/formation-overwrite-success-notice.png b/src/images/Global_zh-tw/final_restriction_rls/formation-overwrite-success-notice.png new file mode 100644 index 000000000..675312b32 Binary files /dev/null and b/src/images/Global_zh-tw/final_restriction_rls/formation-overwrite-success-notice.png differ diff --git a/src/images/Global_zh-tw/final_restriction_rls/menu.png b/src/images/Global_zh-tw/final_restriction_rls/menu.png new file mode 100644 index 000000000..90dbb4b66 Binary files /dev/null and b/src/images/Global_zh-tw/final_restriction_rls/menu.png differ diff --git a/src/images/Global_zh-tw/final_restriction_rls/reward-details.png b/src/images/Global_zh-tw/final_restriction_rls/reward-details.png new file mode 100644 index 000000000..d7ef56476 Binary files /dev/null and b/src/images/Global_zh-tw/final_restriction_rls/reward-details.png differ diff --git a/src/images/Global_zh-tw/final_restriction_rls/state-open.png b/src/images/Global_zh-tw/final_restriction_rls/state-open.png new file mode 100644 index 000000000..39f6ecb53 Binary files /dev/null and b/src/images/Global_zh-tw/final_restriction_rls/state-open.png differ diff --git a/src/images/Global_zh-tw/friend/player-info-assistant-selected.png b/src/images/Global_zh-tw/friend/player-info-assistant-selected.png new file mode 100644 index 000000000..fed7cf065 Binary files /dev/null and b/src/images/Global_zh-tw/friend/player-info-assistant-selected.png differ diff --git a/src/images/Global_zh-tw/friend/player-info-profile-selected.png b/src/images/Global_zh-tw/friend/player-info-profile-selected.png new file mode 100644 index 000000000..1c8620f05 Binary files /dev/null and b/src/images/Global_zh-tw/friend/player-info-profile-selected.png differ diff --git a/src/images/Global_zh-tw/friend/player-info-progress-selected.png b/src/images/Global_zh-tw/friend/player-info-progress-selected.png new file mode 100644 index 000000000..2d4eb82d0 Binary files /dev/null and b/src/images/Global_zh-tw/friend/player-info-progress-selected.png differ diff --git a/src/images/Global_zh-tw/main_page/skip-notice.png b/src/images/Global_zh-tw/main_page/skip-notice.png new file mode 100644 index 000000000..e2e47f96a Binary files /dev/null and b/src/images/Global_zh-tw/main_page/skip-notice.png differ diff --git a/src/images/Global_zh-tw/x_y_range/final_restriction_rls.py b/src/images/Global_zh-tw/x_y_range/final_restriction_rls.py new file mode 100644 index 000000000..cfa590ae7 --- /dev/null +++ b/src/images/Global_zh-tw/x_y_range/final_restriction_rls.py @@ -0,0 +1,27 @@ +prefix = "final_restriction_rls" +path = "final_restriction_rls" +x_y_range = { + "menu": (107, 9, 310, 36), + + "state-open": (940, 564, 1030, 598), + "state-close": (), + + "button-return-to-select-stage": (57, 105, 181, 135), + "button-select-stage": (157, 512, 206, 540), + "button-enter-stage": (1107, 640, 1171, 670), + + "clear-unit-menu": (566, 120, 718, 155), + "clear-unit-empty-student": (), + + "formation-menu": (107, 9, 370, 36), + + "formation-overwrite-menu": (606, 79, 720, 124), + "formation-overwrite-notice": (573, 79, 703, 124), + "formation-overwrite-success-notice": (505, 317, 768, 354), + "copy-clear-unit-student-unavailable-notice": (461, 299, 807, 370), + + "best-record": (550, 134, 730, 172), + "reward-details": (569, 90, 717, 124) + +} + diff --git a/src/images/Global_zh-tw/x_y_range/friend.py b/src/images/Global_zh-tw/x_y_range/friend.py index 4d35dcd28..7129ed113 100644 --- a/src/images/Global_zh-tw/x_y_range/friend.py +++ b/src/images/Global_zh-tw/x_y_range/friend.py @@ -4,5 +4,8 @@ 'friend-management-menu': (100, 8, 220, 37), 'player-info': (577, 86, 703, 121), 'delete-friend-notice': (487, 315, 782, 347), - 'delete-friend': (1100, 225, 1186, 258) + 'delete-friend': (1100, 225, 1186, 258), + 'player-info-profile-selected': (433, 162, 479, 193), + 'player-info-progress-selected': (593, 162, 685, 193), + 'player-info-assistant-selected': (801, 162, 848, 193) } diff --git a/src/images/Global_zh-tw/x_y_range/main_page.py b/src/images/Global_zh-tw/x_y_range/main_page.py index 8f819bd42..f3f7f7bda 100644 --- a/src/images/Global_zh-tw/x_y_range/main_page.py +++ b/src/images/Global_zh-tw/x_y_range/main_page.py @@ -9,7 +9,7 @@ 'login-feature': (40, 625, 96, 649), 'news': (50, 102, 96, 144), 'daily-attendance': (540, 90, 1100, 174), - 'skip-notice': (589, 309, 701, 353), + 'skip-notice': (570, 309, 701, 353), 'full-notice': (397, 294, 871, 370), 'back-arrow': (37, 20, 80, 57), "enter-existing-fight": (516, 293, 755, 370), diff --git a/src/images/JP/final_restriction_rls/best-record.png b/src/images/JP/final_restriction_rls/best-record.png new file mode 100644 index 000000000..c48152ed6 Binary files /dev/null and b/src/images/JP/final_restriction_rls/best-record.png differ diff --git a/src/images/JP/final_restriction_rls/button-enter-stage.png b/src/images/JP/final_restriction_rls/button-enter-stage.png new file mode 100644 index 000000000..54a6d7812 Binary files /dev/null and b/src/images/JP/final_restriction_rls/button-enter-stage.png differ diff --git a/src/images/JP/final_restriction_rls/button-return-to-select-stage.png b/src/images/JP/final_restriction_rls/button-return-to-select-stage.png new file mode 100644 index 000000000..64c889736 Binary files /dev/null and b/src/images/JP/final_restriction_rls/button-return-to-select-stage.png differ diff --git a/src/images/JP/final_restriction_rls/button-select-stage.png b/src/images/JP/final_restriction_rls/button-select-stage.png new file mode 100644 index 000000000..3f0e7443b Binary files /dev/null and b/src/images/JP/final_restriction_rls/button-select-stage.png differ diff --git a/src/images/JP/final_restriction_rls/clear-unit-empty-student.png b/src/images/JP/final_restriction_rls/clear-unit-empty-student.png new file mode 100644 index 000000000..1a3799280 Binary files /dev/null and b/src/images/JP/final_restriction_rls/clear-unit-empty-student.png differ diff --git a/src/images/JP/final_restriction_rls/clear-unit-menu.png b/src/images/JP/final_restriction_rls/clear-unit-menu.png new file mode 100644 index 000000000..076a0ae30 Binary files /dev/null and b/src/images/JP/final_restriction_rls/clear-unit-menu.png differ diff --git a/src/images/JP/final_restriction_rls/copy-clear-unit-student-unavailable-notice.png b/src/images/JP/final_restriction_rls/copy-clear-unit-student-unavailable-notice.png new file mode 100644 index 000000000..6cb112ae0 Binary files /dev/null and b/src/images/JP/final_restriction_rls/copy-clear-unit-student-unavailable-notice.png differ diff --git a/src/images/JP/final_restriction_rls/formation-menu.png b/src/images/JP/final_restriction_rls/formation-menu.png new file mode 100644 index 000000000..c955043f2 Binary files /dev/null and b/src/images/JP/final_restriction_rls/formation-menu.png differ diff --git a/src/images/JP/final_restriction_rls/formation-overwrite-menu.png b/src/images/JP/final_restriction_rls/formation-overwrite-menu.png new file mode 100644 index 000000000..6a8863c7c Binary files /dev/null and b/src/images/JP/final_restriction_rls/formation-overwrite-menu.png differ diff --git a/src/images/JP/final_restriction_rls/formation-overwrite-notice.png b/src/images/JP/final_restriction_rls/formation-overwrite-notice.png new file mode 100644 index 000000000..5052d92bd Binary files /dev/null and b/src/images/JP/final_restriction_rls/formation-overwrite-notice.png differ diff --git a/src/images/JP/final_restriction_rls/formation-overwrite-success-notice.png b/src/images/JP/final_restriction_rls/formation-overwrite-success-notice.png new file mode 100644 index 000000000..a665c86d4 Binary files /dev/null and b/src/images/JP/final_restriction_rls/formation-overwrite-success-notice.png differ diff --git a/src/images/JP/final_restriction_rls/menu.png b/src/images/JP/final_restriction_rls/menu.png new file mode 100644 index 000000000..1a1bfee92 Binary files /dev/null and b/src/images/JP/final_restriction_rls/menu.png differ diff --git a/src/images/JP/final_restriction_rls/reward-details.png b/src/images/JP/final_restriction_rls/reward-details.png new file mode 100644 index 000000000..272d9f155 Binary files /dev/null and b/src/images/JP/final_restriction_rls/reward-details.png differ diff --git a/src/images/JP/final_restriction_rls/state-close.png b/src/images/JP/final_restriction_rls/state-close.png new file mode 100644 index 000000000..961239305 Binary files /dev/null and b/src/images/JP/final_restriction_rls/state-close.png differ diff --git a/src/images/JP/final_restriction_rls/state-open.png b/src/images/JP/final_restriction_rls/state-open.png new file mode 100644 index 000000000..5f39537ee Binary files /dev/null and b/src/images/JP/final_restriction_rls/state-open.png differ diff --git a/src/images/JP/friend/delete-friend-notice.png b/src/images/JP/friend/delete-friend-notice.png index b506d787f..a59abf2cb 100644 Binary files a/src/images/JP/friend/delete-friend-notice.png and b/src/images/JP/friend/delete-friend-notice.png differ diff --git a/src/images/JP/friend/delete-friend.png b/src/images/JP/friend/delete-friend.png new file mode 100644 index 000000000..91ad99273 Binary files /dev/null and b/src/images/JP/friend/delete-friend.png differ diff --git a/src/images/JP/friend/friend-management-menu.png b/src/images/JP/friend/friend-management-menu.png index 3a40a4703..41ce89c3b 100644 Binary files a/src/images/JP/friend/friend-management-menu.png and b/src/images/JP/friend/friend-management-menu.png differ diff --git a/src/images/JP/friend/friend-menu.png b/src/images/JP/friend/friend-menu.png deleted file mode 100644 index a6b4ead1e..000000000 Binary files a/src/images/JP/friend/friend-menu.png and /dev/null differ diff --git a/src/images/JP/friend/player-info-assistant-selected.png b/src/images/JP/friend/player-info-assistant-selected.png new file mode 100644 index 000000000..9a062b9c3 Binary files /dev/null and b/src/images/JP/friend/player-info-assistant-selected.png differ diff --git a/src/images/JP/friend/player-info-profile-selected.png b/src/images/JP/friend/player-info-profile-selected.png new file mode 100644 index 000000000..4ca98f2ec Binary files /dev/null and b/src/images/JP/friend/player-info-profile-selected.png differ diff --git a/src/images/JP/friend/player-info-progress-selected.png b/src/images/JP/friend/player-info-progress-selected.png new file mode 100644 index 000000000..8c2aff672 Binary files /dev/null and b/src/images/JP/friend/player-info-progress-selected.png differ diff --git a/src/images/JP/friend/player-info.png b/src/images/JP/friend/player-info.png index c7e7f4e7a..87b02fe10 100644 Binary files a/src/images/JP/friend/player-info.png and b/src/images/JP/friend/player-info.png differ diff --git a/src/images/JP/main_page/skip-notice.png b/src/images/JP/main_page/skip-notice.png index da9c5d584..ef9816a3b 100644 Binary files a/src/images/JP/main_page/skip-notice.png and b/src/images/JP/main_page/skip-notice.png differ diff --git a/src/images/JP/normal_task/formation-edit-preset-name.png b/src/images/JP/normal_task/formation-edit-preset-name.png index 5f49ea413..75f51cc03 100644 Binary files a/src/images/JP/normal_task/formation-edit-preset-name.png and b/src/images/JP/normal_task/formation-edit-preset-name.png differ diff --git a/src/images/JP/normal_task/formation-preset.png b/src/images/JP/normal_task/formation-preset.png index 5f1866963..179b31b5f 100644 Binary files a/src/images/JP/normal_task/formation-preset.png and b/src/images/JP/normal_task/formation-preset.png differ diff --git a/src/images/JP/normal_task/formation-set-confirm.png b/src/images/JP/normal_task/formation-set-confirm.png index 43aa2f76e..a2d1b0a52 100644 Binary files a/src/images/JP/normal_task/formation-set-confirm.png and b/src/images/JP/normal_task/formation-set-confirm.png differ diff --git a/src/images/JP/x_y_range/final_restriction_rls.py b/src/images/JP/x_y_range/final_restriction_rls.py new file mode 100644 index 000000000..e10130fb5 --- /dev/null +++ b/src/images/JP/x_y_range/final_restriction_rls.py @@ -0,0 +1,25 @@ +prefix = "final_restriction_rls" +path = "final_restriction_rls" +x_y_range = { + "menu": (107, 9, 300, 36), + + "state-open": (956, 564, 1019, 598), + "state-close": (512, 343, 754, 375), + + "button-return-to-select-stage": (57, 105, 181, 135), + "button-select-stage": (157, 512, 206, 540), + "button-enter-stage": (1107, 640, 1171, 670), + + "clear-unit-menu": (566, 120, 718, 155), + "clear-unit-empty-student": (), + + "formation-menu": (107, 9, 350, 36), + + "formation-overwrite-menu": (596, 79, 733, 124), + "formation-overwrite-notice": (562, 79, 719, 124), + "formation-overwrite-success-notice": (424, 317, 842, 354), + "copy-clear-unit-student-unavailable-notice": (395, 300, 866, 370), + + "best-record": (551, 134, 731, 172), + "reward-details": (470, 90, 816, 124) +} diff --git a/src/images/JP/x_y_range/friend.py b/src/images/JP/x_y_range/friend.py new file mode 100644 index 000000000..1e43e0838 --- /dev/null +++ b/src/images/JP/x_y_range/friend.py @@ -0,0 +1,11 @@ +prefix = "friend" +path = "friend" +x_y_range = { + 'friend-management-menu': (100, 8, 212, 37), + 'player-info': (551, 82, 732, 121), + 'delete-friend-notice': (434, 315, 837, 354), + 'delete-friend': (1087, 225, 1201, 258), + 'player-info-profile-selected': (390, 162, 523, 193), + 'player-info-progress-selected': (595, 162, 688, 193), + 'player-info-assistant-selected': (784, 162, 859, 193) +} diff --git a/src/images/JP/x_y_range/main_page.py b/src/images/JP/x_y_range/main_page.py index c40da4b84..0ffdabd5b 100644 --- a/src/images/JP/x_y_range/main_page.py +++ b/src/images/JP/x_y_range/main_page.py @@ -1,11 +1,11 @@ prefix = "main_page" path = "main_page" x_y_range = { - 'bus': (107, 9, 190, 36), # 业务区 - 'home-feature': (1203, 24, 1240, 60), # 右上角菜单(作为主页标志) - 'quick-home': (1215, 5, 1255, 42), # 快速回到首页,右上 - # 'login-feature': (1105, 601, 1142, 640), # 登录界面 - 'skip-notice': (509, 309, 761, 353), # 跳过公告 + 'bus': (107, 9, 190, 36), + 'home-feature': (1203, 24, 1240, 60), + 'quick-home': (1215, 5, 1255, 42), + # 'login-feature': (1105, 601, 1142, 640), + 'skip-notice': (509, 309, 761, 353), 'insufficient-inventory-space': (344, 391, 894, 453), 'daily-attendance': (540, 90, 1100, 174), 'news': (250, 85, 328, 117), diff --git a/src/rgb_feature/CN.json b/src/rgb_feature/CN.json index 4e102aa31..ab741dc99 100644 --- a/src/rgb_feature/CN.json +++ b/src/rgb_feature/CN.json @@ -35,6 +35,8 @@ "preset_choose4": [[[646, 153]], [[40, 50, 70, 80, 110, 120]]], "no-goal-task_passed": [[[148, 522]],[[232, 255,219, 255, 0, 30]]], "explore-task-grid-mode": [[[239, 183], [527, 183], [740, 183], [1019, 183]], [[235, 255, 235, 255, 235, 255], [235, 255, 235 ,255, 235, 255], [159, 179, 214, 234, 235, 255], [159, 179, 214, 234, 235, 255]]], - "explore-task-simple-mode": [[[239, 183], [527, 183], [740, 183], [1019, 183]], [[186, 206, 238, 255, 168, 188], [186, 206, 238, 255, 168, 188], [235, 255, 235 ,255, 235, 255], [235, 255, 235 ,255, 235, 255]]] + "explore-task-simple-mode": [[[239, 183], [527, 183], [740, 183], [1019, 183]], [[186, 206, 238, 255, 168, 188], [186, 206, 238, 255, 168, 188], [235, 255, 235 ,255, 235, 255], [235, 255, 235 ,255, 235, 255]]], + "final-restriction-rls-select-stage": [[[1231, 72], [1252,71],[1231, 93],[1254, 93], [1242, 82], [27, 93], [661, 673]], [[235, 255, 235, 255, 235, 255], [235, 255, 235, 255, 235, 255], [235, 255, 235, 255, 235, 255], [235, 255, 235, 255, 235, 255], [235, 255, 235, 255, 235, 255], [0, 127, 0, 127, 0, 127], [0, 127, 0, 127, 0, 127]]], + "final-restriction-rls-fighting_feature": [[[755, 694], [715, 678], [744, 681]],[[0, 75, 165, 227, 235, 255],[0, 90, 57,92,120, 200],[0, 90, 57,92,120, 200]]] } } diff --git a/src/rgb_feature/Global_en-us.json b/src/rgb_feature/Global_en-us.json index 2b9b19d37..d35850e16 100644 --- a/src/rgb_feature/Global_en-us.json +++ b/src/rgb_feature/Global_en-us.json @@ -34,7 +34,9 @@ "preset_choose2": [[[334, 153]], [[40, 50, 70, 80, 110, 120]]], "preset_choose3": [[[490, 153]], [[40, 50, 70, 80, 110, 120]]], "preset_choose4": [[[646, 153]], [[40, 50, 70, 80, 110, 120]]], - "explore-task-grid-mode": [[[239, 183], [527, 183], [740, 183], [1019, 183]], [[235, 255, 235, 255, 235, 255], [235, 255, 235 ,255, 235, 255], [159, 179, 214, 234, 235, 255], [159, 179, 214, 234, 235, 255]]], - "explore-task-simple-mode": [[[239, 183], [527, 183], [740, 183], [1019, 183]], [[186, 206, 238, 255, 168, 188], [186, 206, 238, 255, 168, 188], [235, 255, 235 ,255, 235, 255], [235, 255, 235 ,255, 235, 255]]] + "explore-task-grid-mode": [[[239, 183], [527, 183], [740, 183], [1019, 183]], [[235, 255, 235, 255, 235, 255], [235, 255, 235 ,255, 235, 255], [159, 179, 214, 234, 235, 255], [159, 179, 214, 234, 235, 255]]], + "explore-task-simple-mode": [[[239, 183], [527, 183], [740, 183], [1019, 183]], [[186, 206, 238, 255, 168, 188], [186, 206, 238, 255, 168, 188], [235, 255, 235 ,255, 235, 255], [235, 255, 235 ,255, 235, 255]]], + "final-restriction-rls-select-stage": [[[1231, 72], [1252,71],[1231, 93],[1254, 93], [1242, 82], [27, 93], [661, 673]], [[235, 255, 235, 255, 235, 255], [235, 255, 235, 255, 235, 255], [235, 255, 235, 255, 235, 255], [235, 255, 235, 255, 235, 255], [235, 255, 235, 255, 235, 255], [0, 127, 0, 127, 0, 127], [0, 127, 0, 127, 0, 127]]], + "final-restriction-rls-fighting_feature": [[[755, 694], [715, 678], [744, 681]],[[0, 75, 165, 227, 235, 255],[0, 90, 57,92,120, 200],[0, 90, 57,92,120, 200]]] } } diff --git a/src/rgb_feature/Global_ko-kr.json b/src/rgb_feature/Global_ko-kr.json index 88346e954..7a4fd54df 100644 --- a/src/rgb_feature/Global_ko-kr.json +++ b/src/rgb_feature/Global_ko-kr.json @@ -34,7 +34,9 @@ "preset_choose2": [[[334, 153]], [[40, 50, 70, 80, 110, 120]]], "preset_choose3": [[[490, 153]], [[40, 50, 70, 80, 110, 120]]], "preset_choose4": [[[646, 153]], [[40, 50, 70, 80, 110, 120]]], - "explore-task-grid-mode": [[[239, 183], [527, 183], [740, 183], [1019, 183]], [[235, 255, 235, 255, 235, 255], [235, 255, 235 ,255, 235, 255], [159, 179, 214, 234, 235, 255], [159, 179, 214, 234, 235, 255]]], - "explore-task-simple-mode": [[[239, 183], [527, 183], [740, 183], [1019, 183]], [[186, 206, 238, 255, 168, 188], [186, 206, 238, 255, 168, 188], [235, 255, 235 ,255, 235, 255], [235, 255, 235 ,255, 235, 255]]] + "explore-task-grid-mode": [[[239, 183], [527, 183], [740, 183], [1019, 183]], [[235, 255, 235, 255, 235, 255], [235, 255, 235 ,255, 235, 255], [159, 179, 214, 234, 235, 255], [159, 179, 214, 234, 235, 255]]], + "explore-task-simple-mode": [[[239, 183], [527, 183], [740, 183], [1019, 183]], [[186, 206, 238, 255, 168, 188], [186, 206, 238, 255, 168, 188], [235, 255, 235 ,255, 235, 255], [235, 255, 235 ,255, 235, 255]]], + "final-restriction-rls-select-stage": [[[1231, 72], [1252,71],[1231, 93],[1254, 93], [1242, 82], [27, 93], [661, 673]], [[235, 255, 235, 255, 235, 255], [235, 255, 235, 255, 235, 255], [235, 255, 235, 255, 235, 255], [235, 255, 235, 255, 235, 255], [235, 255, 235, 255, 235, 255], [0, 127, 0, 127, 0, 127], [0, 127, 0, 127, 0, 127]]], + "final-restriction-rls-fighting_feature": [[[755, 694], [715, 678], [744, 681]],[[0, 75, 165, 227, 235, 255],[0, 90, 57,92,120, 200],[0, 90, 57,92,120, 200]]] } } diff --git a/src/rgb_feature/Global_zh-tw.json b/src/rgb_feature/Global_zh-tw.json index 9eae6bfd6..f1da51798 100644 --- a/src/rgb_feature/Global_zh-tw.json +++ b/src/rgb_feature/Global_zh-tw.json @@ -34,7 +34,9 @@ "preset_choose2": [[[334, 153]], [[40, 50, 70, 80, 110, 120]]], "preset_choose3": [[[490, 153]], [[40, 50, 70, 80, 110, 120]]], "preset_choose4": [[[646, 153]], [[40, 50, 70, 80, 110, 120]]], - "explore-task-grid-mode": [[[239, 183], [527, 183], [740, 183], [1019, 183]], [[235, 255, 235, 255, 235, 255], [235, 255, 235 ,255, 235, 255], [159, 179, 214, 234, 235, 255], [159, 179, 214, 234, 235, 255]]], - "explore-task-simple-mode": [[[239, 183], [527, 183], [740, 183], [1019, 183]], [[186, 206, 238, 255, 168, 188], [186, 206, 238, 255, 168, 188], [235, 255, 235 ,255, 235, 255], [235, 255, 235 ,255, 235, 255]]] + "explore-task-grid-mode": [[[239, 183], [527, 183], [740, 183], [1019, 183]], [[235, 255, 235, 255, 235, 255], [235, 255, 235 ,255, 235, 255], [159, 179, 214, 234, 235, 255], [159, 179, 214, 234, 235, 255]]], + "explore-task-simple-mode": [[[239, 183], [527, 183], [740, 183], [1019, 183]], [[186, 206, 238, 255, 168, 188], [186, 206, 238, 255, 168, 188], [235, 255, 235 ,255, 235, 255], [235, 255, 235 ,255, 235, 255]]], + "final-restriction-rls-select-stage": [[[1231, 72], [1252,71],[1231, 93],[1254, 93], [1242, 82], [27, 93], [661, 673]], [[235, 255, 235, 255, 235, 255], [235, 255, 235, 255, 235, 255], [235, 255, 235, 255, 235, 255], [235, 255, 235, 255, 235, 255], [235, 255, 235, 255, 235, 255], [0, 127, 0, 127, 0, 127], [0, 127, 0, 127, 0, 127]]], + "final-restriction-rls-fighting_feature": [[[755, 694], [715, 678], [744, 681]],[[0, 75, 165, 227, 235, 255],[0, 90, 57,92,120, 200],[0, 90, 57,92,120, 200]]] } } diff --git a/src/rgb_feature/JP.json b/src/rgb_feature/JP.json index 0a9b77d1d..4ab2dbb12 100644 --- a/src/rgb_feature/JP.json +++ b/src/rgb_feature/JP.json @@ -34,7 +34,9 @@ "preset_choose2": [[[334, 153]], [[40, 50, 70, 80, 110, 120]]], "preset_choose3": [[[490, 153]], [[40, 50, 70, 80, 110, 120]]], "preset_choose4": [[[646, 153]], [[40, 50, 70, 80, 110, 120]]], - "explore-task-grid-mode": [[[239, 183], [527, 183], [740, 183], [1019, 183]], [[235, 255, 235, 255, 235, 255], [235, 255, 235 ,255, 235, 255], [159, 179, 214, 234, 235, 255], [159, 179, 214, 234, 235, 255]]], - "explore-task-simple-mode": [[[239, 183], [527, 183], [740, 183], [1019, 183]], [[186, 206, 238, 255, 168, 188], [186, 206, 238, 255, 168, 188], [235, 255, 235 ,255, 235, 255], [235, 255, 235 ,255, 235, 255]]] + "explore-task-grid-mode": [[[239, 183], [527, 183], [740, 183], [1019, 183]], [[235, 255, 235, 255, 235, 255], [235, 255, 235 ,255, 235, 255], [159, 179, 214, 234, 235, 255], [159, 179, 214, 234, 235, 255]]], + "explore-task-simple-mode": [[[239, 183], [527, 183], [740, 183], [1019, 183]], [[186, 206, 238, 255, 168, 188], [186, 206, 238, 255, 168, 188], [235, 255, 235 ,255, 235, 255], [235, 255, 235 ,255, 235, 255]]], + "final-restriction-rls-select-stage": [[[1231, 72], [1252,71],[1231, 93],[1254, 93], [1242, 82], [27, 93], [661, 673]], [[235, 255, 235, 255, 235, 255], [235, 255, 235, 255, 235, 255], [235, 255, 235, 255, 235, 255], [235, 255, 235, 255, 235, 255], [235, 255, 235, 255, 235, 255], [0, 127, 0, 127, 0, 127], [0, 127, 0, 127, 0, 127]]], + "final-restriction-rls-fighting_feature": [[[755, 694], [715, 678], [744, 681]],[[0, 75, 165, 227, 235, 255],[0, 90, 57,92,120, 200],[0, 90, 57,92,120, 200]]] } } diff --git a/window.py b/window.py index 971e58fad..5b0ae037c 100644 --- a/window.py +++ b/window.py @@ -10,7 +10,7 @@ from PyQt5.QtCore import Qt, QSize, QPoint, pyqtSignal, QObject, QTimer from PyQt5.QtGui import QIcon, QColor -from PyQt5.QtWidgets import QApplication, QHBoxLayout, QLabel +from PyQt5.QtWidgets import QApplication, QHBoxLayout, QLabel, QAbstractScrollArea, QScroller, QScrollerProperties from qfluentwidgets import FluentIcon as FIF, FluentTranslator, SplashScreen, MSFluentWindow, TabBar, \ MSFluentTitleBar, MessageBox, TransparentToolButton, FluentIconBase, TabItem, \ RoundMenu, Action, MenuAnimationType, MessageBoxBase, LineEdit @@ -25,6 +25,7 @@ from core.config.config_set import ConfigSet from gui.util.language import Language from gui.util.translator import baasTranslator as bt +from core.utils import host_platform_is_android # sys.stderr = open('error.log', 'w+', encoding='utf-8') # sys.stdout = open('output.log', 'w+', encoding='utf-8') @@ -446,6 +447,8 @@ def __init__(self, *args, **kwargs): # SingleShot is used to speed up the initialization process # self.init_main_class() QTimer.singleShot(100, self.init_main_class) + if host_platform_is_android(): + self.init_touch_scrolling() def init_main_class(self, ): threading.Thread(target=self.init_main_class_thread).start() @@ -508,6 +511,19 @@ def initWindow(self): self.setWindowIcon(QIcon(ICON_DIR)) self.setWindowTitle('BlueArchiveAutoScript') + def init_touch_scrolling(self): + scroll_areas = self.findChildren(QAbstractScrollArea) + + for area in scroll_areas: + QScroller.grabGesture( + area.viewport(), + QScroller.TouchGesture + ) + + props = QScroller.scroller(area.viewport()).scrollerProperties() + props.setScrollMetric(QScrollerProperties.DecelerationFactor, 0.5) # 减速因子 + QScroller.scroller(area.viewport()).setScrollerProperties(props) + def closeEvent(self, event): super().closeEvent(event) @@ -631,7 +647,10 @@ def start(): app = QApplication(sys.argv) w = Window() - w.show() + if host_platform_is_android(): + w.showFullScreen() + else: + w.show() app.exec_()