2 * Copyright (c) 2010-2023 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
7 * This program and the accompanying materials are made available under the
8 * terms of the Eclipse Public License 2.0 which is available at
9 * http://www.eclipse.org/legal/epl-2.0
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.androiddebugbridge.internal;
15 import java.io.ByteArrayOutputStream;
16 import java.io.IOException;
17 import java.net.InetSocketAddress;
18 import java.net.Socket;
20 import java.net.URLEncoder;
21 import java.nio.charset.StandardCharsets;
22 import java.nio.file.Files;
23 import java.nio.file.Path;
24 import java.security.NoSuchAlgorithmException;
25 import java.security.spec.InvalidKeySpecException;
26 import java.util.ArrayList;
27 import java.util.Arrays;
28 import java.util.Base64;
29 import java.util.HashMap;
31 import java.util.concurrent.ExecutionException;
32 import java.util.concurrent.Future;
33 import java.util.concurrent.ScheduledExecutorService;
34 import java.util.concurrent.TimeUnit;
35 import java.util.concurrent.TimeoutException;
36 import java.util.concurrent.locks.ReentrantLock;
37 import java.util.regex.Matcher;
38 import java.util.regex.Pattern;
39 import java.util.stream.Collectors;
41 import org.eclipse.jdt.annotation.NonNullByDefault;
42 import org.eclipse.jdt.annotation.Nullable;
43 import org.openhab.core.OpenHAB;
44 import org.slf4j.Logger;
45 import org.slf4j.LoggerFactory;
47 import com.tananaev.adblib.AdbBase64;
48 import com.tananaev.adblib.AdbConnection;
49 import com.tananaev.adblib.AdbCrypto;
50 import com.tananaev.adblib.AdbStream;
53 * The {@link AndroidDebugBridgeConfiguration} class encapsulates adb device connection logic.
55 * @author Miguel Álvarez - Initial contribution
58 public class AndroidDebugBridgeDevice {
59 private static final Path ADB_FOLDER = Path.of(OpenHAB.getUserDataFolder(), ".adb");
60 public static final int ANDROID_MEDIA_STREAM = 3;
61 private final Logger logger = LoggerFactory.getLogger(AndroidDebugBridgeDevice.class);
62 private static final Pattern VOLUME_PATTERN = Pattern
63 .compile("volume is (?<current>\\d.*) in range \\[(?<min>\\d.*)\\.\\.(?<max>\\d.*)]");
64 private static final Pattern TAP_EVENT_PATTERN = Pattern.compile("(?<x>\\d+),(?<y>\\d+)");
65 private static final Pattern PACKAGE_NAME_PATTERN = Pattern
66 .compile("^([A-Za-z]{1}[A-Za-z\\d_]*\\.)+[A-Za-z][A-Za-z\\d_]*$");
67 private static final Pattern URL_PATTERN = Pattern.compile(
68 "https?:\\/\\/(www\\.)?[-a-zA-Z0-9@:%._\\+~#=]{2,256}\\.[a-z]{2,4}\\b([-a-zA-Z0-9@:%_\\+.~#?&//=]*)$");
69 private static final Pattern INPUT_EVENT_PATTERN = Pattern
70 .compile("/(?<input>\\S+): (?<n1>\\S+) (?<n2>\\S+) (?<n3>\\S+)$", Pattern.MULTILINE);
71 private static final Pattern VERSION_PATTERN = Pattern
72 .compile("^(?<major>\\d+)(\\.)?(?<minor>\\d+)?(\\.)?(?<patch>\\*|\\d+)?");
73 private static final Pattern MAC_PATTERN = Pattern.compile("^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$");
75 private static final Pattern SECURE_SHELL_INPUT_PATTERN = Pattern.compile("^[^\\|\\&;\\\"]+$");
77 private static @Nullable AdbCrypto adbCrypto;
79 private final ScheduledExecutorService scheduler;
80 private final ReentrantLock commandLock = new ReentrantLock();
82 private String ip = "127.0.0.1";
83 private int port = 5555;
84 private int timeoutSec = 5;
85 private int recordDuration;
86 private @Nullable Integer maxVolumeLevel = null;
87 private @Nullable Socket socket;
88 private @Nullable AdbConnection connection;
89 private @Nullable Future<String> commandFuture;
90 private int majorVersionNumber = 0;
91 private int minorVersionNumber = 0;
92 private int patchVersionNumber = 0;
94 * Assumed max volume for android versions that do not expose this value.
96 private int deviceMaxVolume = 25;
97 private String volumeSettingKey = "volume_music_hdmi";
99 public AndroidDebugBridgeDevice(ScheduledExecutorService scheduler) {
100 this.scheduler = scheduler;
103 public void configure(AndroidDebugBridgeConfiguration config) {
104 configureConnection(config.ip, config.port, config.timeout);
105 this.recordDuration = config.recordDuration;
106 this.volumeSettingKey = config.volumeSettingKey;
107 this.deviceMaxVolume = config.deviceMaxVolume;
110 public void configureConnection(String ip, int port, int timeout) {
113 this.timeoutSec = timeout;
116 public void sendKeyEvent(String eventCode)
117 throws InterruptedException, AndroidDebugBridgeDeviceException, TimeoutException, ExecutionException {
118 runAdbShell("input", "keyevent", eventCode);
121 public void sendText(String text)
122 throws AndroidDebugBridgeDeviceException, InterruptedException, TimeoutException, ExecutionException {
123 runAdbShell("input", "text", URLEncoder.encode(text, StandardCharsets.UTF_8));
126 public void sendTap(String point)
127 throws AndroidDebugBridgeDeviceException, InterruptedException, TimeoutException, ExecutionException {
128 var match = TAP_EVENT_PATTERN.matcher(point);
129 if (!match.matches()) {
130 throw new AndroidDebugBridgeDeviceException("Unable to parse tap event");
132 runAdbShell("input", "mouse", "tap", match.group("x"), match.group("y"));
135 public void openUrl(String url)
136 throws InterruptedException, AndroidDebugBridgeDeviceException, TimeoutException, ExecutionException {
137 var match = URL_PATTERN.matcher(url);
138 if (!match.matches()) {
139 throw new AndroidDebugBridgeDeviceException("Unable to parse url");
141 runAdbShell("am", "start", "-a", url);
144 public void startPackage(String packageName)
145 throws InterruptedException, AndroidDebugBridgeDeviceException, TimeoutException, ExecutionException {
146 if (packageName.contains("/")) {
147 startPackageWithActivity(packageName);
150 if (!PACKAGE_NAME_PATTERN.matcher(packageName).matches()) {
151 logger.warn("{} is not a valid package name", packageName);
154 var out = runAdbShell("monkey", "--pct-syskeys", "0", "-p", packageName, "-v", "1");
155 if (out.contains("monkey aborted")) {
156 startTVPackage(packageName);
160 private void startTVPackage(String packageName)
161 throws InterruptedException, AndroidDebugBridgeDeviceException, TimeoutException, ExecutionException {
162 // https://developer.android.com/training/tv/start/start
163 String result = runAdbShell("monkey", "--pct-syskeys", "0", "-c", "android.intent.category.LEANBACK_LAUNCHER",
164 "-p", packageName, "1");
165 if (result.contains("monkey aborted")) {
166 throw new AndroidDebugBridgeDeviceException("Unable to open package");
170 public void startPackageWithActivity(String packageWithActivity)
171 throws InterruptedException, AndroidDebugBridgeDeviceException, TimeoutException, ExecutionException {
172 var parts = packageWithActivity.split("/");
173 if (parts.length != 2) {
174 logger.warn("{} is not a valid package", packageWithActivity);
177 var packageName = parts[0];
178 var activityName = parts[1];
179 if (!PACKAGE_NAME_PATTERN.matcher(packageName).matches()) {
180 logger.warn("{} is not a valid package name", packageName);
183 if (!SECURE_SHELL_INPUT_PATTERN.matcher(activityName).matches()) {
184 logger.warn("{} is not a valid activity name", activityName);
187 var out = runAdbShell("am", "start", "-n", packageWithActivity);
188 if (out.contains("usage: am")) {
189 out = runAdbShell("am", "start", packageWithActivity);
191 if (out.contains("usage: am") || out.contains("Exception")) {
192 logger.warn("open {} fail; retrying to open without activity info", packageWithActivity);
193 startPackage(packageName);
197 public void stopPackage(String packageName)
198 throws AndroidDebugBridgeDeviceException, InterruptedException, TimeoutException, ExecutionException {
199 if (!PACKAGE_NAME_PATTERN.matcher(packageName).matches()) {
200 logger.warn("{} is not a valid package name", packageName);
203 runAdbShell("am", "force-stop", packageName);
206 public String getCurrentPackage() throws AndroidDebugBridgeDeviceException, InterruptedException,
207 AndroidDebugBridgeDeviceReadException, TimeoutException, ExecutionException {
209 if (isAtLeastVersion(10)) {
210 out = runAdbShell("dumpsys", "window", "displays", "|", "grep", "mFocusedApp");
212 out = runAdbShell("dumpsys", "window", "windows", "|", "grep", "mFocusedApp");
214 var targetLine = Arrays.stream(out.split("\n")).findFirst().orElse("");
215 var lineParts = targetLine.split(" ");
216 if (lineParts.length >= 2) {
217 var packageActivityName = lineParts[lineParts.length - 2];
218 if (packageActivityName.contains("/")) {
219 return packageActivityName.split("/")[0];
222 throw new AndroidDebugBridgeDeviceReadException("Unable to read package name");
225 public boolean isAwake()
226 throws InterruptedException, AndroidDebugBridgeDeviceException, TimeoutException, ExecutionException {
227 String devicesResp = runAdbShell("dumpsys", "activity", "|", "grep", "mWakefulness");
228 return devicesResp.contains("mWakefulness=Awake");
231 public boolean isScreenOn() throws InterruptedException, AndroidDebugBridgeDeviceException,
232 AndroidDebugBridgeDeviceReadException, TimeoutException, ExecutionException {
233 if (isAtLeastVersion(12)) {
234 String devicesResp = runAdbShell("getprop", "debug.tracing.screen_state");
235 return "2".equals(devicesResp.replace("\n", ""));
237 String devicesResp = runAdbShell("dumpsys", "power", "|", "grep", "'Display Power'");
238 if (devicesResp.contains("=")) {
240 var state = devicesResp.split("=")[1].trim();
241 return "ON".equals(state);
242 } catch (NumberFormatException e) {
243 logger.debug("Unable to parse device screen state: {}", e.getMessage());
246 throw new AndroidDebugBridgeDeviceReadException("Unable to read screen state");
249 public boolean isPlayingMedia(String currentApp)
250 throws AndroidDebugBridgeDeviceException, InterruptedException, TimeoutException, ExecutionException {
251 String devicesResp = runAdbShell("dumpsys", "media_session", "|", "grep", "-A", "100", "'Sessions Stack'", "|",
252 "grep", "-A", "50", currentApp);
253 String[] mediaSessions = devicesResp.split("\n\n");
254 if (mediaSessions.length == 0) {
255 // no media session found for current app
258 boolean isPlaying = mediaSessions[0].contains("PlaybackState {state=3");
259 logger.debug("device media state playing {}", isPlaying);
263 public boolean isPlayingAudio()
264 throws AndroidDebugBridgeDeviceException, InterruptedException, TimeoutException, ExecutionException {
265 String audioDump = runAdbShell("dumpsys", "audio", "|", "grep", "ID:");
266 return audioDump.contains("state:started");
269 public VolumeInfo getMediaVolume() throws AndroidDebugBridgeDeviceException, InterruptedException,
270 AndroidDebugBridgeDeviceReadException, TimeoutException, ExecutionException {
271 return getVolume(ANDROID_MEDIA_STREAM);
274 public void setMediaVolume(int volume)
275 throws AndroidDebugBridgeDeviceException, InterruptedException, TimeoutException, ExecutionException {
276 setVolume(ANDROID_MEDIA_STREAM, volume);
279 public int getPowerWakeLock() throws InterruptedException, AndroidDebugBridgeDeviceException,
280 AndroidDebugBridgeDeviceReadException, TimeoutException, ExecutionException {
281 String lockResp = runAdbShell("dumpsys", "power", "|", "grep", "Locks", "|", "grep", "'size='");
282 if (lockResp.contains("=")) {
284 return Integer.parseInt(lockResp.replace("\n", "").split("=")[1].trim());
285 } catch (NumberFormatException e) {
286 String message = String.format("Unable to parse device wake-lock '%s'", lockResp);
287 logger.debug("{}: {}", message, e.getMessage());
288 throw new AndroidDebugBridgeDeviceReadException(message);
291 throw new AndroidDebugBridgeDeviceReadException(String.format("Unable to read wake-lock '%s'", lockResp));
294 private void setVolume(int stream, int volume)
295 throws AndroidDebugBridgeDeviceException, InterruptedException, TimeoutException, ExecutionException {
296 if (isAtLeastVersion(12)) {
297 runAdbShell("service", "call", "audio", "11", "i32", String.valueOf(stream), "i32", String.valueOf(volume),
299 } else if (isAtLeastVersion(11)) {
300 runAdbShell("service", "call", "audio", "10", "i32", String.valueOf(stream), "i32", String.valueOf(volume),
303 runAdbShell("media", "volume", "--show", "--stream", String.valueOf(stream), "--set",
304 String.valueOf(volume));
308 public String getModel() throws AndroidDebugBridgeDeviceException, InterruptedException,
309 AndroidDebugBridgeDeviceReadException, TimeoutException, ExecutionException {
310 return getDeviceProp("ro.product.model");
313 public String getAndroidVersion() throws AndroidDebugBridgeDeviceException, InterruptedException,
314 AndroidDebugBridgeDeviceReadException, TimeoutException, ExecutionException {
315 return getDeviceProp("ro.build.version.release");
318 public void setAndroidVersion(String version) {
319 var matcher = VERSION_PATTERN.matcher(version);
320 if (!matcher.find()) {
321 logger.warn("Unable to parse android version");
324 this.majorVersionNumber = Integer.parseInt(matcher.group("major"));
325 var minorMatch = matcher.group("minor");
326 var patchMatch = matcher.group("patch");
327 this.minorVersionNumber = minorMatch != null ? Integer.parseInt(minorMatch) : 0;
328 this.patchVersionNumber = patchMatch != null ? Integer.parseInt(patchMatch) : 0;
331 public String getBrand() throws AndroidDebugBridgeDeviceException, InterruptedException,
332 AndroidDebugBridgeDeviceReadException, TimeoutException, ExecutionException {
333 return getDeviceProp("ro.product.brand");
336 public String getSerialNo() throws AndroidDebugBridgeDeviceException, InterruptedException,
337 AndroidDebugBridgeDeviceReadException, TimeoutException, ExecutionException {
338 return getDeviceProp("ro.serialno");
341 public String getMacAddress() throws AndroidDebugBridgeDeviceException, InterruptedException,
342 AndroidDebugBridgeDeviceReadException, TimeoutException, ExecutionException {
343 var macAddress = runAdbShell("cat", "/sys/class/net/wlan0/address").replace("\n", "").replace("\r", "");
344 var matcher = MAC_PATTERN.matcher(macAddress);
345 if (!matcher.find()) {
346 macAddress = runAdbShell("ip", "address", "|", "grep", "-m", "1", "link/ether", "|", "awk", "'{print $2}'")
347 .replace("\n", "").replace("\r", "");
348 matcher = MAC_PATTERN.matcher(macAddress);
349 if (matcher.find()) {
353 return "00:00:00:00:00:00";
356 private String getDeviceProp(String name) throws AndroidDebugBridgeDeviceException, InterruptedException,
357 AndroidDebugBridgeDeviceReadException, TimeoutException, ExecutionException {
358 var propValue = runAdbShell("getprop", name, "&&", "sleep", "0.3").replace("\n", "").replace("\r", "");
359 if (propValue.length() == 0) {
360 throw new AndroidDebugBridgeDeviceReadException(String.format("Unable to get device property '%s'", name));
365 private VolumeInfo getVolume(int stream) throws AndroidDebugBridgeDeviceException, InterruptedException,
366 AndroidDebugBridgeDeviceReadException, TimeoutException, ExecutionException {
367 if (isAtLeastVersion(11)) {
368 String volumeResp = runAdbShell("settings", "get", "system", volumeSettingKey);
369 var maxVolumeLevel = this.maxVolumeLevel;
370 if (maxVolumeLevel == null) {
372 maxVolumeLevel = Integer.parseInt(getDeviceProp("ro.config.media_vol_steps"));
373 this.maxVolumeLevel = maxVolumeLevel;
374 } catch (NumberFormatException ignored) {
375 logger.debug("Max volume level not available, using 'deviceMaxVolume' config");
376 maxVolumeLevel = deviceMaxVolume;
379 return new VolumeInfo(Integer.parseInt(volumeResp.replace("\n", "")), 0, maxVolumeLevel);
381 String volumeResp = runAdbShell("media", "volume", "--show", "--stream", String.valueOf(stream), "--get",
382 "|", "grep", "volume");
383 Matcher matcher = VOLUME_PATTERN.matcher(volumeResp);
384 if (!matcher.find()) {
385 throw new AndroidDebugBridgeDeviceReadException("Unable to get volume info");
387 var volumeInfo = new VolumeInfo(Integer.parseInt(matcher.group("current")),
388 Integer.parseInt(matcher.group("min")), Integer.parseInt(matcher.group("max")));
389 logger.debug("Device {}:{} VolumeInfo: current {}, min {}, max {}", this.ip, this.port, volumeInfo.current,
390 volumeInfo.min, volumeInfo.max);
395 public String recordInputEvents()
396 throws AndroidDebugBridgeDeviceException, InterruptedException, TimeoutException, ExecutionException {
397 String out = runAdbShell(recordDuration * 2, "getevent", "&", "sleep", Integer.toString(recordDuration), "&&",
399 var matcher = INPUT_EVENT_PATTERN.matcher(out);
400 var commandList = new ArrayList<String>();
402 while (matcher.find()) {
403 String inputPath = matcher.group("input");
404 int n1 = Integer.parseInt(matcher.group("n1"), 16);
405 int n2 = Integer.parseInt(matcher.group("n2"), 16);
406 int n3 = Integer.parseInt(matcher.group("n3"), 16);
407 commandList.add(String.format("sendevent /%s %d %d %d", inputPath, n1, n2, n3));
409 } catch (NumberFormatException e) {
410 logger.warn("NumberFormatException while parsing events, aborting");
413 return String.join(" && ", commandList);
416 public void sendInputEvents(String command)
417 throws AndroidDebugBridgeDeviceException, InterruptedException, TimeoutException, ExecutionException {
418 String out = runAdbShell(command.split(" "));
419 if (out.length() != 0) {
420 logger.warn("Device event unexpected output: {}", out);
421 throw new AndroidDebugBridgeDeviceException("Device event execution fail");
425 public void rebootDevice()
426 throws AndroidDebugBridgeDeviceException, InterruptedException, TimeoutException, ExecutionException {
428 runAdbShell("reboot", "&", "sleep", "0.1", "&&", "exit");
434 public void powerOffDevice()
435 throws AndroidDebugBridgeDeviceException, InterruptedException, TimeoutException, ExecutionException {
437 runAdbShell("reboot", "-p", "&", "sleep", "0.1", "&&", "exit");
443 public void startIntent(String command)
444 throws AndroidDebugBridgeDeviceException, ExecutionException, InterruptedException, TimeoutException {
445 String[] commandParts = command.split("\\|\\|");
446 if (commandParts.length == 0) {
447 throw new AndroidDebugBridgeDeviceException("Empty command");
449 String targetPackage = commandParts[0];
450 var targetPackageParts = targetPackage.split("/");
451 if (targetPackageParts.length > 2) {
452 throw new AndroidDebugBridgeDeviceException("Invalid target package " + targetPackage);
454 if (!PACKAGE_NAME_PATTERN.matcher(targetPackageParts[0]).matches()) {
455 logger.warn("{} is not a valid package name", targetPackageParts[0]);
458 if (targetPackageParts.length == 2 && !SECURE_SHELL_INPUT_PATTERN.matcher(targetPackageParts[1]).matches()) {
459 logger.warn("{} is not a valid activity name", targetPackageParts[1]);
463 String action = null;
465 String dataUri = null;
467 String mimeType = null;
469 String category = null;
471 String component = null;
474 Map<String, Boolean> extraBooleans = new HashMap<>();
475 Map<String, String> extraStrings = new HashMap<>();
476 Map<String, Integer> extraIntegers = new HashMap<>();
477 Map<String, Float> extraFloats = new HashMap<>();
478 Map<String, Long> extraLongs = new HashMap<>();
479 Map<String, URI> extraUris = new HashMap<>();
480 for (var i = 1; i < commandParts.length; i++) {
481 var commandPart = commandParts[i];
482 var endToken = commandPart.indexOf(">");
483 var argName = commandPart.substring(0, endToken + 1);
484 var argValue = commandPart.substring(endToken + 1);
490 if (!PACKAGE_NAME_PATTERN.matcher(argValue).matches()) {
491 logger.warn("{} is not a valid action name", argValue);
498 if (!SECURE_SHELL_INPUT_PATTERN.matcher(argValue).matches()) {
499 logger.warn("{}, insecure input value", argValue);
506 if (!SECURE_SHELL_INPUT_PATTERN.matcher(argValue).matches()) {
507 logger.warn("{}, insecure input value", argValue);
514 if (!SECURE_SHELL_INPUT_PATTERN.matcher(argValue).matches()) {
515 logger.warn("{}, insecure input value", argValue);
522 if (!SECURE_SHELL_INPUT_PATTERN.matcher(argValue).matches()) {
523 logger.warn("{}, insecure input value", argValue);
526 component = argValue;
530 if (!SECURE_SHELL_INPUT_PATTERN.matcher(argValue).matches()) {
531 logger.warn("{}, insecure input value", argValue);
538 valueParts = argValue.split(" ");
539 if (valueParts.length != 2) {
540 logger.warn("argument '{}' requires a key value pair separated by space, current value '{}'",
544 if (!SECURE_SHELL_INPUT_PATTERN.matcher(valueParts[0]).matches()) {
545 logger.warn("{}, insecure input value", valueParts[0]);
548 if (!SECURE_SHELL_INPUT_PATTERN.matcher(valueParts[1]).matches()) {
549 logger.warn("{}, insecure input value", valueParts[1]);
552 extraStrings.put(valueParts[0], valueParts[1]);
555 valueParts = argValue.split(" ");
556 if (valueParts.length != 2) {
557 logger.warn("argument '{}' requires a key value pair separated by space, current value '{}'",
561 if (!SECURE_SHELL_INPUT_PATTERN.matcher(valueParts[0]).matches()) {
562 logger.warn("{}, insecure input value", valueParts[0]);
565 if (!SECURE_SHELL_INPUT_PATTERN.matcher(valueParts[1]).matches()) {
566 logger.warn("{}, insecure input value", valueParts[1]);
569 extraBooleans.put(valueParts[0], Boolean.parseBoolean(valueParts[1]));
572 valueParts = argValue.split(" ");
573 if (valueParts.length != 2) {
574 logger.warn("argument '{}' requires a key value pair separated by space, current value '{}'",
578 if (!SECURE_SHELL_INPUT_PATTERN.matcher(valueParts[0]).matches()) {
579 logger.warn("{}, insecure input value", valueParts[0]);
582 if (!SECURE_SHELL_INPUT_PATTERN.matcher(valueParts[1]).matches()) {
583 logger.warn("{}, insecure input value", valueParts[1]);
587 extraIntegers.put(valueParts[0], Integer.parseInt(valueParts[1]));
588 } catch (NumberFormatException e) {
589 logger.warn("Unable to parse {} as integer", valueParts[1]);
594 valueParts = argValue.split(" ");
595 if (valueParts.length != 2) {
596 logger.warn("argument '{}' requires a key value pair separated by space, current value '{}'",
600 if (!SECURE_SHELL_INPUT_PATTERN.matcher(valueParts[0]).matches()) {
601 logger.warn("{}, insecure input value", valueParts[0]);
604 if (!SECURE_SHELL_INPUT_PATTERN.matcher(valueParts[1]).matches()) {
605 logger.warn("{}, insecure input value", valueParts[1]);
609 extraLongs.put(valueParts[0], Long.parseLong(valueParts[1]));
610 } catch (NumberFormatException e) {
611 logger.warn("Unable to parse {} as long", valueParts[1]);
616 valueParts = argValue.split(" ");
617 if (valueParts.length != 2) {
618 logger.warn("argument '{}' requires a key value pair separated by space, current value '{}'",
622 if (!SECURE_SHELL_INPUT_PATTERN.matcher(valueParts[0]).matches()) {
623 logger.warn("{}, insecure input value", valueParts[0]);
626 if (!SECURE_SHELL_INPUT_PATTERN.matcher(valueParts[1]).matches()) {
627 logger.warn("{}, insecure input value", valueParts[1]);
631 extraFloats.put(valueParts[0], Float.parseFloat(valueParts[1]));
632 } catch (NumberFormatException e) {
633 logger.warn("Unable to parse {} as float", valueParts[1]);
638 valueParts = argValue.split(" ");
639 if (valueParts.length != 2) {
640 logger.warn("argument '{}' requires a key value pair separated by space, current value '{}'",
644 if (!SECURE_SHELL_INPUT_PATTERN.matcher(valueParts[0]).matches()) {
645 logger.warn("{}, insecure input value", valueParts[0]);
648 if (!SECURE_SHELL_INPUT_PATTERN.matcher(valueParts[1]).matches()) {
649 logger.warn("{}, insecure input value", valueParts[1]);
652 extraUris.put(valueParts[0], URI.create(valueParts[1]));
655 throw new AndroidDebugBridgeDeviceException("Unsupported arg " + argName
656 + ". Open an issue or pr for it if you think support should be added.");
660 StringBuilder adbCommandBuilder = new StringBuilder("am start -n " + targetPackage);
661 if (action != null) {
662 adbCommandBuilder.append(" -a ").append(action);
664 if (dataUri != null) {
665 adbCommandBuilder.append(" -d ").append(dataUri);
667 if (mimeType != null) {
668 adbCommandBuilder.append(" -t ").append(mimeType);
670 if (category != null) {
671 adbCommandBuilder.append(" -c ").append(category);
673 if (component != null) {
674 adbCommandBuilder.append(" -n ").append(component);
677 adbCommandBuilder.append(" -f ").append(flags);
679 if (!extraStrings.isEmpty()) {
680 adbCommandBuilder.append(extraStrings.entrySet().stream()
681 .map(entry -> " --es \"" + entry.getKey() + "\" \"" + entry.getValue() + "\"")
682 .collect(Collectors.joining(" ")));
684 if (!extraBooleans.isEmpty()) {
685 adbCommandBuilder.append(extraBooleans.entrySet().stream()
686 .map(entry -> " --ez \"" + entry.getKey() + "\" " + entry.getValue())
687 .collect(Collectors.joining(" ")));
689 if (!extraIntegers.isEmpty()) {
690 adbCommandBuilder.append(extraIntegers.entrySet().stream()
691 .map(entry -> " --ei \"" + entry.getKey() + "\" " + entry.getValue())
692 .collect(Collectors.joining(" ")));
694 if (!extraFloats.isEmpty()) {
695 adbCommandBuilder.append(
696 extraFloats.entrySet().stream().map(entry -> " --ef \"" + entry.getKey() + "\" " + entry.getValue())
697 .collect(Collectors.joining(" ")));
699 if (!extraLongs.isEmpty()) {
700 adbCommandBuilder.append(
701 extraLongs.entrySet().stream().map(entry -> " --el \"" + entry.getKey() + "\" " + entry.getValue())
702 .collect(Collectors.joining(" ")));
704 runAdbShell(adbCommandBuilder.toString());
707 public boolean isConnected() {
708 var currentSocket = socket;
709 return currentSocket != null && currentSocket.isConnected();
712 public void connect() throws AndroidDebugBridgeDeviceException, InterruptedException {
714 AdbConnection adbConnection;
716 AdbCrypto crypto = adbCrypto;
717 if (crypto == null) {
718 throw new AndroidDebugBridgeDeviceException("Device not connected");
723 sock.connect(new InetSocketAddress(ip, port), (int) TimeUnit.SECONDS.toMillis(15));
724 } catch (IOException e) {
725 logger.debug("Error connecting to {}: [{}] {}", ip, e.getClass().getName(), e.getMessage());
726 if ("Socket closed".equals(e.getMessage())) {
727 // Connection aborted by us
728 throw new InterruptedException();
730 throw new AndroidDebugBridgeDeviceException("Can not open socket " + ip + ":" + port);
733 adbConnection = AdbConnection.create(sock, crypto);
734 connection = adbConnection;
735 adbConnection.connect(15, TimeUnit.SECONDS, false);
736 } catch (IOException e) {
737 logger.debug("Error connecting to {}: {}", ip, e.getMessage());
738 throw new AndroidDebugBridgeDeviceException("Can not open adb connection " + ip + ":" + port);
742 private String runAdbShell(String... args)
743 throws InterruptedException, AndroidDebugBridgeDeviceException, TimeoutException, ExecutionException {
744 return runAdbShell(timeoutSec, args);
747 private String runAdbShell(int commandTimeout, String... args)
748 throws InterruptedException, AndroidDebugBridgeDeviceException, TimeoutException, ExecutionException {
749 var adb = connection;
751 throw new AndroidDebugBridgeDeviceException("Device not connected");
755 var commandFuture = scheduler.submit(() -> {
756 var byteArrayOutputStream = new ByteArrayOutputStream();
757 String cmd = String.join(" ", args);
758 logger.debug("{} - shell:{}", ip, cmd);
759 try (AdbStream stream = adb.open("shell:" + cmd)) {
761 byteArrayOutputStream.writeBytes(stream.read());
762 } while (!stream.isClosed());
763 } catch (IOException e) {
764 if (!"Stream closed".equals(e.getMessage())) {
768 return byteArrayOutputStream.toString(StandardCharsets.US_ASCII);
770 this.commandFuture = commandFuture;
771 return commandFuture.get(commandTimeout, TimeUnit.SECONDS);
773 var commandFuture = this.commandFuture;
774 if (commandFuture != null) {
775 commandFuture.cancel(true);
776 this.commandFuture = null;
778 commandLock.unlock();
782 public static void initADB() {
783 Logger logger = LoggerFactory.getLogger(AndroidDebugBridgeDevice.class);
785 if (!Files.exists(ADB_FOLDER) || !Files.isDirectory(ADB_FOLDER)) {
786 Files.createDirectory(ADB_FOLDER);
787 logger.info("Binding folder {} created", ADB_FOLDER);
789 adbCrypto = loadKeyPair(ADB_FOLDER.resolve("adb_pub.key"), ADB_FOLDER.resolve("adb.key"));
790 } catch (NoSuchAlgorithmException | IOException | InvalidKeySpecException e) {
791 logger.warn("Unable to setup adb keys: {}", e.getMessage());
795 private static AdbBase64 getBase64Impl() {
796 return bytes -> new String(Base64.getEncoder().encode(bytes), StandardCharsets.US_ASCII);
799 private static AdbCrypto loadKeyPair(Path pubKey, Path privKey)
800 throws NoSuchAlgorithmException, IOException, InvalidKeySpecException {
803 if (Files.exists(pubKey) && Files.exists(privKey)) {
805 c = AdbCrypto.loadAdbKeyPair(getBase64Impl(), privKey.toFile(), pubKey.toFile());
806 } catch (IOException ignored) {
812 c = AdbCrypto.generateAdbKeyPair(getBase64Impl());
813 c.saveAdbKeyPair(privKey.toFile(), pubKey.toFile());
818 public void disconnect() {
819 var commandFuture = this.commandFuture;
820 if (commandFuture != null && !commandFuture.isDone()) {
821 commandFuture.cancel(true);
823 var adb = connection;
828 } catch (IOException ignored) {
835 } catch (IOException ignored) {
841 private boolean isAtLeastVersion(int major) {
842 return isAtLeastVersion(major, 0);
845 private boolean isAtLeastVersion(int major, int minor) {
846 return isAtLeastVersion(major, minor, 0);
849 private boolean isAtLeastVersion(int major, int minor, int patch) {
850 return majorVersionNumber > major || (majorVersionNumber == major
851 && (minorVersionNumber > minor || (minorVersionNumber == minor && patchVersionNumber >= patch)));
854 public static class VolumeInfo {
859 VolumeInfo(int current, int min, int max) {
860 this.current = current;