]> git.basschouten.com Git - openhab-addons.git/blob
c00c93b4c4695f6b0501e751d196db622a710244
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2023 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
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
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.binding.androiddebugbridge.internal;
14
15 import java.io.ByteArrayOutputStream;
16 import java.io.IOException;
17 import java.net.InetSocketAddress;
18 import java.net.Socket;
19 import java.net.URI;
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;
30 import java.util.Map;
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;
40
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;
46
47 import com.tananaev.adblib.AdbBase64;
48 import com.tananaev.adblib.AdbConnection;
49 import com.tananaev.adblib.AdbCrypto;
50 import com.tananaev.adblib.AdbStream;
51
52 /**
53  * The {@link AndroidDebugBridgeConfiguration} class encapsulates adb device connection logic.
54  *
55  * @author Miguel Álvarez - Initial contribution
56  */
57 @NonNullByDefault
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})$");
74
75     private static final Pattern SECURE_SHELL_INPUT_PATTERN = Pattern.compile("^[^\\|\\&;\\\"]+$");
76
77     private static @Nullable AdbCrypto adbCrypto;
78
79     private final ScheduledExecutorService scheduler;
80     private final ReentrantLock commandLock = new ReentrantLock();
81
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;
93     /**
94      * Assumed max volume for android versions that do not expose this value.
95      */
96     private int deviceMaxVolume = 25;
97     private String volumeSettingKey = "volume_music_hdmi";
98
99     public AndroidDebugBridgeDevice(ScheduledExecutorService scheduler) {
100         this.scheduler = scheduler;
101     }
102
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;
108     }
109
110     public void configureConnection(String ip, int port, int timeout) {
111         this.ip = ip;
112         this.port = port;
113         this.timeoutSec = timeout;
114     }
115
116     public void sendKeyEvent(String eventCode)
117             throws InterruptedException, AndroidDebugBridgeDeviceException, TimeoutException, ExecutionException {
118         runAdbShell("input", "keyevent", eventCode);
119     }
120
121     public void sendText(String text)
122             throws AndroidDebugBridgeDeviceException, InterruptedException, TimeoutException, ExecutionException {
123         runAdbShell("input", "text", URLEncoder.encode(text, StandardCharsets.UTF_8));
124     }
125
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");
131         }
132         runAdbShell("input", "mouse", "tap", match.group("x"), match.group("y"));
133     }
134
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");
140         }
141         runAdbShell("am", "start", "-a", url);
142     }
143
144     public void startPackage(String packageName)
145             throws InterruptedException, AndroidDebugBridgeDeviceException, TimeoutException, ExecutionException {
146         if (packageName.contains("/")) {
147             startPackageWithActivity(packageName);
148             return;
149         }
150         if (!PACKAGE_NAME_PATTERN.matcher(packageName).matches()) {
151             logger.warn("{} is not a valid package name", packageName);
152             return;
153         }
154         var out = runAdbShell("monkey", "--pct-syskeys", "0", "-p", packageName, "-v", "1");
155         if (out.contains("monkey aborted")) {
156             startTVPackage(packageName);
157         }
158     }
159
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");
167         }
168     }
169
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);
175             return;
176         }
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);
181             return;
182         }
183         if (!SECURE_SHELL_INPUT_PATTERN.matcher(activityName).matches()) {
184             logger.warn("{} is not a valid activity name", activityName);
185             return;
186         }
187         var out = runAdbShell("am", "start", "-n", packageWithActivity);
188         if (out.contains("usage: am")) {
189             out = runAdbShell("am", "start", packageWithActivity);
190         }
191         if (out.contains("usage: am") || out.contains("Exception")) {
192             logger.warn("open {} fail; retrying to open without activity info", packageWithActivity);
193             startPackage(packageName);
194         }
195     }
196
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);
201             return;
202         }
203         runAdbShell("am", "force-stop", packageName);
204     }
205
206     public String getCurrentPackage() throws AndroidDebugBridgeDeviceException, InterruptedException,
207             AndroidDebugBridgeDeviceReadException, TimeoutException, ExecutionException {
208         String out;
209         if (isAtLeastVersion(10)) {
210             out = runAdbShell("dumpsys", "window", "displays", "|", "grep", "mFocusedApp");
211         } else {
212             out = runAdbShell("dumpsys", "window", "windows", "|", "grep", "mFocusedApp");
213         }
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];
220             }
221         }
222         throw new AndroidDebugBridgeDeviceReadException("Unable to read package name");
223     }
224
225     public boolean isAwake()
226             throws InterruptedException, AndroidDebugBridgeDeviceException, TimeoutException, ExecutionException {
227         String devicesResp = runAdbShell("dumpsys", "activity", "|", "grep", "mWakefulness");
228         return devicesResp.contains("mWakefulness=Awake");
229     }
230
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", ""));
236         }
237         String devicesResp = runAdbShell("dumpsys", "power", "|", "grep", "'Display Power'");
238         if (devicesResp.contains("=")) {
239             try {
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());
244             }
245         }
246         throw new AndroidDebugBridgeDeviceReadException("Unable to read screen state");
247     }
248
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
256             return false;
257         }
258         boolean isPlaying = mediaSessions[0].contains("PlaybackState {state=3");
259         logger.debug("device media state playing {}", isPlaying);
260         return isPlaying;
261     }
262
263     public boolean isPlayingAudio()
264             throws AndroidDebugBridgeDeviceException, InterruptedException, TimeoutException, ExecutionException {
265         String audioDump = runAdbShell("dumpsys", "audio", "|", "grep", "ID:");
266         return audioDump.contains("state:started");
267     }
268
269     public VolumeInfo getMediaVolume() throws AndroidDebugBridgeDeviceException, InterruptedException,
270             AndroidDebugBridgeDeviceReadException, TimeoutException, ExecutionException {
271         return getVolume(ANDROID_MEDIA_STREAM);
272     }
273
274     public void setMediaVolume(int volume)
275             throws AndroidDebugBridgeDeviceException, InterruptedException, TimeoutException, ExecutionException {
276         setVolume(ANDROID_MEDIA_STREAM, volume);
277     }
278
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("=")) {
283             try {
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);
289             }
290         }
291         throw new AndroidDebugBridgeDeviceReadException(String.format("Unable to read wake-lock '%s'", lockResp));
292     }
293
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),
298                     "i32", "1");
299         } else if (isAtLeastVersion(11)) {
300             runAdbShell("service", "call", "audio", "10", "i32", String.valueOf(stream), "i32", String.valueOf(volume),
301                     "i32", "1");
302         } else {
303             runAdbShell("media", "volume", "--show", "--stream", String.valueOf(stream), "--set",
304                     String.valueOf(volume));
305         }
306     }
307
308     public String getModel() throws AndroidDebugBridgeDeviceException, InterruptedException,
309             AndroidDebugBridgeDeviceReadException, TimeoutException, ExecutionException {
310         return getDeviceProp("ro.product.model");
311     }
312
313     public String getAndroidVersion() throws AndroidDebugBridgeDeviceException, InterruptedException,
314             AndroidDebugBridgeDeviceReadException, TimeoutException, ExecutionException {
315         return getDeviceProp("ro.build.version.release");
316     }
317
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");
322             return;
323         }
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;
329     }
330
331     public String getBrand() throws AndroidDebugBridgeDeviceException, InterruptedException,
332             AndroidDebugBridgeDeviceReadException, TimeoutException, ExecutionException {
333         return getDeviceProp("ro.product.brand");
334     }
335
336     public String getSerialNo() throws AndroidDebugBridgeDeviceException, InterruptedException,
337             AndroidDebugBridgeDeviceReadException, TimeoutException, ExecutionException {
338         return getDeviceProp("ro.serialno");
339     }
340
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()) {
350                 return macAddress;
351             }
352         }
353         return "00:00:00:00:00:00";
354     }
355
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));
361         }
362         return propValue;
363     }
364
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) {
371                 try {
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;
377                 }
378             }
379             return new VolumeInfo(Integer.parseInt(volumeResp.replace("\n", "")), 0, maxVolumeLevel);
380         } else {
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");
386             }
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);
391             return volumeInfo;
392         }
393     }
394
395     public String recordInputEvents()
396             throws AndroidDebugBridgeDeviceException, InterruptedException, TimeoutException, ExecutionException {
397         String out = runAdbShell(recordDuration * 2, "getevent", "&", "sleep", Integer.toString(recordDuration), "&&",
398                 "exit");
399         var matcher = INPUT_EVENT_PATTERN.matcher(out);
400         var commandList = new ArrayList<String>();
401         try {
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));
408             }
409         } catch (NumberFormatException e) {
410             logger.warn("NumberFormatException while parsing events, aborting");
411             return "";
412         }
413         return String.join(" && ", commandList);
414     }
415
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");
422         }
423     }
424
425     public void rebootDevice()
426             throws AndroidDebugBridgeDeviceException, InterruptedException, TimeoutException, ExecutionException {
427         try {
428             runAdbShell("reboot", "&", "sleep", "0.1", "&&", "exit");
429         } finally {
430             disconnect();
431         }
432     }
433
434     public void powerOffDevice()
435             throws AndroidDebugBridgeDeviceException, InterruptedException, TimeoutException, ExecutionException {
436         try {
437             runAdbShell("reboot", "-p", "&", "sleep", "0.1", "&&", "exit");
438         } finally {
439             disconnect();
440         }
441     }
442
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");
448         }
449         String targetPackage = commandParts[0];
450         var targetPackageParts = targetPackage.split("/");
451         if (targetPackageParts.length > 2) {
452             throw new AndroidDebugBridgeDeviceException("Invalid target package " + targetPackage);
453         }
454         if (!PACKAGE_NAME_PATTERN.matcher(targetPackageParts[0]).matches()) {
455             logger.warn("{} is not a valid package name", targetPackageParts[0]);
456             return;
457         }
458         if (targetPackageParts.length == 2 && !SECURE_SHELL_INPUT_PATTERN.matcher(targetPackageParts[1]).matches()) {
459             logger.warn("{} is not a valid activity name", targetPackageParts[1]);
460             return;
461         }
462         @Nullable
463         String action = null;
464         @Nullable
465         String dataUri = null;
466         @Nullable
467         String mimeType = null;
468         @Nullable
469         String category = null;
470         @Nullable
471         String component = null;
472         @Nullable
473         String flags = 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);
485
486             String[] valueParts;
487             switch (argName) {
488                 case "<a>":
489                 case "<action>":
490                     if (!PACKAGE_NAME_PATTERN.matcher(argValue).matches()) {
491                         logger.warn("{} is not a valid action name", argValue);
492                         return;
493                     }
494                     action = argValue;
495                     break;
496                 case "<d>":
497                 case "":
498                     if (!SECURE_SHELL_INPUT_PATTERN.matcher(argValue).matches()) {
499                         logger.warn("{}, insecure input value", argValue);
500                         return;
501                     }
502                     dataUri = argValue;
503                     break;
504                 case "<t>":
505                 case "<mime_type>":
506                     if (!SECURE_SHELL_INPUT_PATTERN.matcher(argValue).matches()) {
507                         logger.warn("{}, insecure input value", argValue);
508                         return;
509                     }
510                     mimeType = argValue;
511                     break;
512                 case "<c>":
513                 case "<category>":
514                     if (!SECURE_SHELL_INPUT_PATTERN.matcher(argValue).matches()) {
515                         logger.warn("{}, insecure input value", argValue);
516                         return;
517                     }
518                     category = argValue;
519                     break;
520                 case "<n>":
521                 case "<component>":
522                     if (!SECURE_SHELL_INPUT_PATTERN.matcher(argValue).matches()) {
523                         logger.warn("{}, insecure input value", argValue);
524                         return;
525                     }
526                     component = argValue;
527                     break;
528                 case "<f>":
529                 case "<flags>":
530                     if (!SECURE_SHELL_INPUT_PATTERN.matcher(argValue).matches()) {
531                         logger.warn("{}, insecure input value", argValue);
532                         return;
533                     }
534                     flags = argValue;
535                     break;
536                 case "<e>":
537                 case "<es>":
538                     valueParts = argValue.split(" ");
539                     if (valueParts.length != 2) {
540                         logger.warn("argument '{}' requires a key value pair separated by space, current value '{}'",
541                                 argName, argValue);
542                         return;
543                     }
544                     if (!SECURE_SHELL_INPUT_PATTERN.matcher(valueParts[0]).matches()) {
545                         logger.warn("{}, insecure input value", valueParts[0]);
546                         return;
547                     }
548                     if (!SECURE_SHELL_INPUT_PATTERN.matcher(valueParts[1]).matches()) {
549                         logger.warn("{}, insecure input value", valueParts[1]);
550                         return;
551                     }
552                     extraStrings.put(valueParts[0], valueParts[1]);
553                     break;
554                 case "<ez>":
555                     valueParts = argValue.split(" ");
556                     if (valueParts.length != 2) {
557                         logger.warn("argument '{}' requires a key value pair separated by space, current value '{}'",
558                                 argName, argValue);
559                         return;
560                     }
561                     if (!SECURE_SHELL_INPUT_PATTERN.matcher(valueParts[0]).matches()) {
562                         logger.warn("{}, insecure input value", valueParts[0]);
563                         return;
564                     }
565                     if (!SECURE_SHELL_INPUT_PATTERN.matcher(valueParts[1]).matches()) {
566                         logger.warn("{}, insecure input value", valueParts[1]);
567                         return;
568                     }
569                     extraBooleans.put(valueParts[0], Boolean.parseBoolean(valueParts[1]));
570                     break;
571                 case "<ei>":
572                     valueParts = argValue.split(" ");
573                     if (valueParts.length != 2) {
574                         logger.warn("argument '{}' requires a key value pair separated by space, current value '{}'",
575                                 argName, argValue);
576                         return;
577                     }
578                     if (!SECURE_SHELL_INPUT_PATTERN.matcher(valueParts[0]).matches()) {
579                         logger.warn("{}, insecure input value", valueParts[0]);
580                         return;
581                     }
582                     if (!SECURE_SHELL_INPUT_PATTERN.matcher(valueParts[1]).matches()) {
583                         logger.warn("{}, insecure input value", valueParts[1]);
584                         return;
585                     }
586                     try {
587                         extraIntegers.put(valueParts[0], Integer.parseInt(valueParts[1]));
588                     } catch (NumberFormatException e) {
589                         logger.warn("Unable to parse {} as integer", valueParts[1]);
590                         return;
591                     }
592                     break;
593                 case "<el>":
594                     valueParts = argValue.split(" ");
595                     if (valueParts.length != 2) {
596                         logger.warn("argument '{}' requires a key value pair separated by space, current value '{}'",
597                                 argName, argValue);
598                         return;
599                     }
600                     if (!SECURE_SHELL_INPUT_PATTERN.matcher(valueParts[0]).matches()) {
601                         logger.warn("{}, insecure input value", valueParts[0]);
602                         return;
603                     }
604                     if (!SECURE_SHELL_INPUT_PATTERN.matcher(valueParts[1]).matches()) {
605                         logger.warn("{}, insecure input value", valueParts[1]);
606                         return;
607                     }
608                     try {
609                         extraLongs.put(valueParts[0], Long.parseLong(valueParts[1]));
610                     } catch (NumberFormatException e) {
611                         logger.warn("Unable to parse {} as long", valueParts[1]);
612                         return;
613                     }
614                     break;
615                 case "<ef>":
616                     valueParts = argValue.split(" ");
617                     if (valueParts.length != 2) {
618                         logger.warn("argument '{}' requires a key value pair separated by space, current value '{}'",
619                                 argName, argValue);
620                         return;
621                     }
622                     if (!SECURE_SHELL_INPUT_PATTERN.matcher(valueParts[0]).matches()) {
623                         logger.warn("{}, insecure input value", valueParts[0]);
624                         return;
625                     }
626                     if (!SECURE_SHELL_INPUT_PATTERN.matcher(valueParts[1]).matches()) {
627                         logger.warn("{}, insecure input value", valueParts[1]);
628                         return;
629                     }
630                     try {
631                         extraFloats.put(valueParts[0], Float.parseFloat(valueParts[1]));
632                     } catch (NumberFormatException e) {
633                         logger.warn("Unable to parse {} as float", valueParts[1]);
634                         return;
635                     }
636                     break;
637                 case "<eu>":
638                     valueParts = argValue.split(" ");
639                     if (valueParts.length != 2) {
640                         logger.warn("argument '{}' requires a key value pair separated by space, current value '{}'",
641                                 argName, argValue);
642                         return;
643                     }
644                     if (!SECURE_SHELL_INPUT_PATTERN.matcher(valueParts[0]).matches()) {
645                         logger.warn("{}, insecure input value", valueParts[0]);
646                         return;
647                     }
648                     if (!SECURE_SHELL_INPUT_PATTERN.matcher(valueParts[1]).matches()) {
649                         logger.warn("{}, insecure input value", valueParts[1]);
650                         return;
651                     }
652                     extraUris.put(valueParts[0], URI.create(valueParts[1]));
653                     break;
654                 default:
655                     throw new AndroidDebugBridgeDeviceException("Unsupported arg " + argName
656                             + ". Open an issue or pr for it if you think support should be added.");
657             }
658         }
659
660         StringBuilder adbCommandBuilder = new StringBuilder("am start -n " + targetPackage);
661         if (action != null) {
662             adbCommandBuilder.append(" -a ").append(action);
663         }
664         if (dataUri != null) {
665             adbCommandBuilder.append(" -d ").append(dataUri);
666         }
667         if (mimeType != null) {
668             adbCommandBuilder.append(" -t ").append(mimeType);
669         }
670         if (category != null) {
671             adbCommandBuilder.append(" -c ").append(category);
672         }
673         if (component != null) {
674             adbCommandBuilder.append(" -n ").append(component);
675         }
676         if (flags != null) {
677             adbCommandBuilder.append(" -f ").append(flags);
678         }
679         if (!extraStrings.isEmpty()) {
680             adbCommandBuilder.append(extraStrings.entrySet().stream()
681                     .map(entry -> " --es \"" + entry.getKey() + "\" \"" + entry.getValue() + "\"")
682                     .collect(Collectors.joining(" ")));
683         }
684         if (!extraBooleans.isEmpty()) {
685             adbCommandBuilder.append(extraBooleans.entrySet().stream()
686                     .map(entry -> " --ez \"" + entry.getKey() + "\" " + entry.getValue())
687                     .collect(Collectors.joining(" ")));
688         }
689         if (!extraIntegers.isEmpty()) {
690             adbCommandBuilder.append(extraIntegers.entrySet().stream()
691                     .map(entry -> " --ei \"" + entry.getKey() + "\" " + entry.getValue())
692                     .collect(Collectors.joining(" ")));
693         }
694         if (!extraFloats.isEmpty()) {
695             adbCommandBuilder.append(
696                     extraFloats.entrySet().stream().map(entry -> " --ef \"" + entry.getKey() + "\" " + entry.getValue())
697                             .collect(Collectors.joining(" ")));
698         }
699         if (!extraLongs.isEmpty()) {
700             adbCommandBuilder.append(
701                     extraLongs.entrySet().stream().map(entry -> " --el \"" + entry.getKey() + "\" " + entry.getValue())
702                             .collect(Collectors.joining(" ")));
703         }
704         runAdbShell(adbCommandBuilder.toString());
705     }
706
707     public boolean isConnected() {
708         var currentSocket = socket;
709         return currentSocket != null && currentSocket.isConnected();
710     }
711
712     public void connect() throws AndroidDebugBridgeDeviceException, InterruptedException {
713         this.disconnect();
714         AdbConnection adbConnection;
715         Socket sock;
716         AdbCrypto crypto = adbCrypto;
717         if (crypto == null) {
718             throw new AndroidDebugBridgeDeviceException("Device not connected");
719         }
720         try {
721             sock = new Socket();
722             socket = sock;
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();
729             }
730             throw new AndroidDebugBridgeDeviceException("Can not open socket " + ip + ":" + port);
731         }
732         try {
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);
739         }
740     }
741
742     private String runAdbShell(String... args)
743             throws InterruptedException, AndroidDebugBridgeDeviceException, TimeoutException, ExecutionException {
744         return runAdbShell(timeoutSec, args);
745     }
746
747     private String runAdbShell(int commandTimeout, String... args)
748             throws InterruptedException, AndroidDebugBridgeDeviceException, TimeoutException, ExecutionException {
749         var adb = connection;
750         if (adb == null) {
751             throw new AndroidDebugBridgeDeviceException("Device not connected");
752         }
753         try {
754             commandLock.lock();
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)) {
760                     do {
761                         byteArrayOutputStream.writeBytes(stream.read());
762                     } while (!stream.isClosed());
763                 } catch (IOException e) {
764                     if (!"Stream closed".equals(e.getMessage())) {
765                         throw e;
766                     }
767                 }
768                 return byteArrayOutputStream.toString(StandardCharsets.US_ASCII);
769             });
770             this.commandFuture = commandFuture;
771             return commandFuture.get(commandTimeout, TimeUnit.SECONDS);
772         } finally {
773             var commandFuture = this.commandFuture;
774             if (commandFuture != null) {
775                 commandFuture.cancel(true);
776                 this.commandFuture = null;
777             }
778             commandLock.unlock();
779         }
780     }
781
782     public static void initADB() {
783         Logger logger = LoggerFactory.getLogger(AndroidDebugBridgeDevice.class);
784         try {
785             if (!Files.exists(ADB_FOLDER) || !Files.isDirectory(ADB_FOLDER)) {
786                 Files.createDirectory(ADB_FOLDER);
787                 logger.info("Binding folder {} created", ADB_FOLDER);
788             }
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());
792         }
793     }
794
795     private static AdbBase64 getBase64Impl() {
796         return bytes -> new String(Base64.getEncoder().encode(bytes), StandardCharsets.US_ASCII);
797     }
798
799     private static AdbCrypto loadKeyPair(Path pubKey, Path privKey)
800             throws NoSuchAlgorithmException, IOException, InvalidKeySpecException {
801         AdbCrypto c = null;
802         // load key pair
803         if (Files.exists(pubKey) && Files.exists(privKey)) {
804             try {
805                 c = AdbCrypto.loadAdbKeyPair(getBase64Impl(), privKey.toFile(), pubKey.toFile());
806             } catch (IOException ignored) {
807                 // Keys don't exits
808             }
809         }
810         if (c == null) {
811             // generate key pair
812             c = AdbCrypto.generateAdbKeyPair(getBase64Impl());
813             c.saveAdbKeyPair(privKey.toFile(), pubKey.toFile());
814         }
815         return c;
816     }
817
818     public void disconnect() {
819         var commandFuture = this.commandFuture;
820         if (commandFuture != null && !commandFuture.isDone()) {
821             commandFuture.cancel(true);
822         }
823         var adb = connection;
824         var sock = socket;
825         if (adb != null) {
826             try {
827                 adb.close();
828             } catch (IOException ignored) {
829             }
830             connection = null;
831         }
832         if (sock != null) {
833             try {
834                 sock.close();
835             } catch (IOException ignored) {
836             }
837             socket = null;
838         }
839     }
840
841     private boolean isAtLeastVersion(int major) {
842         return isAtLeastVersion(major, 0);
843     }
844
845     private boolean isAtLeastVersion(int major, int minor) {
846         return isAtLeastVersion(major, minor, 0);
847     }
848
849     private boolean isAtLeastVersion(int major, int minor, int patch) {
850         return majorVersionNumber > major || (majorVersionNumber == major
851                 && (minorVersionNumber > minor || (minorVersionNumber == minor && patchVersionNumber >= patch)));
852     }
853
854     public static class VolumeInfo {
855         public int current;
856         public int min;
857         public int max;
858
859         VolumeInfo(int current, int min, int max) {
860             this.current = current;
861             this.min = min;
862             this.max = max;
863         }
864     }
865 }