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