package net.geedge.api.util; import cn.hutool.core.codec.Base32Codec; import cn.hutool.core.thread.NamedThreadFactory; import cn.hutool.log.Log; import net.geedge.api.entity.EnvApiYml; import net.geedge.common.APIException; import net.geedge.common.Constant; import net.geedge.common.RCode; import net.geedge.common.T; import java.io.File; import java.nio.file.Paths; import java.util.*; import java.util.concurrent.*; import java.util.regex.Matcher; import java.util.regex.Pattern; public class AdbUtil { private static final Log log = Log.get(); private static AdbUtil instance; private static String DEFAULT_DROIDVNC_NG_APK_PATH = "./lib/droidvnc-np-2.6.0.apk"; private static String DEFAULT_DROIDVNC_NG_DEFAULTS_JSON_PATH = "./lib/droidvnc-np-defaults.json"; private String serial; private String host; private Integer port; private ExecutorService threadPool; public String getSerial() { return T.StrUtil.isNotEmpty(this.serial) ? serial : String.format("%s:%s", this.host, this.port); } public record CommandResult(Integer exitCode, String output) { } private AdbUtil(EnvApiYml.Adb adb) { this.serial = T.StrUtil.emptyToDefault(adb.getSerial(), ""); this.host = adb.getHost(); this.port = adb.getPort(); // adb connect this.connect(); // init this.init(); } public static AdbUtil getInstance(EnvApiYml.Adb connInfo) { if (instance == null) { synchronized (AdbUtil.class) { if (instance == null) { instance = new AdbUtil(connInfo); } } } return instance; } /** * connect */ private void connect() { if (T.StrUtil.isNotEmpty(this.serial)) { // local AdbDevice adbDevice = this.getAdbDevice(); log.info("[connect] [result: {}]", T.JSONUtil.toJsonStr(adbDevice)); if (null == adbDevice || !adbDevice.isAvailable()) { log.error("[device is not available, program exit]"); Runtime.getRuntime().halt(1); } } else { // remote String result = CommandExec.exec(AdbCommandBuilder.builder() .buildConnectCommand(this.host, this.port) .build()); log.info("[connect] [result: {}]", result); if (!T.StrUtil.contains(result, "connected")) { log.error("[connect error, program exit]"); Runtime.getRuntime().halt(1); } } } /** * init * su root * install droidVNC NG */ private void init() { // adb root String result = CommandExec.exec(AdbCommandBuilder.builder() .serial(this.getSerial()) .buildRootCommand() .build() ); log.info("[init] [adb root] [result: {}]", result); // install droidVNC NG CommandResult installed = this.install(DEFAULT_DROIDVNC_NG_APK_PATH, true, true); log.info("[init] [install droidVNC NG] [result: {}]", installed); // 上传默认配置 this.execShellCommand("shell mkdir -p /storage/emulated/0/Android/data/net.christianbeier.droidvnc_ng/files"); this.push(DEFAULT_DROIDVNC_NG_DEFAULTS_JSON_PATH, "/storage/emulated/0/Android/data/net.christianbeier.droidvnc_ng/files/defaults.json"); // 无障碍权限 this.execShellCommand("shell settings put secure enabled_accessibility_services net.christianbeier.droidvnc_ng/.InputService:$(settings get secure enabled_accessibility_services)"); // 存储空间权限 this.execShellCommand("shell pm grant net.christianbeier.droidvnc_ng android.permission.WRITE_EXTERNAL_STORAGE"); // 屏幕录制权限 this.execShellCommand("shell appops set net.christianbeier.droidvnc_ng PROJECT_MEDIA allow"); // 后台启动 this.execShellCommand("shell am start-foreground-service -n net.christianbeier.droidvnc_ng/.MainService -a net.christianbeier.droidvnc_ng.ACTION_STOP --es net.christianbeier.droidvnc_ng.EXTRA_ACCESS_KEY d042e2b5d5f348588a4e1a243eb7a9a0"); this.execShellCommand("shell am start-foreground-service -n net.christianbeier.droidvnc_ng/.MainService -a net.christianbeier.droidvnc_ng.ACTION_START --es net.christianbeier.droidvnc_ng.EXTRA_ACCESS_KEY d042e2b5d5f348588a4e1a243eb7a9a0"); } /** * status * * @return */ public Map status() { Map m = T.MapUtil.builder() .put("platform", "android") .build(); AdbDevice device = this.getAdbDevice(); m.put("status", device.isAvailable() ? "online" : "offline"); Map prop = this.getProp(); m.put("name", T.MapUtil.getStr(prop, "ro.product.name", "")); m.put("brand", T.MapUtil.getStr(prop, "ro.product.brand", "")); m.put("model", T.MapUtil.getStr(prop, "ro.product.model", "")); m.put("version", T.MapUtil.getStr(prop, "ro.build.version.release", "")); m.put("resolution", T.MapUtil.getStr(prop, "wm.size", "")); // 默认为真机 String type = "device"; for (Map.Entry entry : prop.entrySet()) { // 根据 ro.build.* 这一组配置值判定是否为模拟器,如果包含 emulator、sdk 则为模拟器 if (entry.getKey().contains("ro.build")) { String value = entry.getValue(); if (T.StrUtil.containsAnyIgnoreCase(value, "emulator", "sdk")) { type = "emulator"; break; } } } m.put("type", type); // check root String checkRootResult = CommandExec.exec(AdbCommandBuilder.builder() .serial(this.getSerial()) .buildCheckRootCommand() .build() ); m.put("root", !T.StrUtil.containsIgnoreCase(checkRootResult, "Permission denied")); return m; } /** * getAdbDevice * adb devices -l * * @return */ private AdbDevice getAdbDevice() { String result = CommandExec.exec(AdbCommandBuilder.builder() .buildDevicesCommand() .build() ); List list = T.ListUtil.list(true); String[] lines = result.split("\\n"); for (String line : lines) { if (line.startsWith("*") || line.startsWith("List") || T.StrUtil.isEmpty(line)) continue; list.add(new AdbDevice(line)); } AdbDevice adbDevice = list.stream() .filter(pojo -> T.StrUtil.equals(pojo.getSerial(), this.getSerial())) .findFirst() .orElse(null); return adbDevice; } /** * getProp * adb shell getprop * * @return */ private Map getProp() { String result = CommandExec.exec(AdbCommandBuilder.builder() .serial(this.getSerial()) .buildGetpropCommand() .build() ); Map prop = new LinkedHashMap<>(); Pattern pattern = Pattern.compile("\\[(.*?)\\]: \\[(.*?)\\]"); Matcher matcher = pattern.matcher(result); while (matcher.find()) { String key = matcher.group(1).trim(); String value = matcher.group(2).trim(); prop.put(key, value); } // 分辨率 Physical size: 1440x3040 String wmSize = CommandExec.exec(AdbCommandBuilder.builder() .serial(this.getSerial()) .buildWmSizeCommand() .build() ); prop.put("wm.size", T.StrUtil.trim(wmSize.replaceAll("Physical size: ", ""))); return prop; } /** * md5sum */ private CommandResult md5sum(String path) { String result = CommandExec.exec(AdbCommandBuilder.builder() .serial(this.getSerial()) .buildMd5sumCommand(path) .build() ); log.info("[md5sum] [path: {}] [result: {}]", path, result); if (T.StrUtil.isNotEmpty(result)) { String md5 = result.split("\\s+")[0]; return new CommandResult(0, md5); } return new CommandResult(1, ""); } /** * push * 0 success; !0 failed */ public CommandResult push(String local, String remote) { String result = CommandExec.exec(AdbCommandBuilder.builder() .serial(this.getSerial()) .buildPushCommand(local, remote) .build() ); log.info("[push] [local: {}] [remote: {}] [result: {}]", local, remote, result); return new CommandResult(T.StrUtil.contains(result, "failed") ? 1 : 0, result); } /** * pull * 0 success; !0 failed */ public CommandResult pull(String remote, String local) { String result = CommandExec.exec(AdbCommandBuilder.builder() .serial(this.getSerial()) .buildPullCommand(remote, local) .build() ); log.info("[pull] [remote: {}] [local: {}] [result: {}]", remote, local, result); return new CommandResult(T.StrUtil.containsAny(result, "file pulled", "files pulled") ? 0 : 1, result); } /** * list dir * ls -l * stat filename */ public List listDir(String path) { String result = CommandExec.exec(AdbCommandBuilder.builder() .serial(this.getSerial()) .buildLsDirCommand(path) .build() ); if (T.StrUtil.contains(result, "No such file or directory")) { log.warn("[listDir] [path: {}] [result: {}]", path, result); throw new APIException(RCode.NOT_EXISTS); } if (T.StrUtil.contains(result, "Permission denied")) { log.warn("[listDir] [path: {}] [result: {}]", path, result); throw new APIException(RCode.NOT_PERMISSION); } List> futureList = T.ListUtil.list(false); List listDir = T.ListUtil.list(true); String[] lines = result.split("\\n"); boolean isDir = false; for (String line : lines) { if (line.startsWith("total")) { isDir = true; continue; } String[] split = line.split("\\s+"); String name; // link file|dir if (10 == split.length) { name = split[7]; } else { name = split[split.length - 1]; } String statFilePath = isDir ? Paths.get(path).resolve(name).toString() : path; String statCommand = "shell stat -c \"'%N %a %X %Y'\" " + statFilePath; futureList.add( CompletableFuture.supplyAsync(() -> { String statResult = CommandExec.exec(AdbCommandBuilder.builder() .serial(this.getSerial()) .buildShellCommand(statCommand.replaceAll("\\\\", "/")) .build() ); // reverse result List list = Arrays.asList(statResult.split("\\s+")); Collections.reverse(list); String fullName = list.get(3).replaceAll("'|`", ""); Map relMap = T.MapUtil.newHashMap(); relMap.put("name", name); relMap.put("value", T.MapUtil.builder() .put("id", Base32Codec.Base32Encoder.ENCODER.encode(fullName.getBytes())) .put("fullName", fullName) .put("permissions", Long.parseLong(list.get(2))) .put("cts", Long.parseLong(list.get(1))) .put("uts", Long.parseLong(list.get(0))) .build()); return relMap; }, getThreadPool()) ); Map m = T.MapUtil.builder() .put("name", name) .build(); listDir.add(m); } try { CompletableFuture.allOf(futureList.toArray(new CompletableFuture[0])).get(); futureList.forEach(f -> { Map map = f.getNow(null); if (T.MapUtil.isNotEmpty(map)) { String name = T.MapUtil.getStr(map, "name"); Map fileAttr = listDir.stream().filter(m -> T.MapUtil.getStr(m, "name").equals(name)).findFirst().get(); fileAttr.putAll(T.MapUtil.get(map, "value", Map.class)); } }); } catch (Exception e) { log.warn(e); } return listDir; } /** * listApp * adb shell pm list packages * * @return */ public List listApp(String arg) { String result = CommandExec.exec(AdbCommandBuilder.builder() .serial(this.getSerial()) .buildPmListPackagesCommand(arg) .build() ); List listApp = T.ListUtil.list(true); List> futureList = T.ListUtil.list(false); String prefix = "package:"; String[] lines = result.split("\\n"); for (String line : lines) { String packageName = T.StrUtil.trim(line.substring(prefix.length())); String dumpsysResult = CommandExec.exec(AdbCommandBuilder.builder() .serial(this.getSerial()) .buildShellCommand("shell dumpsys package " + packageName) .build() ); String[] split = dumpsysResult.split("\\n"); String version = "", apkPath = ""; for (String s : split) { if (s.contains("versionName=")) { version = T.StrUtil.trim(s).replaceAll("versionName=", ""); } if (s.contains("path: ")) { apkPath = T.StrUtil.trim(s).replaceAll("path: ", ""); } } if (T.StrUtil.isNotEmpty(apkPath)) { String finalApkPath = apkPath; futureList.add( CompletableFuture.supplyAsync(() -> { try { CommandResult md5sumRes = this.md5sum(finalApkPath); String md5Value = md5sumRes.output(); File localApk = T.FileUtil.file(Constant.TEMP_PATH, md5Value + ".apk"); if (!T.FileUtil.exist(localApk)) { CommandResult pulled = this.pull(finalApkPath, localApk.getAbsolutePath()); if (0 != pulled.exitCode()) { log.warn("[listApp] [pull apk error] [pkg: {}]", packageName); return null; } } ApkUtil apkUtil = new ApkUtil(); ApkInfo apkInfo = apkUtil.parseApk(localApk.getAbsolutePath()); String appName = apkInfo.getLabel(); String iconFilename = apkInfo.getIcon(); String base64IconDate = apkUtil.extractFileFromApk(localApk.getAbsolutePath(), iconFilename); Map relMap = T.MapUtil.newHashMap(); relMap.put("pkg", packageName); relMap.put("value", T.MapUtil.builder() .put("name", appName) .put("icon", base64IconDate) .build()); return relMap; } catch (Exception e) { log.error(e, "[listApp] [parse apk] [pkg: {}]", packageName); } return null; }, getThreadPool()) ); } Map m = T.MapUtil.builder() .put("packageName", packageName) .put("version", version) .build(); listApp.add(m); } try { CompletableFuture.allOf(futureList.toArray(new CompletableFuture[0])).get(); futureList.forEach(f -> { Map map = f.getNow(null); if (T.MapUtil.isNotEmpty(map)) { String pkg = T.MapUtil.getStr(map, "pkg"); Map appAttr = listApp.stream().filter(m -> T.MapUtil.getStr(m, "packageName").equals(pkg)).findFirst().get(); appAttr.putAll(T.MapUtil.get(map, "value", Map.class)); } }); } catch (Exception e) { log.warn(e); } return listApp; } /** * install app * adb install apk */ public CommandResult install(String localFilePath, boolean isDebugApk, boolean isReInstall) { String result = CommandExec.exec(AdbCommandBuilder.builder() .serial(this.getSerial()) .buildInstallCommand(localFilePath, isDebugApk, isReInstall) .build() ); log.info("[install] [localFilePath: {}] [isDebugApk: {}] [isReInstall: {}] [result: {}]", localFilePath, isDebugApk, isReInstall, result); return new CommandResult(T.StrUtil.containsAny(result, "Success") ? 0 : 1, result); } /** * uninstall app * adb uninstall package_name */ public CommandResult uninstall(String packageName) { String result = CommandExec.exec(AdbCommandBuilder.builder() .serial(this.getSerial()) .buildUnInstallCommand(packageName) .build() ); log.info("[uninstall] [packageName: {}] [result: {}]", packageName, result); return new CommandResult(T.StrUtil.containsAny(result, "Success") ? 0 : 1, result); } /** * iptables -F * iptables -X */ @Deprecated private void cleanIptables() { // Delete all rules in chain or all chains CommandExec.exec(AdbCommandBuilder.builder() .serial(this.getSerial()) .buildShellCommand("shell iptables -F") .build() ); // Delete user-defined chain CommandExec.exec(AdbCommandBuilder.builder() .serial(this.getSerial()) .buildShellCommand("shell iptables -X") .build() ); } /** * list tcpdump */ public List listTcpdump() { String result = CommandExec.exec(AdbCommandBuilder.builder() .serial(this.getSerial()) .buildShellCommand(String.format("shell \"ps -ef | grep tcpdump | grep -v grep | grep capture_ | awk '{print $NF}' \"")) .build()); List list = T.ListUtil.list(true); String[] lines = result.split("\\n"); for (String line : lines) { try { String fileName = T.FileUtil.mainName(line); String taskId = "", packageName = ""; if (fileName.contains("capture_all_")) { taskId = fileName.replaceAll("capture_all_", ""); } else { String[] split = fileName.split("_"); packageName = split[2]; taskId = split[split.length - 1]; } Map m = T.MapUtil.builder() .put("id", taskId) .put("packageName", packageName) .build(); list.add(m); } catch (Exception e) { log.warn(e, "[listTcpdump] [get task info error] [line: {}]", line); } } return list; } /** * start Tcpdump * tcpdump pcap */ public CommandResult startTcpdump(String packageName) { String taskId = T.IdUtil.fastSimpleUUID(); if (T.StrUtil.isNotEmpty(packageName)) { log.info("[startTcpdump] [capture app package] [pkg: {}]", packageName); String dumpsysResult = CommandExec.exec(AdbCommandBuilder.builder() .serial(this.getSerial()) .buildShellCommand("shell dumpsys package " + packageName) .build() ); String[] lines = dumpsysResult.split("\\n"); String userId = Arrays.stream(lines) .filter(s -> T.StrUtil.contains(s, "userId=")) .findFirst() .map(s -> T.StrUtil.trim(s).replaceAll("userId=", "")) .orElseThrow(() -> new APIException("Not found userId by package name. package name: " + packageName)); CommandExec.exec(AdbCommandBuilder.builder() .serial(this.getSerial()) .buildShellCommand(String.format("shell iptables -A OUTPUT -m owner --uid-owner %s -j CONNMARK --set-mark %s", userId, userId)) .build()); CommandExec.exec(AdbCommandBuilder.builder() .serial(this.getSerial()) .buildShellCommand(String.format("shell iptables -A INPUT -m connmark --mark %s -j NFLOG --nflog-group %s", userId, userId)) .build()); CommandExec.exec(AdbCommandBuilder.builder() .serial(this.getSerial()) .buildShellCommand(String.format("shell iptables -A OUTPUT -m connmark --mark %s -j NFLOG --nflog-group %s", userId, userId)) .build()); String ruleList = CommandExec.exec(AdbCommandBuilder.builder() .serial(this.getSerial()) .buildShellCommand("shell iptables -L") .build()); log.info("[startTcpdump] [iptables -L] [result: {}]", ruleList); // pcap 格式:capture_{userId}_{pcakageName}_{taskId}.pcap String pcapFilePath = "/data/local/tmp/capture_" + userId + "_" + packageName + "_" + taskId + ".pcap"; CommandExec.execForProcess(AdbCommandBuilder.builder() .serial(this.getSerial()) .buildShellCommand(String.format("shell tcpdump -i nflog:%s -w %s &", userId, pcapFilePath)) .build()); } else { log.info("[startTcpdump] [capture all package]"); // pcap 格式:capture_all_{taskId}.pcap String pcapFilePath = "/data/local/tmp/capture_all_" + taskId + ".pcap"; CommandExec.execForProcess(AdbCommandBuilder.builder() .serial(this.getSerial()) .buildShellCommand(String.format("shell tcpdump -w %s &", pcapFilePath)) .build()); } String result = CommandExec.exec(AdbCommandBuilder.builder() .serial(this.getSerial()) .buildShellCommand(String.format("shell \"ps -ef | grep tcpdump | grep -v grep | grep %s | awk '{print $2}' \"", taskId)) .build()); log.info("[startTcpdump] [taskId: {}] [tcpdump pid: {}]", taskId, result); return new CommandResult(T.StrUtil.isNotEmpty(result) ? 0 : 1, taskId); } /** * stop tcpdump * kill -INT {pid} */ public CommandResult stopTcpdump(String id) { String pcapFilePath = CommandExec.exec(AdbCommandBuilder.builder() .serial(this.getSerial()) .buildShellCommand(String.format("shell \"ps -ef | grep tcpdump | grep -v grep | grep %s | awk '{print $NF}' \"", id)) .build()); if (T.StrUtil.isNotEmpty(pcapFilePath)) { if (!pcapFilePath.contains("capture_all_")) { // 删除 iptables rule String[] split = T.FileUtil.mainName(pcapFilePath).split("_"); String userId = split[1]; log.info("[stopTcpdump] [remove iptables rule] [userId: {}]", userId); CommandExec.exec(AdbCommandBuilder.builder() .serial(this.getSerial()) .buildShellCommand(String.format("shell iptables -D OUTPUT -m owner --uid-owner %s -j CONNMARK --set-mark %s", userId, userId)) .build()); CommandExec.exec(AdbCommandBuilder.builder() .serial(this.getSerial()) .buildShellCommand(String.format("shell iptables -D INPUT -m connmark --mark %s -j NFLOG --nflog-group %s", userId, userId)) .build()); CommandExec.exec(AdbCommandBuilder.builder() .serial(this.getSerial()) .buildShellCommand(String.format("shell iptables -D OUTPUT -m connmark --mark %s -j NFLOG --nflog-group %s", userId, userId)) .build()); } } String result = CommandExec.exec(AdbCommandBuilder.builder() .serial(this.getSerial()) .buildShellCommand(String.format("shell \"ps -ef | grep tcpdump | grep -v grep | grep %s | awk '{print $2}' | xargs kill -INT \"", id)) .build()); log.info("[stopTcpdump] [id: {}] [pcapFilePath: {}] [result: {}]", id, pcapFilePath, result); if (T.StrUtil.isEmpty(result)) { return new CommandResult(0, pcapFilePath); } return new CommandResult(1, result); } /** * exec shell command */ public void execShellCommand(String shellCmd) { String result = CommandExec.exec(AdbCommandBuilder.builder() .serial(this.getSerial()) .buildShellCommand(shellCmd) .build()); log.info("[execShellCommand] [shellCmd: {}] [result: {}]", shellCmd, result); } /** * exec shell command */ public String execShellCommand(String cmd, Integer timeout){ Process process = CommandExec.execForProcess(AdbCommandBuilder.builder() .serial(this.getSerial()) .buildShellCommand("shell " + cmd) .build()); ExecutorService executor = Executors.newSingleThreadExecutor(); Future future = executor.submit(() -> T.IoUtil.read(process.getInputStream(), T.CharsetUtil.CHARSET_UTF_8)); try { String result = future.get(timeout, TimeUnit.SECONDS); return result; } catch (TimeoutException e) { process.destroyForcibly(); throw new APIException(RCode.TIMEOUT); } catch (ExecutionException | InterruptedException e) { throw new APIException(RCode.ERROR); } finally { executor.shutdown(); } } private synchronized ExecutorService getThreadPool() { if (threadPool == null) { threadPool = new ThreadPoolExecutor( 5, 10, 30, TimeUnit.SECONDS, new ArrayBlockingQueue(10000), new NamedThreadFactory("API-", true)); } return threadPool; } class CommandExec { public static String exec(List command) { String str = T.RuntimeUtil.execForStr(T.CharsetUtil.CHARSET_UTF_8, command.stream().toArray(String[]::new)); return str.stripTrailing(); } public static Process execForProcess(List command) { Process process = T.RuntimeUtil.exec(command.stream().toArray(String[]::new)); return process; } } }