2 * Copyright (c) 2010-2021 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
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
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.androiddebugbridge.internal;
15 import java.io.ByteArrayOutputStream;
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;
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;
38 import com.tananaev.adblib.AdbBase64;
39 import com.tananaev.adblib.AdbConnection;
40 import com.tananaev.adblib.AdbCrypto;
41 import com.tananaev.adblib.AdbStream;
44 * The {@link AndroidDebugBridgeConfiguration} class encapsulates adb device connection logic.
46 * @author Miguel Álvarez - Initial contribution
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_]*$");
59 private static @Nullable AdbCrypto adbCrypto;
62 var logger = LoggerFactory.getLogger(AndroidDebugBridgeDevice.class);
64 File directory = new File(ADB_FOLDER);
65 if (!directory.exists()) {
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());
75 private final ScheduledExecutorService scheduler;
76 private final ReentrantLock commandLock = new ReentrantLock();
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;
85 AndroidDebugBridgeDevice(ScheduledExecutorService scheduler) {
86 this.scheduler = scheduler;
89 public void configure(String ip, int port, int timeout) {
92 this.timeoutSec = timeout;
95 public void sendKeyEvent(String eventCode)
96 throws InterruptedException, AndroidDebugBridgeDeviceException, TimeoutException, ExecutionException {
97 runAdbShell("input", "keyevent", eventCode);
100 public void sendText(String text)
101 throws AndroidDebugBridgeDeviceException, InterruptedException, TimeoutException, ExecutionException {
102 runAdbShell("input", "text", URLEncoder.encode(text, StandardCharsets.UTF_8));
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");
111 runAdbShell("input", "mouse", "tap", match.group("x"), match.group("y"));
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);
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");
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);
132 runAdbShell("am", "force-stop", packageName);
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];
146 throw new AndroidDebugBridgeDeviceReadException("Unable to read package name");
149 public boolean isAwake()
150 throws InterruptedException, AndroidDebugBridgeDeviceException, TimeoutException, ExecutionException {
151 String devicesResp = runAdbShell("dumpsys", "activity", "|", "grep", "mWakefulness");
152 return devicesResp.contains("mWakefulness=Awake");
155 public boolean isScreenOn() throws InterruptedException, AndroidDebugBridgeDeviceException,
156 AndroidDebugBridgeDeviceReadException, TimeoutException, ExecutionException {
157 String devicesResp = runAdbShell("dumpsys", "power", "|", "grep", "'Display Power'");
158 if (devicesResp.contains("=")) {
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());
166 throw new AndroidDebugBridgeDeviceReadException("Unable to read screen state");
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
178 boolean isPlaying = mediaSessions[0].contains("PlaybackState {state=3");
179 logger.debug("device media state playing {}", isPlaying);
183 public boolean isPlayingAudio()
184 throws AndroidDebugBridgeDeviceException, InterruptedException, TimeoutException, ExecutionException {
185 String audioDump = runAdbShell("dumpsys", "audio", "|", "grep", "ID:");
186 return audioDump.contains("state:started");
189 public VolumeInfo getMediaVolume() throws AndroidDebugBridgeDeviceException, InterruptedException,
190 AndroidDebugBridgeDeviceReadException, TimeoutException, ExecutionException {
191 return getVolume(ANDROID_MEDIA_STREAM);
194 public void setMediaVolume(int volume)
195 throws AndroidDebugBridgeDeviceException, InterruptedException, TimeoutException, ExecutionException {
196 setVolume(ANDROID_MEDIA_STREAM, volume);
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("=")) {
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());
209 throw new AndroidDebugBridgeDeviceReadException("Unable to read wake lock");
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));
217 public String getModel() throws AndroidDebugBridgeDeviceException, InterruptedException,
218 AndroidDebugBridgeDeviceReadException, TimeoutException, ExecutionException {
219 return getDeviceProp("ro.product.model");
222 public String getAndroidVersion() throws AndroidDebugBridgeDeviceException, InterruptedException,
223 AndroidDebugBridgeDeviceReadException, TimeoutException, ExecutionException {
224 return getDeviceProp("ro.build.version.release");
227 public String getBrand() throws AndroidDebugBridgeDeviceException, InterruptedException,
228 AndroidDebugBridgeDeviceReadException, TimeoutException, ExecutionException {
229 return getDeviceProp("ro.product.brand");
232 public String getSerialNo() throws AndroidDebugBridgeDeviceException, InterruptedException,
233 AndroidDebugBridgeDeviceReadException, TimeoutException, ExecutionException {
234 return getDeviceProp("ro.serialno");
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");
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", "|",
250 Matcher matcher = VOLUME_PATTERN.matcher(volumeResp);
251 if (!matcher.find()) {
252 throw new AndroidDebugBridgeDeviceReadException("Unable to get volume info");
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);
261 public void rebootDevice()
262 throws AndroidDebugBridgeDeviceException, InterruptedException, TimeoutException, ExecutionException {
264 runAdbShell("reboot", "&", "sleep", "0.1", "&&", "exit");
270 public void powerOffDevice()
271 throws AndroidDebugBridgeDeviceException, InterruptedException, TimeoutException, ExecutionException {
273 runAdbShell("reboot", "-p", "&", "sleep", "0.1", "&&", "exit");
279 public boolean isConnected() {
280 var currentSocket = socket;
281 return currentSocket != null && currentSocket.isConnected();
284 public void connect() throws AndroidDebugBridgeDeviceException, InterruptedException {
286 AdbConnection adbConnection;
288 AdbCrypto crypto = adbCrypto;
289 if (crypto == null) {
290 throw new AndroidDebugBridgeDeviceException("Device not connected");
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();
302 throw new AndroidDebugBridgeDeviceException("Can not open socket " + ip + ":" + port);
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);
314 private String runAdbShell(String... args)
315 throws InterruptedException, AndroidDebugBridgeDeviceException, TimeoutException, ExecutionException {
316 var adb = connection;
318 throw new AndroidDebugBridgeDeviceException("Device not connected");
322 var commandFuture = scheduler.submit(() -> {
323 var byteArrayOutputStream = new ByteArrayOutputStream();
324 String cmd = String.join(" ", args);
325 logger.debug("{} - shell:{}", ip, cmd);
327 AdbStream stream = adb.open("shell:" + cmd);
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")) {
337 return byteArrayOutputStream.toString(StandardCharsets.US_ASCII);
339 this.commandFuture = commandFuture;
340 return commandFuture.get(timeoutSec, TimeUnit.SECONDS);
342 var commandFuture = this.commandFuture;
343 if (commandFuture != null) {
344 commandFuture.cancel(true);
345 this.commandFuture = null;
347 commandLock.unlock();
351 private static AdbBase64 getBase64Impl() {
352 Charset asciiCharset = Charset.forName("ASCII");
353 return bytes -> new String(Base64.getEncoder().encode(bytes), asciiCharset);
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);
362 if (pub.exists() && priv.exists()) {
364 c = AdbCrypto.loadAdbKeyPair(getBase64Impl(), priv, pub);
365 } catch (IOException ignored) {
371 c = AdbCrypto.generateAdbKeyPair(getBase64Impl());
372 c.saveAdbKeyPair(priv, pub);
377 public void disconnect() {
378 var commandFuture = this.commandFuture;
379 if (commandFuture != null && !commandFuture.isDone()) {
380 commandFuture.cancel(true);
382 var adb = connection;
387 } catch (IOException ignored) {
394 } catch (IOException ignored) {
400 public static class VolumeInfo {
405 VolumeInfo(int current, int min, int max) {
406 this.current = current;