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