|
@@ -0,0 +1,325 @@
|
|
|
|
|
+package com.usky.eg.service.impl;
|
|
|
|
|
+
|
|
|
|
|
+import com.baomidou.mybatisplus.core.toolkit.StringUtils;
|
|
|
|
|
+import com.usky.common.core.exception.BusinessException;
|
|
|
|
|
+import com.usky.eg.adb.EgDeviceAdbProperties;
|
|
|
|
|
+import com.usky.eg.service.EgDeviceAdbService;
|
|
|
|
|
+import com.usky.eg.service.vo.EgDeviceAdbResultVO;
|
|
|
|
|
+import lombok.extern.slf4j.Slf4j;
|
|
|
|
|
+import org.springframework.beans.factory.annotation.Autowired;
|
|
|
|
|
+import org.springframework.stereotype.Service;
|
|
|
|
|
+
|
|
|
|
|
+import java.io.ByteArrayOutputStream;
|
|
|
|
|
+import java.io.IOException;
|
|
|
|
|
+import java.io.InputStream;
|
|
|
|
|
+import java.nio.charset.Charset;
|
|
|
|
|
+import java.util.Arrays;
|
|
|
|
|
+import java.util.List;
|
|
|
|
|
+import java.util.concurrent.TimeUnit;
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 通过 {@link ProcessBuilder} 调用本机 PATH 中的官方 adb。
|
|
|
|
|
+ */
|
|
|
|
|
+@Slf4j
|
|
|
|
|
+@Service
|
|
|
|
|
+public class EgDeviceAdbServiceImpl implements EgDeviceAdbService {
|
|
|
|
|
+
|
|
|
|
|
+ private static final int BUFFER = 16384;
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Windows 下 adb 常输出系统本地编码(如 GBK),勿固定按 UTF-8 解码以免日志/接口里出现乱码。
|
|
|
|
|
+ */
|
|
|
|
|
+ private static final Charset ADB_CONSOLE_CHARSET = Charset.defaultCharset();
|
|
|
|
|
+
|
|
|
|
|
+ private static final String NETWORK_TIMEOUT_HINT =
|
|
|
|
|
+ " 【排查】运行本服务的机器须与设备网络互通并能访问目标 TCP 端口(常见 5555);"
|
|
|
|
|
+ + "检查防火墙/安全组/跨网段路由;确认手机「无线调试」已开启且端口与请求一致;"
|
|
|
|
|
+ + "Android 11+ 有时需先用系统界面配对,端口可能不是 5555;"
|
|
|
|
|
+ + "错误 10060 表示连接超时,多为网络不可达或端口未监听。";
|
|
|
|
|
+
|
|
|
|
|
+ @Autowired
|
|
|
|
|
+ private EgDeviceAdbProperties properties;
|
|
|
|
|
+
|
|
|
|
|
+ @Override
|
|
|
|
|
+ public EgDeviceAdbResultVO connect(String deviceIp, Integer devicePort) {
|
|
|
|
|
+ validateEndpoint(deviceIp, devicePort);
|
|
|
|
|
+ String endpoint = toEndpoint(deviceIp, devicePort);
|
|
|
|
|
+ try {
|
|
|
|
|
+ String out = runAdbMergedOutput(properties.getCommandTimeoutSeconds(),
|
|
|
|
|
+ Arrays.asList(properties.getExecutable(), "connect", endpoint));
|
|
|
|
|
+ return EgDeviceAdbResultVO.ok(out);
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.warn("adb connect failed: {}", endpoint, e);
|
|
|
|
|
+ return EgDeviceAdbResultVO.fail(e.getMessage() != null ? e.getMessage() : e.toString());
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Override
|
|
|
|
|
+ public EgDeviceAdbResultVO disconnect(String deviceIp, Integer devicePort) {
|
|
|
|
|
+ validateEndpoint(deviceIp, devicePort);
|
|
|
|
|
+ String endpoint = toEndpoint(deviceIp, devicePort);
|
|
|
|
|
+ try {
|
|
|
|
|
+ String out = runAdbMergedOutput(properties.getCommandTimeoutSeconds(),
|
|
|
|
|
+ Arrays.asList(properties.getExecutable(), "disconnect", endpoint));
|
|
|
|
|
+ return EgDeviceAdbResultVO.ok(out);
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.warn("adb disconnect failed: {}", endpoint, e);
|
|
|
|
|
+ return EgDeviceAdbResultVO.fail(e.getMessage() != null ? e.getMessage() : e.toString());
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Override
|
|
|
|
|
+ public EgDeviceAdbResultVO tcpip(String deviceIp, Integer devicePort, Integer listenPort) {
|
|
|
|
|
+ validateEndpoint(deviceIp, devicePort);
|
|
|
|
|
+ if (listenPort == null || listenPort < 1 || listenPort > 65535) {
|
|
|
|
|
+ throw new BusinessException("port 无效(tcpip 监听端口)");
|
|
|
|
|
+ }
|
|
|
|
|
+ String serial = toSerial(deviceIp, devicePort);
|
|
|
|
|
+ EgDeviceAdbResultVO first = runTcpipOnce(serial, listenPort);
|
|
|
|
|
+ if (EgDeviceAdbResultVO.STATUS_SUCCESS.equals(first.getStatus())) {
|
|
|
|
|
+ return first;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!properties.isTcpipRetryAfterConnect()) {
|
|
|
|
|
+ return first;
|
|
|
|
|
+ }
|
|
|
|
|
+ log.info("adb tcpip 首次失败,尝试 connect 后重试: {}", serial);
|
|
|
|
|
+ EgDeviceAdbResultVO c = connect(deviceIp, devicePort);
|
|
|
|
|
+ if (!EgDeviceAdbResultVO.STATUS_SUCCESS.equals(c.getStatus())) {
|
|
|
|
|
+ return EgDeviceAdbResultVO.fail("tcpip 失败且自动 connect 未成功: " + first.getDetail() + " | connect: " + c.getDetail());
|
|
|
|
|
+ }
|
|
|
|
|
+ return runTcpipOnce(serial, listenPort);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private EgDeviceAdbResultVO runTcpipOnce(String serial, int listenPort) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ String out = runAdbMergedOutput(properties.getCommandTimeoutSeconds(),
|
|
|
|
|
+ Arrays.asList(properties.getExecutable(), "-s", serial, "tcpip", String.valueOf(listenPort)));
|
|
|
|
|
+ return EgDeviceAdbResultVO.ok(out);
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.warn("adb tcpip failed: {} {}", serial, listenPort, e);
|
|
|
|
|
+ return EgDeviceAdbResultVO.fail(e.getMessage() != null ? e.getMessage() : e.toString());
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Override
|
|
|
|
|
+ public byte[] screenshot(String deviceIp, Integer devicePort) {
|
|
|
|
|
+ validateEndpoint(deviceIp, devicePort);
|
|
|
|
|
+ String serial = toSerial(deviceIp, devicePort);
|
|
|
|
|
+ if (properties.isScreenshotConnectFirst()) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ ensureAdbDeviceOnline(deviceIp, devicePort);
|
|
|
|
|
+ } catch (InterruptedException e) {
|
|
|
|
|
+ Thread.currentThread().interrupt();
|
|
|
|
|
+ throw new BusinessException("设备未就绪,无法截屏: " + e.getMessage());
|
|
|
|
|
+ } catch (IOException e) {
|
|
|
|
|
+ throw new BusinessException("设备未就绪,无法截屏: " + e.getMessage());
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ List<String> cmd = Arrays.asList(
|
|
|
|
|
+ properties.getExecutable(),
|
|
|
|
|
+ "-s", serial,
|
|
|
|
|
+ "exec-out", "screencap", "-p");
|
|
|
|
|
+ try {
|
|
|
|
|
+ byte[] png = runAdbBinaryStdout(cmd, properties.getScreenshotTimeoutSeconds());
|
|
|
|
|
+ checkPng(png);
|
|
|
|
|
+ return png;
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.warn("adb screencap failed: {}", serial, e);
|
|
|
|
|
+ throw new BusinessException("截屏失败: " + e.getMessage()
|
|
|
|
|
+ + "(请确认设备已开无线调试、端口正确,或先调用 /egDevice/adb/connect)");
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 让本机 adb 真正识别该 serial:执行 connect、校验 {@code adb devices} 为 {@code device} 状态。
|
|
|
|
|
+ * 若仅“假装 connect”或忽略失败,会继续出现 {@code device 'ip:port' not found}。
|
|
|
|
|
+ */
|
|
|
|
|
+ private void ensureAdbDeviceOnline(String deviceIp, int devicePort) throws IOException, InterruptedException {
|
|
|
|
|
+ String serial = toSerial(deviceIp, devicePort);
|
|
|
|
|
+ String endpoint = toEndpoint(deviceIp, devicePort);
|
|
|
|
|
+
|
|
|
|
|
+ String connectOut = runAdbMergedOutput(properties.getCommandTimeoutSeconds(),
|
|
|
|
|
+ Arrays.asList(properties.getExecutable(), "connect", endpoint));
|
|
|
|
|
+ if (connectOutputIndicatesFailure(connectOut)) {
|
|
|
|
|
+ throw new IOException(withTimeoutHint("adb connect 输出表明失败: " + connectOut));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (waitUntilDeviceOnline(serial, 3, 400)) {
|
|
|
|
|
+ Thread.sleep(200);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ log.warn("首次 connect 后 devices 仍未就绪,重试 connect: {}", endpoint);
|
|
|
|
|
+ connectOut = runAdbMergedOutput(properties.getCommandTimeoutSeconds(),
|
|
|
|
|
+ Arrays.asList(properties.getExecutable(), "connect", endpoint));
|
|
|
|
|
+ if (connectOutputIndicatesFailure(connectOut)) {
|
|
|
|
|
+ throw new IOException(withTimeoutHint("adb connect(重试)失败: " + connectOut));
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!waitUntilDeviceOnline(serial, 5, 500)) {
|
|
|
|
|
+ String devicesList = runAdbMergedOutput(properties.getCommandTimeoutSeconds(),
|
|
|
|
|
+ Arrays.asList(properties.getExecutable(), "devices", "-l"));
|
|
|
|
|
+ throw new IOException(withTimeoutHint("adb devices 中无可用设备 " + serial
|
|
|
|
|
+ + "。connect 输出: " + connectOut + ";当前列表:\n" + devicesList));
|
|
|
|
|
+ }
|
|
|
|
|
+ Thread.sleep(200);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private boolean waitUntilDeviceOnline(String serial, int attempts, long gapMs) throws IOException, InterruptedException {
|
|
|
|
|
+ for (int i = 0; i < attempts; i++) {
|
|
|
|
|
+ if (i > 0) {
|
|
|
|
|
+ Thread.sleep(gapMs);
|
|
|
|
|
+ }
|
|
|
|
|
+ String list = runAdbMergedOutput(properties.getCommandTimeoutSeconds(),
|
|
|
|
|
+ Arrays.asList(properties.getExecutable(), "devices"));
|
|
|
|
|
+ String state = findSerialState(list, serial);
|
|
|
|
|
+ if ("device".equals(state)) {
|
|
|
|
|
+ return true;
|
|
|
|
|
+ }
|
|
|
|
|
+ if ("unauthorized".equals(state)) {
|
|
|
|
|
+ throw new IOException("设备已发现但未授权调试,请在设备上点「允许 USB 调试」: " + serial);
|
|
|
|
|
+ }
|
|
|
|
|
+ if ("offline".equals(state)) {
|
|
|
|
|
+ log.warn("设备当前为 offline: {}", serial);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 解析 {@code adb devices},返回 serial 对应第二列状态;未找到返回 null
|
|
|
|
|
+ */
|
|
|
|
|
+ private static String findSerialState(String devicesOutput, String serial) {
|
|
|
|
|
+ if (devicesOutput == null) {
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ for (String raw : devicesOutput.split("\\r?\\n")) {
|
|
|
|
|
+ String line = raw.trim();
|
|
|
|
|
+ if (line.isEmpty() || line.startsWith("List of devices")) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ String[] parts = line.split("\\s+");
|
|
|
|
|
+ if (parts.length >= 2 && serial.equals(parts[0])) {
|
|
|
|
|
+ return parts[1];
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static boolean connectOutputIndicatesFailure(String connectOut) {
|
|
|
|
|
+ if (connectOut == null || connectOut.isEmpty()) {
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+ String s = connectOut.toLowerCase();
|
|
|
|
|
+ return s.contains("cannot connect")
|
|
|
|
|
+ || s.contains("failed to connect")
|
|
|
|
|
+ || s.contains("unable to connect")
|
|
|
|
|
+ || s.contains("connection refused")
|
|
|
|
|
+ || s.contains("10061")
|
|
|
|
|
+ || s.contains("10060")
|
|
|
|
|
+ || s.contains("timed out")
|
|
|
|
|
+ || connectOut.contains("连接尝试失败")
|
|
|
|
|
+ || connectOut.contains("没有正确答复");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static String withTimeoutHint(String message) {
|
|
|
|
|
+ if (message == null) {
|
|
|
|
|
+ return NETWORK_TIMEOUT_HINT.trim();
|
|
|
|
|
+ }
|
|
|
|
|
+ String m = message;
|
|
|
|
|
+ String lower = m.toLowerCase();
|
|
|
|
|
+ if (lower.contains("10060") || lower.contains("10061") || lower.contains("cannot connect")
|
|
|
|
|
+ || lower.contains("timed out") || m.contains("连接尝试失败") || m.contains("没有正确答复")) {
|
|
|
|
|
+ return m + NETWORK_TIMEOUT_HINT;
|
|
|
|
|
+ }
|
|
|
|
|
+ return m;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * adb 文本类输出(stdout/stderr)按 JVM 默认字符集解码
|
|
|
|
|
+ */
|
|
|
|
|
+ private static String decodeAdbConsole(byte[] raw) {
|
|
|
|
|
+ if (raw == null || raw.length == 0) {
|
|
|
|
|
+ return "";
|
|
|
|
|
+ }
|
|
|
|
|
+ return new String(raw, ADB_CONSOLE_CHARSET).trim();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static void validateEndpoint(String deviceIp, Integer devicePort) {
|
|
|
|
|
+ if (StringUtils.isBlank(deviceIp)) {
|
|
|
|
|
+ throw new BusinessException("deviceIp 不能为空");
|
|
|
|
|
+ }
|
|
|
|
|
+ if (devicePort == null || devicePort < 1 || devicePort > 65535) {
|
|
|
|
|
+ throw new BusinessException("devicePort 无效");
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static String toEndpoint(String deviceIp, int devicePort) {
|
|
|
|
|
+ return deviceIp.trim() + ":" + devicePort;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static String toSerial(String deviceIp, int devicePort) {
|
|
|
|
|
+ return toEndpoint(deviceIp, devicePort);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 合并 stdout/stderr,非 0 退出码抛出异常(文本类命令)
|
|
|
|
|
+ */
|
|
|
|
|
+ private String runAdbMergedOutput(long timeoutSeconds, List<String> command) throws IOException, InterruptedException {
|
|
|
|
|
+ ProcessBuilder pb = new ProcessBuilder(command);
|
|
|
|
|
+ pb.redirectErrorStream(true);
|
|
|
|
|
+ Process p = pb.start();
|
|
|
|
|
+ byte[] raw = readAll(p.getInputStream());
|
|
|
|
|
+ String text = decodeAdbConsole(raw);
|
|
|
|
|
+ boolean finished = p.waitFor(timeoutSeconds, TimeUnit.SECONDS);
|
|
|
|
|
+ if (!finished) {
|
|
|
|
|
+ p.destroyForcibly();
|
|
|
|
|
+ throw new IOException("adb 执行超时(" + timeoutSeconds + "s)");
|
|
|
|
|
+ }
|
|
|
|
|
+ int code = p.exitValue();
|
|
|
|
|
+ if (code != 0) {
|
|
|
|
|
+ throw new IOException(withTimeoutHint("adb 退出码 " + code + (text.isEmpty() ? "" : (": " + text))));
|
|
|
|
|
+ }
|
|
|
|
|
+ return text.isEmpty() ? "OK" : text;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private byte[] runAdbBinaryStdout(List<String> command, long timeoutSeconds) throws IOException, InterruptedException {
|
|
|
|
|
+ ProcessBuilder pb = new ProcessBuilder(command);
|
|
|
|
|
+ pb.redirectErrorStream(false);
|
|
|
|
|
+ Process p = pb.start();
|
|
|
|
|
+ byte[] out = readAll(p.getInputStream());
|
|
|
|
|
+ byte[] err = readAll(p.getErrorStream());
|
|
|
|
|
+ boolean finished = p.waitFor(timeoutSeconds, TimeUnit.SECONDS);
|
|
|
|
|
+ if (!finished) {
|
|
|
|
|
+ p.destroyForcibly();
|
|
|
|
|
+ throw new IOException("adb 执行超时(" + timeoutSeconds + "s)");
|
|
|
|
|
+ }
|
|
|
|
|
+ int code = p.exitValue();
|
|
|
|
|
+ String errStr = err.length == 0 ? "" : decodeAdbConsole(err);
|
|
|
|
|
+ if (code != 0) {
|
|
|
|
|
+ throw new IOException(withTimeoutHint("adb 退出码 " + code + (errStr.isEmpty() ? "" : (": " + errStr))));
|
|
|
|
|
+ }
|
|
|
|
|
+ if (out.length == 0 && !errStr.isEmpty()) {
|
|
|
|
|
+ throw new IOException(withTimeoutHint(errStr));
|
|
|
|
|
+ }
|
|
|
|
|
+ return out;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static byte[] readAll(InputStream in) throws IOException {
|
|
|
|
|
+ ByteArrayOutputStream bos = new ByteArrayOutputStream();
|
|
|
|
|
+ byte[] buf = new byte[BUFFER];
|
|
|
|
|
+ int n;
|
|
|
|
|
+ while ((n = in.read(buf)) != -1) {
|
|
|
|
|
+ bos.write(buf, 0, n);
|
|
|
|
|
+ }
|
|
|
|
|
+ return bos.toByteArray();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static void checkPng(byte[] data) {
|
|
|
|
|
+ if (data == null || data.length < 24) {
|
|
|
|
|
+ throw new BusinessException("截屏数据无效(过小)");
|
|
|
|
|
+ }
|
|
|
|
|
+ if (data[0] != (byte) 0x89 || data[1] != 0x50 || data[2] != 0x4E || data[3] != 0x47) {
|
|
|
|
|
+ throw new BusinessException("截屏数据不是有效的 PNG");
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|