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