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