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