]> git.basschouten.com Git - openhab-addons.git/blob
2404b1c82b1342adb6c80f0104086659958dc89a
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2022 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.File;
17 import java.io.IOException;
18 import java.net.InetSocketAddress;
19 import java.net.Socket;
20 import java.net.URI;
21 import java.net.URLEncoder;
22 import java.nio.charset.Charset;
23 import java.nio.charset.StandardCharsets;
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     public static final int ANDROID_MEDIA_STREAM = 3;
60     private static final String ADB_FOLDER = OpenHAB.getUserDataFolder() + File.separator + ".adb";
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
72     private static final Pattern SECURE_SHELL_INPUT_PATTERN = Pattern.compile("^[^\\|\\&;\\\"]+$");
73
74     private static @Nullable AdbCrypto adbCrypto;
75
76     static {
77         var logger = LoggerFactory.getLogger(AndroidDebugBridgeDevice.class);
78         try {
79             File directory = new File(ADB_FOLDER);
80             if (!directory.exists()) {
81                 directory.mkdir();
82             }
83             adbCrypto = loadKeyPair(ADB_FOLDER + File.separator + "adb_pub.key",
84                     ADB_FOLDER + File.separator + "adb.key");
85         } catch (NoSuchAlgorithmException | IOException | InvalidKeySpecException e) {
86             logger.warn("Unable to setup adb keys: {}", e.getMessage());
87         }
88     }
89
90     private final ScheduledExecutorService scheduler;
91     private final ReentrantLock commandLock = new ReentrantLock();
92
93     private String ip = "127.0.0.1";
94     private int port = 5555;
95     private int timeoutSec = 5;
96     private int recordDuration;
97     private @Nullable Socket socket;
98     private @Nullable AdbConnection connection;
99     private @Nullable Future<String> commandFuture;
100
101     public AndroidDebugBridgeDevice(ScheduledExecutorService scheduler) {
102         this.scheduler = scheduler;
103     }
104
105     public void configure(String ip, int port, int timeout, int recordDuration) {
106         this.ip = ip;
107         this.port = port;
108         this.timeoutSec = timeout;
109         this.recordDuration = recordDuration;
110     }
111
112     public void sendKeyEvent(String eventCode)
113             throws InterruptedException, AndroidDebugBridgeDeviceException, TimeoutException, ExecutionException {
114         runAdbShell("input", "keyevent", eventCode);
115     }
116
117     public void sendText(String text)
118             throws AndroidDebugBridgeDeviceException, InterruptedException, TimeoutException, ExecutionException {
119         runAdbShell("input", "text", URLEncoder.encode(text, StandardCharsets.UTF_8));
120     }
121
122     public void sendTap(String point)
123             throws AndroidDebugBridgeDeviceException, InterruptedException, TimeoutException, ExecutionException {
124         var match = TAP_EVENT_PATTERN.matcher(point);
125         if (!match.matches()) {
126             throw new AndroidDebugBridgeDeviceException("Unable to parse tap event");
127         }
128         runAdbShell("input", "mouse", "tap", match.group("x"), match.group("y"));
129     }
130
131     public void openUrl(String url)
132             throws InterruptedException, AndroidDebugBridgeDeviceException, TimeoutException, ExecutionException {
133         var match = URL_PATTERN.matcher(url);
134         if (!match.matches()) {
135             throw new AndroidDebugBridgeDeviceException("Unable to parse url");
136         }
137         runAdbShell("am", "start", "-a", url);
138     }
139
140     public void startPackage(String packageName)
141             throws InterruptedException, AndroidDebugBridgeDeviceException, TimeoutException, ExecutionException {
142         if (packageName.contains("/")) {
143             startPackageWithActivity(packageName);
144             return;
145         }
146         if (!PACKAGE_NAME_PATTERN.matcher(packageName).matches()) {
147             logger.warn("{} is not a valid package name", packageName);
148             return;
149         }
150         var out = runAdbShell("monkey", "--pct-syskeys", "0", "-p", packageName, "-v", "1");
151         if (out.contains("monkey aborted")) {
152             startTVPackage(packageName);
153         }
154     }
155
156     private void startTVPackage(String packageName)
157             throws InterruptedException, AndroidDebugBridgeDeviceException, TimeoutException, ExecutionException {
158         // https://developer.android.com/training/tv/start/start
159         String result = runAdbShell("monkey", "--pct-syskeys", "0", "-c", "android.intent.category.LEANBACK_LAUNCHER",
160                 "-p", packageName, "1");
161         if (result.contains("monkey aborted")) {
162             throw new AndroidDebugBridgeDeviceException("Unable to open package");
163         }
164     }
165
166     public void startPackageWithActivity(String packageWithActivity)
167             throws InterruptedException, AndroidDebugBridgeDeviceException, TimeoutException, ExecutionException {
168         var parts = packageWithActivity.split("/");
169         if (parts.length != 2) {
170             logger.warn("{} is not a valid package", packageWithActivity);
171             return;
172         }
173         var packageName = parts[0];
174         var activityName = parts[1];
175         if (!PACKAGE_NAME_PATTERN.matcher(packageName).matches()) {
176             logger.warn("{} is not a valid package name", packageName);
177             return;
178         }
179         if (!SECURE_SHELL_INPUT_PATTERN.matcher(activityName).matches()) {
180             logger.warn("{} is not a valid activity name", activityName);
181             return;
182         }
183         var out = runAdbShell("am", "start", "-n", packageWithActivity);
184         if (out.contains("usage: am")) {
185             out = runAdbShell("am", "start", packageWithActivity);
186         }
187         if (out.contains("usage: am") || out.contains("Exception")) {
188             logger.warn("open {} fail; retrying to open without activity info", packageWithActivity);
189             startPackage(packageName);
190         }
191     }
192
193     public void stopPackage(String packageName)
194             throws AndroidDebugBridgeDeviceException, InterruptedException, TimeoutException, ExecutionException {
195         if (!PACKAGE_NAME_PATTERN.matcher(packageName).matches()) {
196             logger.warn("{} is not a valid package name", packageName);
197             return;
198         }
199         runAdbShell("am", "force-stop", packageName);
200     }
201
202     public String getCurrentPackage() throws AndroidDebugBridgeDeviceException, InterruptedException,
203             AndroidDebugBridgeDeviceReadException, TimeoutException, ExecutionException {
204         var out = runAdbShell("dumpsys", "window", "windows", "|", "grep", "mFocusedApp");
205         var targetLine = Arrays.stream(out.split("\n")).findFirst().orElse("");
206         var lineParts = targetLine.split(" ");
207         if (lineParts.length >= 2) {
208             var packageActivityName = lineParts[lineParts.length - 2];
209             if (packageActivityName.contains("/")) {
210                 return packageActivityName.split("/")[0];
211             }
212         }
213         throw new AndroidDebugBridgeDeviceReadException("Unable to read package name");
214     }
215
216     public boolean isAwake()
217             throws InterruptedException, AndroidDebugBridgeDeviceException, TimeoutException, ExecutionException {
218         String devicesResp = runAdbShell("dumpsys", "activity", "|", "grep", "mWakefulness");
219         return devicesResp.contains("mWakefulness=Awake");
220     }
221
222     public boolean isScreenOn() throws InterruptedException, AndroidDebugBridgeDeviceException,
223             AndroidDebugBridgeDeviceReadException, TimeoutException, ExecutionException {
224         String devicesResp = runAdbShell("dumpsys", "power", "|", "grep", "'Display Power'");
225         if (devicesResp.contains("=")) {
226             try {
227                 var state = devicesResp.split("=")[1].trim();
228                 return state.equals("ON");
229             } catch (NumberFormatException e) {
230                 logger.debug("Unable to parse device screen state: {}", e.getMessage());
231             }
232         }
233         throw new AndroidDebugBridgeDeviceReadException("Unable to read screen state");
234     }
235
236     public boolean isPlayingMedia(String currentApp)
237             throws AndroidDebugBridgeDeviceException, InterruptedException, TimeoutException, ExecutionException {
238         String devicesResp = runAdbShell("dumpsys", "media_session", "|", "grep", "-A", "100", "'Sessions Stack'", "|",
239                 "grep", "-A", "50", currentApp);
240         String[] mediaSessions = devicesResp.split("\n\n");
241         if (mediaSessions.length == 0) {
242             // no media session found for current app
243             return false;
244         }
245         boolean isPlaying = mediaSessions[0].contains("PlaybackState {state=3");
246         logger.debug("device media state playing {}", isPlaying);
247         return isPlaying;
248     }
249
250     public boolean isPlayingAudio()
251             throws AndroidDebugBridgeDeviceException, InterruptedException, TimeoutException, ExecutionException {
252         String audioDump = runAdbShell("dumpsys", "audio", "|", "grep", "ID:");
253         return audioDump.contains("state:started");
254     }
255
256     public VolumeInfo getMediaVolume() throws AndroidDebugBridgeDeviceException, InterruptedException,
257             AndroidDebugBridgeDeviceReadException, TimeoutException, ExecutionException {
258         return getVolume(ANDROID_MEDIA_STREAM);
259     }
260
261     public void setMediaVolume(int volume)
262             throws AndroidDebugBridgeDeviceException, InterruptedException, TimeoutException, ExecutionException {
263         setVolume(ANDROID_MEDIA_STREAM, volume);
264     }
265
266     public int getPowerWakeLock() throws InterruptedException, AndroidDebugBridgeDeviceException,
267             AndroidDebugBridgeDeviceReadException, TimeoutException, ExecutionException {
268         String lockResp = runAdbShell("dumpsys", "power", "|", "grep", "Locks", "|", "grep", "'size='");
269         if (lockResp.contains("=")) {
270             try {
271                 return Integer.parseInt(lockResp.replace("\n", "").split("=")[1].trim());
272             } catch (NumberFormatException e) {
273                 String message = String.format("Unable to parse device wake-lock '%s'", lockResp);
274                 logger.debug("{}: {}", message, e.getMessage());
275                 throw new AndroidDebugBridgeDeviceReadException(message);
276             }
277         }
278         throw new AndroidDebugBridgeDeviceReadException(String.format("Unable to read wake-lock '%s'", lockResp));
279     }
280
281     private void setVolume(int stream, int volume)
282             throws AndroidDebugBridgeDeviceException, InterruptedException, TimeoutException, ExecutionException {
283         runAdbShell("media", "volume", "--show", "--stream", String.valueOf(stream), "--set", String.valueOf(volume));
284     }
285
286     public String getModel() throws AndroidDebugBridgeDeviceException, InterruptedException,
287             AndroidDebugBridgeDeviceReadException, TimeoutException, ExecutionException {
288         return getDeviceProp("ro.product.model");
289     }
290
291     public String getAndroidVersion() throws AndroidDebugBridgeDeviceException, InterruptedException,
292             AndroidDebugBridgeDeviceReadException, TimeoutException, ExecutionException {
293         return getDeviceProp("ro.build.version.release");
294     }
295
296     public String getBrand() throws AndroidDebugBridgeDeviceException, InterruptedException,
297             AndroidDebugBridgeDeviceReadException, TimeoutException, ExecutionException {
298         return getDeviceProp("ro.product.brand");
299     }
300
301     public String getSerialNo() throws AndroidDebugBridgeDeviceException, InterruptedException,
302             AndroidDebugBridgeDeviceReadException, TimeoutException, ExecutionException {
303         return getDeviceProp("ro.serialno");
304     }
305
306     public String getMacAddress() throws AndroidDebugBridgeDeviceException, InterruptedException,
307             AndroidDebugBridgeDeviceReadException, TimeoutException, ExecutionException {
308         return runAdbShell("cat", "/sys/class/net/wlan0/address").replace("\n", "").replace("\r", "");
309     }
310
311     private String getDeviceProp(String name) throws AndroidDebugBridgeDeviceException, InterruptedException,
312             AndroidDebugBridgeDeviceReadException, TimeoutException, ExecutionException {
313         var propValue = runAdbShell("getprop", name, "&&", "sleep", "0.3").replace("\n", "").replace("\r", "");
314         if (propValue.length() == 0) {
315             throw new AndroidDebugBridgeDeviceReadException(String.format("Unable to get device property '%s'", name));
316         }
317         return propValue;
318     }
319
320     private VolumeInfo getVolume(int stream) throws AndroidDebugBridgeDeviceException, InterruptedException,
321             AndroidDebugBridgeDeviceReadException, TimeoutException, ExecutionException {
322         String volumeResp = runAdbShell("media", "volume", "--show", "--stream", String.valueOf(stream), "--get", "|",
323                 "grep", "volume");
324         Matcher matcher = VOLUME_PATTERN.matcher(volumeResp);
325         if (!matcher.find()) {
326             throw new AndroidDebugBridgeDeviceReadException("Unable to get volume info");
327         }
328         var volumeInfo = new VolumeInfo(Integer.parseInt(matcher.group("current")),
329                 Integer.parseInt(matcher.group("min")), Integer.parseInt(matcher.group("max")));
330         logger.debug("Device {}:{} VolumeInfo: current {}, min {}, max {}", this.ip, this.port, volumeInfo.current,
331                 volumeInfo.min, volumeInfo.max);
332         return volumeInfo;
333     }
334
335     public String recordInputEvents()
336             throws AndroidDebugBridgeDeviceException, InterruptedException, TimeoutException, ExecutionException {
337         String out = runAdbShell(recordDuration * 2, "getevent", "&", "sleep", Integer.toString(recordDuration), "&&",
338                 "exit");
339         var matcher = INPUT_EVENT_PATTERN.matcher(out);
340         var commandList = new ArrayList<String>();
341         try {
342             while (matcher.find()) {
343                 String inputPath = matcher.group("input");
344                 int n1 = Integer.parseInt(matcher.group("n1"), 16);
345                 int n2 = Integer.parseInt(matcher.group("n2"), 16);
346                 int n3 = Integer.parseInt(matcher.group("n3"), 16);
347                 commandList.add(String.format("sendevent /%s %d %d %d", inputPath, n1, n2, n3));
348             }
349         } catch (NumberFormatException e) {
350             logger.warn("NumberFormatException while parsing events, aborting");
351             return "";
352         }
353         return String.join(" && ", commandList);
354     }
355
356     public void sendInputEvents(String command)
357             throws AndroidDebugBridgeDeviceException, InterruptedException, TimeoutException, ExecutionException {
358         String out = runAdbShell(command.split(" "));
359         if (out.length() != 0) {
360             logger.warn("Device event unexpected output: {}", out);
361             throw new AndroidDebugBridgeDeviceException("Device event execution fail");
362         }
363     }
364
365     public void rebootDevice()
366             throws AndroidDebugBridgeDeviceException, InterruptedException, TimeoutException, ExecutionException {
367         try {
368             runAdbShell("reboot", "&", "sleep", "0.1", "&&", "exit");
369         } finally {
370             disconnect();
371         }
372     }
373
374     public void powerOffDevice()
375             throws AndroidDebugBridgeDeviceException, InterruptedException, TimeoutException, ExecutionException {
376         try {
377             runAdbShell("reboot", "-p", "&", "sleep", "0.1", "&&", "exit");
378         } finally {
379             disconnect();
380         }
381     }
382
383     public void startIntent(String command)
384             throws AndroidDebugBridgeDeviceException, ExecutionException, InterruptedException, TimeoutException {
385         String[] commandParts = command.split("\\|\\|");
386         if (commandParts.length == 0) {
387             throw new AndroidDebugBridgeDeviceException("Empty command");
388         }
389         String targetPackage = commandParts[0];
390         var targetPackageParts = targetPackage.split("/");
391         if (targetPackageParts.length > 2) {
392             throw new AndroidDebugBridgeDeviceException("Invalid target package " + targetPackage);
393         }
394         if (!PACKAGE_NAME_PATTERN.matcher(targetPackageParts[0]).matches()) {
395             logger.warn("{} is not a valid package name", targetPackageParts[0]);
396             return;
397         }
398         if (targetPackageParts.length == 2 && !SECURE_SHELL_INPUT_PATTERN.matcher(targetPackageParts[1]).matches()) {
399             logger.warn("{} is not a valid activity name", targetPackageParts[1]);
400             return;
401         }
402         @Nullable
403         String action = null;
404         @Nullable
405         String dataUri = null;
406         @Nullable
407         String mimeType = null;
408         @Nullable
409         String category = null;
410         @Nullable
411         String component = null;
412         @Nullable
413         String flags = null;
414         Map<String, Boolean> extraBooleans = new HashMap<>();
415         Map<String, String> extraStrings = new HashMap<>();
416         Map<String, Integer> extraIntegers = new HashMap<>();
417         Map<String, Float> extraFloats = new HashMap<>();
418         Map<String, Long> extraLongs = new HashMap<>();
419         Map<String, URI> extraUris = new HashMap<>();
420         for (var i = 1; i < commandParts.length; i++) {
421             var commandPart = commandParts[i];
422             var endToken = commandPart.indexOf(">");
423             var argName = commandPart.substring(0, endToken + 1);
424             var argValue = commandPart.substring(endToken + 1);
425
426             String[] valueParts;
427             switch (argName) {
428                 case "<a>":
429                 case "<action>":
430                     if (!PACKAGE_NAME_PATTERN.matcher(argValue).matches()) {
431                         logger.warn("{} is not a valid action name", argValue);
432                         return;
433                     }
434                     action = argValue;
435                     break;
436                 case "<d>":
437                 case "":
438                     if (!SECURE_SHELL_INPUT_PATTERN.matcher(argValue).matches()) {
439                         logger.warn("{}, insecure input value", argValue);
440                         return;
441                     }
442                     dataUri = argValue;
443                     break;
444                 case "<t>":
445                 case "<mime_type>":
446                     if (!SECURE_SHELL_INPUT_PATTERN.matcher(argValue).matches()) {
447                         logger.warn("{}, insecure input value", argValue);
448                         return;
449                     }
450                     mimeType = argValue;
451                     break;
452                 case "<c>":
453                 case "<category>":
454                     if (!SECURE_SHELL_INPUT_PATTERN.matcher(argValue).matches()) {
455                         logger.warn("{}, insecure input value", argValue);
456                         return;
457                     }
458                     category = argValue;
459                     break;
460                 case "<n>":
461                 case "<component>":
462                     if (!SECURE_SHELL_INPUT_PATTERN.matcher(argValue).matches()) {
463                         logger.warn("{}, insecure input value", argValue);
464                         return;
465                     }
466                     component = argValue;
467                     break;
468                 case "<f>":
469                 case "<flags>":
470                     if (!SECURE_SHELL_INPUT_PATTERN.matcher(argValue).matches()) {
471                         logger.warn("{}, insecure input value", argValue);
472                         return;
473                     }
474                     flags = argValue;
475                     break;
476                 case "<e>":
477                 case "<es>":
478                     valueParts = argValue.split(" ");
479                     if (valueParts.length != 2) {
480                         logger.warn("argument '{}' requires a key value pair separated by space, current value '{}'",
481                                 argName, argValue);
482                         return;
483                     }
484                     if (!SECURE_SHELL_INPUT_PATTERN.matcher(valueParts[0]).matches()) {
485                         logger.warn("{}, insecure input value", valueParts[0]);
486                         return;
487                     }
488                     if (!SECURE_SHELL_INPUT_PATTERN.matcher(valueParts[1]).matches()) {
489                         logger.warn("{}, insecure input value", valueParts[1]);
490                         return;
491                     }
492                     extraStrings.put(valueParts[0], valueParts[1]);
493                     break;
494                 case "<ez>":
495                     valueParts = argValue.split(" ");
496                     if (valueParts.length != 2) {
497                         logger.warn("argument '{}' requires a key value pair separated by space, current value '{}'",
498                                 argName, argValue);
499                         return;
500                     }
501                     if (!SECURE_SHELL_INPUT_PATTERN.matcher(valueParts[0]).matches()) {
502                         logger.warn("{}, insecure input value", valueParts[0]);
503                         return;
504                     }
505                     if (!SECURE_SHELL_INPUT_PATTERN.matcher(valueParts[1]).matches()) {
506                         logger.warn("{}, insecure input value", valueParts[1]);
507                         return;
508                     }
509                     extraBooleans.put(valueParts[0], Boolean.parseBoolean(valueParts[1]));
510                     break;
511                 case "<ei>":
512                     valueParts = argValue.split(" ");
513                     if (valueParts.length != 2) {
514                         logger.warn("argument '{}' requires a key value pair separated by space, current value '{}'",
515                                 argName, argValue);
516                         return;
517                     }
518                     if (!SECURE_SHELL_INPUT_PATTERN.matcher(valueParts[0]).matches()) {
519                         logger.warn("{}, insecure input value", valueParts[0]);
520                         return;
521                     }
522                     if (!SECURE_SHELL_INPUT_PATTERN.matcher(valueParts[1]).matches()) {
523                         logger.warn("{}, insecure input value", valueParts[1]);
524                         return;
525                     }
526                     try {
527                         extraIntegers.put(valueParts[0], Integer.parseInt(valueParts[1]));
528                     } catch (NumberFormatException e) {
529                         logger.warn("Unable to parse {} as integer", valueParts[1]);
530                         return;
531                     }
532                     break;
533                 case "<el>":
534                     valueParts = argValue.split(" ");
535                     if (valueParts.length != 2) {
536                         logger.warn("argument '{}' requires a key value pair separated by space, current value '{}'",
537                                 argName, argValue);
538                         return;
539                     }
540                     if (!SECURE_SHELL_INPUT_PATTERN.matcher(valueParts[0]).matches()) {
541                         logger.warn("{}, insecure input value", valueParts[0]);
542                         return;
543                     }
544                     if (!SECURE_SHELL_INPUT_PATTERN.matcher(valueParts[1]).matches()) {
545                         logger.warn("{}, insecure input value", valueParts[1]);
546                         return;
547                     }
548                     try {
549                         extraLongs.put(valueParts[0], Long.parseLong(valueParts[1]));
550                     } catch (NumberFormatException e) {
551                         logger.warn("Unable to parse {} as long", valueParts[1]);
552                         return;
553                     }
554                     break;
555                 case "<ef>":
556                     valueParts = argValue.split(" ");
557                     if (valueParts.length != 2) {
558                         logger.warn("argument '{}' requires a key value pair separated by space, current value '{}'",
559                                 argName, argValue);
560                         return;
561                     }
562                     if (!SECURE_SHELL_INPUT_PATTERN.matcher(valueParts[0]).matches()) {
563                         logger.warn("{}, insecure input value", valueParts[0]);
564                         return;
565                     }
566                     if (!SECURE_SHELL_INPUT_PATTERN.matcher(valueParts[1]).matches()) {
567                         logger.warn("{}, insecure input value", valueParts[1]);
568                         return;
569                     }
570                     try {
571                         extraFloats.put(valueParts[0], Float.parseFloat(valueParts[1]));
572                     } catch (NumberFormatException e) {
573                         logger.warn("Unable to parse {} as float", valueParts[1]);
574                         return;
575                     }
576                     break;
577                 case "<eu>":
578                     valueParts = argValue.split(" ");
579                     if (valueParts.length != 2) {
580                         logger.warn("argument '{}' requires a key value pair separated by space, current value '{}'",
581                                 argName, argValue);
582                         return;
583                     }
584                     if (!SECURE_SHELL_INPUT_PATTERN.matcher(valueParts[0]).matches()) {
585                         logger.warn("{}, insecure input value", valueParts[0]);
586                         return;
587                     }
588                     if (!SECURE_SHELL_INPUT_PATTERN.matcher(valueParts[1]).matches()) {
589                         logger.warn("{}, insecure input value", valueParts[1]);
590                         return;
591                     }
592                     extraUris.put(valueParts[0], URI.create(valueParts[1]));
593                     break;
594                 default:
595                     throw new AndroidDebugBridgeDeviceException("Unsupported arg " + argName
596                             + ". Open an issue or pr for it if you think support should be added.");
597             }
598         }
599
600         StringBuilder adbCommandBuilder = new StringBuilder("am start -n " + targetPackage);
601         if (action != null) {
602             adbCommandBuilder.append(" -a ").append(action);
603         }
604         if (dataUri != null) {
605             adbCommandBuilder.append(" -d ").append(dataUri);
606         }
607         if (mimeType != null) {
608             adbCommandBuilder.append(" -t ").append(mimeType);
609         }
610         if (category != null) {
611             adbCommandBuilder.append(" -c ").append(category);
612         }
613         if (component != null) {
614             adbCommandBuilder.append(" -n ").append(component);
615         }
616         if (flags != null) {
617             adbCommandBuilder.append(" -f ").append(flags);
618         }
619         if (!extraStrings.isEmpty()) {
620             adbCommandBuilder.append(extraStrings.entrySet().stream()
621                     .map(entry -> " --es \"" + entry.getKey() + "\" \"" + entry.getValue() + "\"")
622                     .collect(Collectors.joining(" ")));
623         }
624         if (!extraBooleans.isEmpty()) {
625             adbCommandBuilder.append(extraBooleans.entrySet().stream()
626                     .map(entry -> " --ez \"" + entry.getKey() + "\" " + entry.getValue())
627                     .collect(Collectors.joining(" ")));
628         }
629         if (!extraIntegers.isEmpty()) {
630             adbCommandBuilder.append(extraIntegers.entrySet().stream()
631                     .map(entry -> " --ei \"" + entry.getKey() + "\" " + entry.getValue())
632                     .collect(Collectors.joining(" ")));
633         }
634         if (!extraFloats.isEmpty()) {
635             adbCommandBuilder.append(
636                     extraFloats.entrySet().stream().map(entry -> " --ef \"" + entry.getKey() + "\" " + entry.getValue())
637                             .collect(Collectors.joining(" ")));
638         }
639         if (!extraLongs.isEmpty()) {
640             adbCommandBuilder.append(
641                     extraLongs.entrySet().stream().map(entry -> " --el \"" + entry.getKey() + "\" " + entry.getValue())
642                             .collect(Collectors.joining(" ")));
643         }
644         runAdbShell(adbCommandBuilder.toString());
645     }
646
647     public boolean isConnected() {
648         var currentSocket = socket;
649         return currentSocket != null && currentSocket.isConnected();
650     }
651
652     public void connect() throws AndroidDebugBridgeDeviceException, InterruptedException {
653         this.disconnect();
654         AdbConnection adbConnection;
655         Socket sock;
656         AdbCrypto crypto = adbCrypto;
657         if (crypto == null) {
658             throw new AndroidDebugBridgeDeviceException("Device not connected");
659         }
660         try {
661             sock = new Socket();
662             socket = sock;
663             sock.connect(new InetSocketAddress(ip, port), (int) TimeUnit.SECONDS.toMillis(15));
664         } catch (IOException e) {
665             logger.debug("Error connecting to {}: [{}] {}", ip, e.getClass().getName(), e.getMessage());
666             if ("Socket closed".equals(e.getMessage())) {
667                 // Connection aborted by us
668                 throw new InterruptedException();
669             }
670             throw new AndroidDebugBridgeDeviceException("Can not open socket " + ip + ":" + port);
671         }
672         try {
673             adbConnection = AdbConnection.create(sock, crypto);
674             connection = adbConnection;
675             adbConnection.connect(15, TimeUnit.SECONDS, false);
676         } catch (IOException e) {
677             logger.debug("Error connecting to {}: {}", ip, e.getMessage());
678             throw new AndroidDebugBridgeDeviceException("Can not open adb connection " + ip + ":" + port);
679         }
680     }
681
682     private String runAdbShell(String... args)
683             throws InterruptedException, AndroidDebugBridgeDeviceException, TimeoutException, ExecutionException {
684         return runAdbShell(timeoutSec, args);
685     }
686
687     private String runAdbShell(int commandTimeout, String... args)
688             throws InterruptedException, AndroidDebugBridgeDeviceException, TimeoutException, ExecutionException {
689         var adb = connection;
690         if (adb == null) {
691             throw new AndroidDebugBridgeDeviceException("Device not connected");
692         }
693         try {
694             commandLock.lock();
695             var commandFuture = scheduler.submit(() -> {
696                 var byteArrayOutputStream = new ByteArrayOutputStream();
697                 String cmd = String.join(" ", args);
698                 logger.debug("{} - shell:{}", ip, cmd);
699                 try (AdbStream stream = adb.open("shell:" + cmd)) {
700                     do {
701                         byteArrayOutputStream.writeBytes(stream.read());
702                     } while (!stream.isClosed());
703                 } catch (IOException e) {
704                     if (!"Stream closed".equals(e.getMessage())) {
705                         throw e;
706                     }
707                 }
708                 return byteArrayOutputStream.toString(StandardCharsets.US_ASCII);
709             });
710             this.commandFuture = commandFuture;
711             return commandFuture.get(commandTimeout, TimeUnit.SECONDS);
712         } finally {
713             var commandFuture = this.commandFuture;
714             if (commandFuture != null) {
715                 commandFuture.cancel(true);
716                 this.commandFuture = null;
717             }
718             commandLock.unlock();
719         }
720     }
721
722     private static AdbBase64 getBase64Impl() {
723         Charset asciiCharset = Charset.forName("ASCII");
724         return bytes -> new String(Base64.getEncoder().encode(bytes), asciiCharset);
725     }
726
727     private static AdbCrypto loadKeyPair(String pubKeyFile, String privKeyFile)
728             throws NoSuchAlgorithmException, IOException, InvalidKeySpecException {
729         File pub = new File(pubKeyFile);
730         File priv = new File(privKeyFile);
731         AdbCrypto c = null;
732         // load key pair
733         if (pub.exists() && priv.exists()) {
734             try {
735                 c = AdbCrypto.loadAdbKeyPair(getBase64Impl(), priv, pub);
736             } catch (IOException ignored) {
737                 // Keys don't exits
738             }
739         }
740         if (c == null) {
741             // generate key pair
742             c = AdbCrypto.generateAdbKeyPair(getBase64Impl());
743             c.saveAdbKeyPair(priv, pub);
744         }
745         return c;
746     }
747
748     public void disconnect() {
749         var commandFuture = this.commandFuture;
750         if (commandFuture != null && !commandFuture.isDone()) {
751             commandFuture.cancel(true);
752         }
753         var adb = connection;
754         var sock = socket;
755         if (adb != null) {
756             try {
757                 adb.close();
758             } catch (IOException ignored) {
759             }
760             connection = null;
761         }
762         if (sock != null) {
763             try {
764                 sock.close();
765             } catch (IOException ignored) {
766             }
767             socket = null;
768         }
769     }
770
771     public static class VolumeInfo {
772         public int current;
773         public int min;
774         public int max;
775
776         VolumeInfo(int current, int min, int max) {
777             this.current = current;
778             this.min = min;
779             this.max = max;
780         }
781     }
782 }