]> git.basschouten.com Git - openhab-addons.git/blob
0fbba7202cbd39be183044b8ec4b66ed0c600897
[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.URLEncoder;
21 import java.nio.charset.Charset;
22 import java.nio.charset.StandardCharsets;
23 import java.security.NoSuchAlgorithmException;
24 import java.security.spec.InvalidKeySpecException;
25 import java.util.ArrayList;
26 import java.util.Arrays;
27 import java.util.Base64;
28 import java.util.concurrent.ExecutionException;
29 import java.util.concurrent.Future;
30 import java.util.concurrent.ScheduledExecutorService;
31 import java.util.concurrent.TimeUnit;
32 import java.util.concurrent.TimeoutException;
33 import java.util.concurrent.locks.ReentrantLock;
34 import java.util.regex.Matcher;
35 import java.util.regex.Pattern;
36
37 import org.eclipse.jdt.annotation.NonNullByDefault;
38 import org.eclipse.jdt.annotation.Nullable;
39 import org.openhab.core.OpenHAB;
40 import org.slf4j.Logger;
41 import org.slf4j.LoggerFactory;
42
43 import com.tananaev.adblib.AdbBase64;
44 import com.tananaev.adblib.AdbConnection;
45 import com.tananaev.adblib.AdbCrypto;
46 import com.tananaev.adblib.AdbStream;
47
48 /**
49  * The {@link AndroidDebugBridgeConfiguration} class encapsulates adb device connection logic.
50  *
51  * @author Miguel Álvarez - Initial contribution
52  */
53 @NonNullByDefault
54 public class AndroidDebugBridgeDevice {
55     public static final int ANDROID_MEDIA_STREAM = 3;
56     private static final String ADB_FOLDER = OpenHAB.getUserDataFolder() + File.separator + ".adb";
57     private final Logger logger = LoggerFactory.getLogger(AndroidDebugBridgeDevice.class);
58     private static final Pattern VOLUME_PATTERN = Pattern
59             .compile("volume is (?<current>\\d.*) in range \\[(?<min>\\d.*)\\.\\.(?<max>\\d.*)]");
60     private static final Pattern TAP_EVENT_PATTERN = Pattern.compile("(?<x>\\d+),(?<y>\\d+)");
61     private static final Pattern PACKAGE_NAME_PATTERN = Pattern
62             .compile("^([A-Za-z]{1}[A-Za-z\\d_]*\\.)+[A-Za-z][A-Za-z\\d_]*$");
63     private static final Pattern URL_PATTERN = Pattern.compile(
64             "https?:\\/\\/(www\\.)?[-a-zA-Z0-9@:%._\\+~#=]{2,256}\\.[a-z]{2,4}\\b([-a-zA-Z0-9@:%_\\+.~#?&//=]*)$");
65     private static final Pattern INPUT_EVENT_PATTERN = Pattern
66             .compile("/(?<input>\\S+): (?<n1>\\S+) (?<n2>\\S+) (?<n3>\\S+)$", Pattern.MULTILINE);
67
68     private static @Nullable AdbCrypto adbCrypto;
69
70     static {
71         var logger = LoggerFactory.getLogger(AndroidDebugBridgeDevice.class);
72         try {
73             File directory = new File(ADB_FOLDER);
74             if (!directory.exists()) {
75                 directory.mkdir();
76             }
77             adbCrypto = loadKeyPair(ADB_FOLDER + File.separator + "adb_pub.key",
78                     ADB_FOLDER + File.separator + "adb.key");
79         } catch (NoSuchAlgorithmException | IOException | InvalidKeySpecException e) {
80             logger.warn("Unable to setup adb keys: {}", e.getMessage());
81         }
82     }
83
84     private final ScheduledExecutorService scheduler;
85     private final ReentrantLock commandLock = new ReentrantLock();
86
87     private String ip = "127.0.0.1";
88     private int port = 5555;
89     private int timeoutSec = 5;
90     private int recordDuration;
91     private @Nullable Socket socket;
92     private @Nullable AdbConnection connection;
93     private @Nullable Future<String> commandFuture;
94
95     public AndroidDebugBridgeDevice(ScheduledExecutorService scheduler) {
96         this.scheduler = scheduler;
97     }
98
99     public void configure(String ip, int port, int timeout, int recordDuration) {
100         this.ip = ip;
101         this.port = port;
102         this.timeoutSec = timeout;
103         this.recordDuration = recordDuration;
104     }
105
106     public void sendKeyEvent(String eventCode)
107             throws InterruptedException, AndroidDebugBridgeDeviceException, TimeoutException, ExecutionException {
108         runAdbShell("input", "keyevent", eventCode);
109     }
110
111     public void sendText(String text)
112             throws AndroidDebugBridgeDeviceException, InterruptedException, TimeoutException, ExecutionException {
113         runAdbShell("input", "text", URLEncoder.encode(text, StandardCharsets.UTF_8));
114     }
115
116     public void sendTap(String point)
117             throws AndroidDebugBridgeDeviceException, InterruptedException, TimeoutException, ExecutionException {
118         var match = TAP_EVENT_PATTERN.matcher(point);
119         if (!match.matches()) {
120             throw new AndroidDebugBridgeDeviceException("Unable to parse tap event");
121         }
122         runAdbShell("input", "mouse", "tap", match.group("x"), match.group("y"));
123     }
124
125     public void openUrl(String url)
126             throws InterruptedException, AndroidDebugBridgeDeviceException, TimeoutException, ExecutionException {
127         var match = URL_PATTERN.matcher(url);
128         if (!match.matches()) {
129             throw new AndroidDebugBridgeDeviceException("Unable to parse url");
130         }
131         runAdbShell("am", "start", "-a", url);
132     }
133
134     public void startPackage(String packageName)
135             throws InterruptedException, AndroidDebugBridgeDeviceException, TimeoutException, ExecutionException {
136         if (packageName.contains("/")) {
137             startPackageWithActivity(packageName);
138             return;
139         }
140         if (!PACKAGE_NAME_PATTERN.matcher(packageName).matches()) {
141             logger.warn("{} is not a valid package name", packageName);
142             return;
143         }
144         var out = runAdbShell("monkey", "--pct-syskeys", "0", "-p", packageName, "-v", "1");
145         if (out.contains("monkey aborted")) {
146             startTVPackage(packageName);
147         }
148     }
149
150     private void startTVPackage(String packageName)
151             throws InterruptedException, AndroidDebugBridgeDeviceException, TimeoutException, ExecutionException {
152         // https://developer.android.com/training/tv/start/start
153         String result = runAdbShell("monkey", "--pct-syskeys", "0", "-c", "android.intent.category.LEANBACK_LAUNCHER",
154                 "-p", packageName, "1");
155         if (result.contains("monkey aborted")) {
156             throw new AndroidDebugBridgeDeviceException("Unable to open package");
157         }
158     }
159
160     public void startPackageWithActivity(String packageWithActivity)
161             throws InterruptedException, AndroidDebugBridgeDeviceException, TimeoutException, ExecutionException {
162         var parts = packageWithActivity.split("/");
163         if (parts.length != 2) {
164             logger.warn("{} is not a valid package", packageWithActivity);
165             return;
166         }
167         var packageName = parts[0];
168         var activityName = parts[1];
169         if (!PACKAGE_NAME_PATTERN.matcher(packageName).matches()) {
170             logger.warn("{} is not a valid package name", packageName);
171             return;
172         }
173         if (!PACKAGE_NAME_PATTERN.matcher(activityName).matches()) {
174             logger.warn("{} is not a valid activity name", activityName);
175             return;
176         }
177         var out = runAdbShell("am", "start", "-n", packageWithActivity);
178         if (out.contains("usage: am")) {
179             out = runAdbShell("am", "start", packageWithActivity);
180         }
181         if (out.contains("usage: am") || out.contains("Exception")) {
182             logger.warn("open {} fail; retrying to open without activity info", packageWithActivity);
183             startPackage(packageName);
184         }
185     }
186
187     public void stopPackage(String packageName)
188             throws AndroidDebugBridgeDeviceException, InterruptedException, TimeoutException, ExecutionException {
189         if (!PACKAGE_NAME_PATTERN.matcher(packageName).matches()) {
190             logger.warn("{} is not a valid package name", packageName);
191             return;
192         }
193         runAdbShell("am", "force-stop", packageName);
194     }
195
196     public String getCurrentPackage() throws AndroidDebugBridgeDeviceException, InterruptedException,
197             AndroidDebugBridgeDeviceReadException, TimeoutException, ExecutionException {
198         var out = runAdbShell("dumpsys", "window", "windows", "|", "grep", "mFocusedApp");
199         var targetLine = Arrays.stream(out.split("\n")).findFirst().orElse("");
200         var lineParts = targetLine.split(" ");
201         if (lineParts.length >= 2) {
202             var packageActivityName = lineParts[lineParts.length - 2];
203             if (packageActivityName.contains("/")) {
204                 return packageActivityName.split("/")[0];
205             }
206         }
207         throw new AndroidDebugBridgeDeviceReadException("Unable to read package name");
208     }
209
210     public boolean isAwake()
211             throws InterruptedException, AndroidDebugBridgeDeviceException, TimeoutException, ExecutionException {
212         String devicesResp = runAdbShell("dumpsys", "activity", "|", "grep", "mWakefulness");
213         return devicesResp.contains("mWakefulness=Awake");
214     }
215
216     public boolean isScreenOn() throws InterruptedException, AndroidDebugBridgeDeviceException,
217             AndroidDebugBridgeDeviceReadException, TimeoutException, ExecutionException {
218         String devicesResp = runAdbShell("dumpsys", "power", "|", "grep", "'Display Power'");
219         if (devicesResp.contains("=")) {
220             try {
221                 var state = devicesResp.split("=")[1].trim();
222                 return state.equals("ON");
223             } catch (NumberFormatException e) {
224                 logger.debug("Unable to parse device screen state: {}", e.getMessage());
225             }
226         }
227         throw new AndroidDebugBridgeDeviceReadException("Unable to read screen state");
228     }
229
230     public boolean isPlayingMedia(String currentApp)
231             throws AndroidDebugBridgeDeviceException, InterruptedException, TimeoutException, ExecutionException {
232         String devicesResp = runAdbShell("dumpsys", "media_session", "|", "grep", "-A", "100", "'Sessions Stack'", "|",
233                 "grep", "-A", "50", currentApp);
234         String[] mediaSessions = devicesResp.split("\n\n");
235         if (mediaSessions.length == 0) {
236             // no media session found for current app
237             return false;
238         }
239         boolean isPlaying = mediaSessions[0].contains("PlaybackState {state=3");
240         logger.debug("device media state playing {}", isPlaying);
241         return isPlaying;
242     }
243
244     public boolean isPlayingAudio()
245             throws AndroidDebugBridgeDeviceException, InterruptedException, TimeoutException, ExecutionException {
246         String audioDump = runAdbShell("dumpsys", "audio", "|", "grep", "ID:");
247         return audioDump.contains("state:started");
248     }
249
250     public VolumeInfo getMediaVolume() throws AndroidDebugBridgeDeviceException, InterruptedException,
251             AndroidDebugBridgeDeviceReadException, TimeoutException, ExecutionException {
252         return getVolume(ANDROID_MEDIA_STREAM);
253     }
254
255     public void setMediaVolume(int volume)
256             throws AndroidDebugBridgeDeviceException, InterruptedException, TimeoutException, ExecutionException {
257         setVolume(ANDROID_MEDIA_STREAM, volume);
258     }
259
260     public int getPowerWakeLock() throws InterruptedException, AndroidDebugBridgeDeviceException,
261             AndroidDebugBridgeDeviceReadException, TimeoutException, ExecutionException {
262         String lockResp = runAdbShell("dumpsys", "power", "|", "grep", "Locks", "|", "grep", "'size='");
263         if (lockResp.contains("=")) {
264             try {
265                 return Integer.parseInt(lockResp.replace("\n", "").split("=")[1].trim());
266             } catch (NumberFormatException e) {
267                 String message = String.format("Unable to parse device wake-lock '%s'", lockResp);
268                 logger.debug("{}: {}", message, e.getMessage());
269                 throw new AndroidDebugBridgeDeviceReadException(message);
270             }
271         }
272         throw new AndroidDebugBridgeDeviceReadException(String.format("Unable to read wake-lock '%s'", lockResp));
273     }
274
275     private void setVolume(int stream, int volume)
276             throws AndroidDebugBridgeDeviceException, InterruptedException, TimeoutException, ExecutionException {
277         runAdbShell("media", "volume", "--show", "--stream", String.valueOf(stream), "--set", String.valueOf(volume));
278     }
279
280     public String getModel() throws AndroidDebugBridgeDeviceException, InterruptedException,
281             AndroidDebugBridgeDeviceReadException, TimeoutException, ExecutionException {
282         return getDeviceProp("ro.product.model");
283     }
284
285     public String getAndroidVersion() throws AndroidDebugBridgeDeviceException, InterruptedException,
286             AndroidDebugBridgeDeviceReadException, TimeoutException, ExecutionException {
287         return getDeviceProp("ro.build.version.release");
288     }
289
290     public String getBrand() throws AndroidDebugBridgeDeviceException, InterruptedException,
291             AndroidDebugBridgeDeviceReadException, TimeoutException, ExecutionException {
292         return getDeviceProp("ro.product.brand");
293     }
294
295     public String getSerialNo() throws AndroidDebugBridgeDeviceException, InterruptedException,
296             AndroidDebugBridgeDeviceReadException, TimeoutException, ExecutionException {
297         return getDeviceProp("ro.serialno");
298     }
299
300     public String getMacAddress() throws AndroidDebugBridgeDeviceException, InterruptedException,
301             AndroidDebugBridgeDeviceReadException, TimeoutException, ExecutionException {
302         return getDeviceProp("ro.boot.wifimacaddr").toLowerCase();
303     }
304
305     private String getDeviceProp(String name) throws AndroidDebugBridgeDeviceException, InterruptedException,
306             AndroidDebugBridgeDeviceReadException, TimeoutException, ExecutionException {
307         var propValue = runAdbShell("getprop", name, "&&", "sleep", "0.3").replace("\n", "").replace("\r", "");
308         if (propValue.length() == 0) {
309             throw new AndroidDebugBridgeDeviceReadException(String.format("Unable to get device property '%s'", name));
310         }
311         return propValue;
312     }
313
314     private VolumeInfo getVolume(int stream) throws AndroidDebugBridgeDeviceException, InterruptedException,
315             AndroidDebugBridgeDeviceReadException, TimeoutException, ExecutionException {
316         String volumeResp = runAdbShell("media", "volume", "--show", "--stream", String.valueOf(stream), "--get", "|",
317                 "grep", "volume");
318         Matcher matcher = VOLUME_PATTERN.matcher(volumeResp);
319         if (!matcher.find()) {
320             throw new AndroidDebugBridgeDeviceReadException("Unable to get volume info");
321         }
322         var volumeInfo = new VolumeInfo(Integer.parseInt(matcher.group("current")),
323                 Integer.parseInt(matcher.group("min")), Integer.parseInt(matcher.group("max")));
324         logger.debug("Device {}:{} VolumeInfo: current {}, min {}, max {}", this.ip, this.port, volumeInfo.current,
325                 volumeInfo.min, volumeInfo.max);
326         return volumeInfo;
327     }
328
329     public String recordInputEvents()
330             throws AndroidDebugBridgeDeviceException, InterruptedException, TimeoutException, ExecutionException {
331         String out = runAdbShell(recordDuration * 2, "getevent", "&", "sleep", Integer.toString(recordDuration), "&&",
332                 "exit");
333         var matcher = INPUT_EVENT_PATTERN.matcher(out);
334         var commandList = new ArrayList<String>();
335         try {
336             while (matcher.find()) {
337                 String inputPath = matcher.group("input");
338                 int n1 = Integer.parseInt(matcher.group("n1"), 16);
339                 int n2 = Integer.parseInt(matcher.group("n2"), 16);
340                 int n3 = Integer.parseInt(matcher.group("n3"), 16);
341                 commandList.add(String.format("sendevent /%s %d %d %d", inputPath, n1, n2, n3));
342             }
343         } catch (NumberFormatException e) {
344             logger.warn("NumberFormatException while parsing events, aborting");
345             return "";
346         }
347         return String.join(" && ", commandList);
348     }
349
350     public void sendInputEvents(String command)
351             throws AndroidDebugBridgeDeviceException, InterruptedException, TimeoutException, ExecutionException {
352         String out = runAdbShell(command.split(" "));
353         if (out.length() != 0) {
354             logger.warn("Device event unexpected output: {}", out);
355             throw new AndroidDebugBridgeDeviceException("Device event execution fail");
356         }
357     }
358
359     public void rebootDevice()
360             throws AndroidDebugBridgeDeviceException, InterruptedException, TimeoutException, ExecutionException {
361         try {
362             runAdbShell("reboot", "&", "sleep", "0.1", "&&", "exit");
363         } finally {
364             disconnect();
365         }
366     }
367
368     public void powerOffDevice()
369             throws AndroidDebugBridgeDeviceException, InterruptedException, TimeoutException, ExecutionException {
370         try {
371             runAdbShell("reboot", "-p", "&", "sleep", "0.1", "&&", "exit");
372         } finally {
373             disconnect();
374         }
375     }
376
377     public boolean isConnected() {
378         var currentSocket = socket;
379         return currentSocket != null && currentSocket.isConnected();
380     }
381
382     public void connect() throws AndroidDebugBridgeDeviceException, InterruptedException {
383         this.disconnect();
384         AdbConnection adbConnection;
385         Socket sock;
386         AdbCrypto crypto = adbCrypto;
387         if (crypto == null) {
388             throw new AndroidDebugBridgeDeviceException("Device not connected");
389         }
390         try {
391             sock = new Socket();
392             socket = sock;
393             sock.connect(new InetSocketAddress(ip, port), (int) TimeUnit.SECONDS.toMillis(15));
394         } catch (IOException e) {
395             logger.debug("Error connecting to {}: [{}] {}", ip, e.getClass().getName(), e.getMessage());
396             if ("Socket closed".equals(e.getMessage())) {
397                 // Connection aborted by us
398                 throw new InterruptedException();
399             }
400             throw new AndroidDebugBridgeDeviceException("Can not open socket " + ip + ":" + port);
401         }
402         try {
403             adbConnection = AdbConnection.create(sock, crypto);
404             connection = adbConnection;
405             adbConnection.connect(15, TimeUnit.SECONDS, false);
406         } catch (IOException e) {
407             logger.debug("Error connecting to {}: {}", ip, e.getMessage());
408             throw new AndroidDebugBridgeDeviceException("Can not open adb connection " + ip + ":" + port);
409         }
410     }
411
412     private String runAdbShell(String... args)
413             throws InterruptedException, AndroidDebugBridgeDeviceException, TimeoutException, ExecutionException {
414         return runAdbShell(timeoutSec, args);
415     }
416
417     private String runAdbShell(int commandTimeout, String... args)
418             throws InterruptedException, AndroidDebugBridgeDeviceException, TimeoutException, ExecutionException {
419         var adb = connection;
420         if (adb == null) {
421             throw new AndroidDebugBridgeDeviceException("Device not connected");
422         }
423         try {
424             commandLock.lock();
425             var commandFuture = scheduler.submit(() -> {
426                 var byteArrayOutputStream = new ByteArrayOutputStream();
427                 String cmd = String.join(" ", args);
428                 logger.debug("{} - shell:{}", ip, cmd);
429                 try (AdbStream stream = adb.open("shell:" + cmd)) {
430                     do {
431                         byteArrayOutputStream.writeBytes(stream.read());
432                     } while (!stream.isClosed());
433                 } catch (IOException e) {
434                     if (!"Stream closed".equals(e.getMessage())) {
435                         throw e;
436                     }
437                 }
438                 return byteArrayOutputStream.toString(StandardCharsets.US_ASCII);
439             });
440             this.commandFuture = commandFuture;
441             return commandFuture.get(commandTimeout, TimeUnit.SECONDS);
442         } finally {
443             var commandFuture = this.commandFuture;
444             if (commandFuture != null) {
445                 commandFuture.cancel(true);
446                 this.commandFuture = null;
447             }
448             commandLock.unlock();
449         }
450     }
451
452     private static AdbBase64 getBase64Impl() {
453         Charset asciiCharset = Charset.forName("ASCII");
454         return bytes -> new String(Base64.getEncoder().encode(bytes), asciiCharset);
455     }
456
457     private static AdbCrypto loadKeyPair(String pubKeyFile, String privKeyFile)
458             throws NoSuchAlgorithmException, IOException, InvalidKeySpecException {
459         File pub = new File(pubKeyFile);
460         File priv = new File(privKeyFile);
461         AdbCrypto c = null;
462         // load key pair
463         if (pub.exists() && priv.exists()) {
464             try {
465                 c = AdbCrypto.loadAdbKeyPair(getBase64Impl(), priv, pub);
466             } catch (IOException ignored) {
467                 // Keys don't exits
468             }
469         }
470         if (c == null) {
471             // generate key pair
472             c = AdbCrypto.generateAdbKeyPair(getBase64Impl());
473             c.saveAdbKeyPair(priv, pub);
474         }
475         return c;
476     }
477
478     public void disconnect() {
479         var commandFuture = this.commandFuture;
480         if (commandFuture != null && !commandFuture.isDone()) {
481             commandFuture.cancel(true);
482         }
483         var adb = connection;
484         var sock = socket;
485         if (adb != null) {
486             try {
487                 adb.close();
488             } catch (IOException ignored) {
489             }
490             connection = null;
491         }
492         if (sock != null) {
493             try {
494                 sock.close();
495             } catch (IOException ignored) {
496             }
497             socket = null;
498         }
499     }
500
501     public static class VolumeInfo {
502         public int current;
503         public int min;
504         public int max;
505
506         VolumeInfo(int current, int min, int max) {
507             this.current = current;
508             this.min = min;
509             this.max = max;
510         }
511     }
512 }