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