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.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;
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;
39 import com.tananaev.adblib.AdbBase64;
40 import com.tananaev.adblib.AdbConnection;
41 import com.tananaev.adblib.AdbCrypto;
42 import com.tananaev.adblib.AdbStream;
45 * The {@link AndroidDebugBridgeConfiguration} class encapsulates adb device connection logic.
47 * @author Miguel Álvarez - Initial contribution
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);
64 private static @Nullable AdbCrypto adbCrypto;
67 var logger = LoggerFactory.getLogger(AndroidDebugBridgeDevice.class);
69 File directory = new File(ADB_FOLDER);
70 if (!directory.exists()) {
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());
80 private final ScheduledExecutorService scheduler;
81 private final ReentrantLock commandLock = new ReentrantLock();
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;
91 AndroidDebugBridgeDevice(ScheduledExecutorService scheduler) {
92 this.scheduler = scheduler;
95 public void configure(String ip, int port, int timeout, int recordDuration) {
98 this.timeoutSec = timeout;
99 this.recordDuration = recordDuration;
102 public void sendKeyEvent(String eventCode)
103 throws InterruptedException, AndroidDebugBridgeDeviceException, TimeoutException, ExecutionException {
104 runAdbShell("input", "keyevent", eventCode);
107 public void sendText(String text)
108 throws AndroidDebugBridgeDeviceException, InterruptedException, TimeoutException, ExecutionException {
109 runAdbShell("input", "text", URLEncoder.encode(text, StandardCharsets.UTF_8));
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");
118 runAdbShell("input", "mouse", "tap", match.group("x"), match.group("y"));
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");
127 runAdbShell("am", "start", "-a", url);
130 public void startPackage(String packageName)
131 throws InterruptedException, AndroidDebugBridgeDeviceException, TimeoutException, ExecutionException {
132 if (packageName.contains("/")) {
133 startPackageWithActivity(packageName);
136 if (!PACKAGE_NAME_PATTERN.matcher(packageName).matches()) {
137 logger.warn("{} is not a valid package name", packageName);
140 var out = runAdbShell("monkey", "--pct-syskeys", "0", "-p", packageName, "-v", "1");
141 if (out.contains("monkey aborted")) {
142 startTVPackage(packageName);
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");
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);
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);
169 if (!PACKAGE_NAME_PATTERN.matcher(activityName).matches()) {
170 logger.warn("{} is not a valid activity name", activityName);
173 var out = runAdbShell("am", "start", "-n", packageWithActivity);
174 if (out.contains("usage: am")) {
175 out = runAdbShell("am", "start", packageWithActivity);
177 if (out.contains("usage: am") || out.contains("Exception")) {
178 logger.warn("open {} fail; retrying to open without activity info", packageWithActivity);
179 startPackage(packageName);
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);
189 runAdbShell("am", "force-stop", packageName);
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];
203 throw new AndroidDebugBridgeDeviceReadException("Unable to read package name");
206 public boolean isAwake()
207 throws InterruptedException, AndroidDebugBridgeDeviceException, TimeoutException, ExecutionException {
208 String devicesResp = runAdbShell("dumpsys", "activity", "|", "grep", "mWakefulness");
209 return devicesResp.contains("mWakefulness=Awake");
212 public boolean isScreenOn() throws InterruptedException, AndroidDebugBridgeDeviceException,
213 AndroidDebugBridgeDeviceReadException, TimeoutException, ExecutionException {
214 String devicesResp = runAdbShell("dumpsys", "power", "|", "grep", "'Display Power'");
215 if (devicesResp.contains("=")) {
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());
223 throw new AndroidDebugBridgeDeviceReadException("Unable to read screen state");
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
235 boolean isPlaying = mediaSessions[0].contains("PlaybackState {state=3");
236 logger.debug("device media state playing {}", isPlaying);
240 public boolean isPlayingAudio()
241 throws AndroidDebugBridgeDeviceException, InterruptedException, TimeoutException, ExecutionException {
242 String audioDump = runAdbShell("dumpsys", "audio", "|", "grep", "ID:");
243 return audioDump.contains("state:started");
246 public VolumeInfo getMediaVolume() throws AndroidDebugBridgeDeviceException, InterruptedException,
247 AndroidDebugBridgeDeviceReadException, TimeoutException, ExecutionException {
248 return getVolume(ANDROID_MEDIA_STREAM);
251 public void setMediaVolume(int volume)
252 throws AndroidDebugBridgeDeviceException, InterruptedException, TimeoutException, ExecutionException {
253 setVolume(ANDROID_MEDIA_STREAM, volume);
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("=")) {
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());
266 throw new AndroidDebugBridgeDeviceReadException("Unable to read wake lock");
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));
274 public String getModel() throws AndroidDebugBridgeDeviceException, InterruptedException,
275 AndroidDebugBridgeDeviceReadException, TimeoutException, ExecutionException {
276 return getDeviceProp("ro.product.model");
279 public String getAndroidVersion() throws AndroidDebugBridgeDeviceException, InterruptedException,
280 AndroidDebugBridgeDeviceReadException, TimeoutException, ExecutionException {
281 return getDeviceProp("ro.build.version.release");
284 public String getBrand() throws AndroidDebugBridgeDeviceException, InterruptedException,
285 AndroidDebugBridgeDeviceReadException, TimeoutException, ExecutionException {
286 return getDeviceProp("ro.product.brand");
289 public String getSerialNo() throws AndroidDebugBridgeDeviceException, InterruptedException,
290 AndroidDebugBridgeDeviceReadException, TimeoutException, ExecutionException {
291 return getDeviceProp("ro.serialno");
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");
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", "|",
307 Matcher matcher = VOLUME_PATTERN.matcher(volumeResp);
308 if (!matcher.find()) {
309 throw new AndroidDebugBridgeDeviceReadException("Unable to get volume info");
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);
318 public String recordInputEvents()
319 throws AndroidDebugBridgeDeviceException, InterruptedException, TimeoutException, ExecutionException {
320 String out = runAdbShell(recordDuration * 2, "getevent", "&", "sleep", Integer.toString(recordDuration), "&&",
322 var matcher = INPUT_EVENT_PATTERN.matcher(out);
323 var commandList = new ArrayList<String>();
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));
332 } catch (NumberFormatException e) {
333 logger.warn("NumberFormatException while parsing events, aborting");
336 return String.join(" && ", commandList);
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");
348 public void rebootDevice()
349 throws AndroidDebugBridgeDeviceException, InterruptedException, TimeoutException, ExecutionException {
351 runAdbShell("reboot", "&", "sleep", "0.1", "&&", "exit");
357 public void powerOffDevice()
358 throws AndroidDebugBridgeDeviceException, InterruptedException, TimeoutException, ExecutionException {
360 runAdbShell("reboot", "-p", "&", "sleep", "0.1", "&&", "exit");
366 public boolean isConnected() {
367 var currentSocket = socket;
368 return currentSocket != null && currentSocket.isConnected();
371 public void connect() throws AndroidDebugBridgeDeviceException, InterruptedException {
373 AdbConnection adbConnection;
375 AdbCrypto crypto = adbCrypto;
376 if (crypto == null) {
377 throw new AndroidDebugBridgeDeviceException("Device not connected");
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();
389 throw new AndroidDebugBridgeDeviceException("Can not open socket " + ip + ":" + port);
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);
401 private String runAdbShell(String... args)
402 throws InterruptedException, AndroidDebugBridgeDeviceException, TimeoutException, ExecutionException {
403 return runAdbShell(timeoutSec, args);
406 private String runAdbShell(int commandTimeout, String... args)
407 throws InterruptedException, AndroidDebugBridgeDeviceException, TimeoutException, ExecutionException {
408 var adb = connection;
410 throw new AndroidDebugBridgeDeviceException("Device not connected");
414 var commandFuture = scheduler.submit(() -> {
415 var byteArrayOutputStream = new ByteArrayOutputStream();
416 String cmd = String.join(" ", args);
417 logger.debug("{} - shell:{}", ip, cmd);
419 AdbStream stream = adb.open("shell:" + cmd);
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")) {
429 return byteArrayOutputStream.toString(StandardCharsets.US_ASCII);
431 this.commandFuture = commandFuture;
432 return commandFuture.get(commandTimeout, TimeUnit.SECONDS);
434 var commandFuture = this.commandFuture;
435 if (commandFuture != null) {
436 commandFuture.cancel(true);
437 this.commandFuture = null;
439 commandLock.unlock();
443 private static AdbBase64 getBase64Impl() {
444 Charset asciiCharset = Charset.forName("ASCII");
445 return bytes -> new String(Base64.getEncoder().encode(bytes), asciiCharset);
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);
454 if (pub.exists() && priv.exists()) {
456 c = AdbCrypto.loadAdbKeyPair(getBase64Impl(), priv, pub);
457 } catch (IOException ignored) {
463 c = AdbCrypto.generateAdbKeyPair(getBase64Impl());
464 c.saveAdbKeyPair(priv, pub);
469 public void disconnect() {
470 var commandFuture = this.commandFuture;
471 if (commandFuture != null && !commandFuture.isDone()) {
472 commandFuture.cancel(true);
474 var adb = connection;
479 } catch (IOException ignored) {
486 } catch (IOException ignored) {
492 public static class VolumeInfo {
497 VolumeInfo(int current, int min, int max) {
498 this.current = current;