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