2 * Copyright (c) 2010-2022 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.ExecutionException;
29 import java.util.concurrent.Future;
30 import java.util.concurrent.ScheduledExecutorService;
31 import java.util.concurrent.TimeUnit;
32 import java.util.concurrent.TimeoutException;
33 import java.util.concurrent.locks.ReentrantLock;
34 import java.util.regex.Matcher;
35 import java.util.regex.Pattern;
37 import org.eclipse.jdt.annotation.NonNullByDefault;
38 import org.eclipse.jdt.annotation.Nullable;
39 import org.openhab.core.OpenHAB;
40 import org.slf4j.Logger;
41 import org.slf4j.LoggerFactory;
43 import com.tananaev.adblib.AdbBase64;
44 import com.tananaev.adblib.AdbConnection;
45 import com.tananaev.adblib.AdbCrypto;
46 import com.tananaev.adblib.AdbStream;
49 * The {@link AndroidDebugBridgeConfiguration} class encapsulates adb device connection logic.
51 * @author Miguel Álvarez - Initial contribution
54 public class AndroidDebugBridgeDevice {
55 public static final int ANDROID_MEDIA_STREAM = 3;
56 private static final String ADB_FOLDER = OpenHAB.getUserDataFolder() + File.separator + ".adb";
57 private final Logger logger = LoggerFactory.getLogger(AndroidDebugBridgeDevice.class);
58 private static final Pattern VOLUME_PATTERN = Pattern
59 .compile("volume is (?<current>\\d.*) in range \\[(?<min>\\d.*)\\.\\.(?<max>\\d.*)]");
60 private static final Pattern TAP_EVENT_PATTERN = Pattern.compile("(?<x>\\d+),(?<y>\\d+)");
61 private static final Pattern PACKAGE_NAME_PATTERN = Pattern
62 .compile("^([A-Za-z]{1}[A-Za-z\\d_]*\\.)+[A-Za-z][A-Za-z\\d_]*$");
63 private static final Pattern URL_PATTERN = Pattern.compile(
64 "https?:\\/\\/(www\\.)?[-a-zA-Z0-9@:%._\\+~#=]{2,256}\\.[a-z]{2,4}\\b([-a-zA-Z0-9@:%_\\+.~#?&//=]*)$");
65 private static final Pattern INPUT_EVENT_PATTERN = Pattern
66 .compile("/(?<input>\\S+): (?<n1>\\S+) (?<n2>\\S+) (?<n3>\\S+)$", Pattern.MULTILINE);
68 private static @Nullable AdbCrypto adbCrypto;
71 var logger = LoggerFactory.getLogger(AndroidDebugBridgeDevice.class);
73 File directory = new File(ADB_FOLDER);
74 if (!directory.exists()) {
77 adbCrypto = loadKeyPair(ADB_FOLDER + File.separator + "adb_pub.key",
78 ADB_FOLDER + File.separator + "adb.key");
79 } catch (NoSuchAlgorithmException | IOException | InvalidKeySpecException e) {
80 logger.warn("Unable to setup adb keys: {}", e.getMessage());
84 private final ScheduledExecutorService scheduler;
85 private final ReentrantLock commandLock = new ReentrantLock();
87 private String ip = "127.0.0.1";
88 private int port = 5555;
89 private int timeoutSec = 5;
90 private int recordDuration;
91 private @Nullable Socket socket;
92 private @Nullable AdbConnection connection;
93 private @Nullable Future<String> commandFuture;
95 public AndroidDebugBridgeDevice(ScheduledExecutorService scheduler) {
96 this.scheduler = scheduler;
99 public void configure(String ip, int port, int timeout, int recordDuration) {
102 this.timeoutSec = timeout;
103 this.recordDuration = recordDuration;
106 public void sendKeyEvent(String eventCode)
107 throws InterruptedException, AndroidDebugBridgeDeviceException, TimeoutException, ExecutionException {
108 runAdbShell("input", "keyevent", eventCode);
111 public void sendText(String text)
112 throws AndroidDebugBridgeDeviceException, InterruptedException, TimeoutException, ExecutionException {
113 runAdbShell("input", "text", URLEncoder.encode(text, StandardCharsets.UTF_8));
116 public void sendTap(String point)
117 throws AndroidDebugBridgeDeviceException, InterruptedException, TimeoutException, ExecutionException {
118 var match = TAP_EVENT_PATTERN.matcher(point);
119 if (!match.matches()) {
120 throw new AndroidDebugBridgeDeviceException("Unable to parse tap event");
122 runAdbShell("input", "mouse", "tap", match.group("x"), match.group("y"));
125 public void openUrl(String url)
126 throws InterruptedException, AndroidDebugBridgeDeviceException, TimeoutException, ExecutionException {
127 var match = URL_PATTERN.matcher(url);
128 if (!match.matches()) {
129 throw new AndroidDebugBridgeDeviceException("Unable to parse url");
131 runAdbShell("am", "start", "-a", url);
134 public void startPackage(String packageName)
135 throws InterruptedException, AndroidDebugBridgeDeviceException, TimeoutException, ExecutionException {
136 if (packageName.contains("/")) {
137 startPackageWithActivity(packageName);
140 if (!PACKAGE_NAME_PATTERN.matcher(packageName).matches()) {
141 logger.warn("{} is not a valid package name", packageName);
144 var out = runAdbShell("monkey", "--pct-syskeys", "0", "-p", packageName, "-v", "1");
145 if (out.contains("monkey aborted")) {
146 startTVPackage(packageName);
150 private void startTVPackage(String packageName)
151 throws InterruptedException, AndroidDebugBridgeDeviceException, TimeoutException, ExecutionException {
152 // https://developer.android.com/training/tv/start/start
153 String result = runAdbShell("monkey", "--pct-syskeys", "0", "-c", "android.intent.category.LEANBACK_LAUNCHER",
154 "-p", packageName, "1");
155 if (result.contains("monkey aborted")) {
156 throw new AndroidDebugBridgeDeviceException("Unable to open package");
160 public void startPackageWithActivity(String packageWithActivity)
161 throws InterruptedException, AndroidDebugBridgeDeviceException, TimeoutException, ExecutionException {
162 var parts = packageWithActivity.split("/");
163 if (parts.length != 2) {
164 logger.warn("{} is not a valid package", packageWithActivity);
167 var packageName = parts[0];
168 var activityName = parts[1];
169 if (!PACKAGE_NAME_PATTERN.matcher(packageName).matches()) {
170 logger.warn("{} is not a valid package name", packageName);
173 if (!PACKAGE_NAME_PATTERN.matcher(activityName).matches()) {
174 logger.warn("{} is not a valid activity name", activityName);
177 var out = runAdbShell("am", "start", "-n", packageWithActivity);
178 if (out.contains("usage: am")) {
179 out = runAdbShell("am", "start", packageWithActivity);
181 if (out.contains("usage: am") || out.contains("Exception")) {
182 logger.warn("open {} fail; retrying to open without activity info", packageWithActivity);
183 startPackage(packageName);
187 public void stopPackage(String packageName)
188 throws AndroidDebugBridgeDeviceException, InterruptedException, TimeoutException, ExecutionException {
189 if (!PACKAGE_NAME_PATTERN.matcher(packageName).matches()) {
190 logger.warn("{} is not a valid package name", packageName);
193 runAdbShell("am", "force-stop", packageName);
196 public String getCurrentPackage() throws AndroidDebugBridgeDeviceException, InterruptedException,
197 AndroidDebugBridgeDeviceReadException, TimeoutException, ExecutionException {
198 var out = runAdbShell("dumpsys", "window", "windows", "|", "grep", "mFocusedApp");
199 var targetLine = Arrays.stream(out.split("\n")).findFirst().orElse("");
200 var lineParts = targetLine.split(" ");
201 if (lineParts.length >= 2) {
202 var packageActivityName = lineParts[lineParts.length - 2];
203 if (packageActivityName.contains("/")) {
204 return packageActivityName.split("/")[0];
207 throw new AndroidDebugBridgeDeviceReadException("Unable to read package name");
210 public boolean isAwake()
211 throws InterruptedException, AndroidDebugBridgeDeviceException, TimeoutException, ExecutionException {
212 String devicesResp = runAdbShell("dumpsys", "activity", "|", "grep", "mWakefulness");
213 return devicesResp.contains("mWakefulness=Awake");
216 public boolean isScreenOn() throws InterruptedException, AndroidDebugBridgeDeviceException,
217 AndroidDebugBridgeDeviceReadException, TimeoutException, ExecutionException {
218 String devicesResp = runAdbShell("dumpsys", "power", "|", "grep", "'Display Power'");
219 if (devicesResp.contains("=")) {
221 var state = devicesResp.split("=")[1].trim();
222 return state.equals("ON");
223 } catch (NumberFormatException e) {
224 logger.debug("Unable to parse device screen state: {}", e.getMessage());
227 throw new AndroidDebugBridgeDeviceReadException("Unable to read screen state");
230 public boolean isPlayingMedia(String currentApp)
231 throws AndroidDebugBridgeDeviceException, InterruptedException, TimeoutException, ExecutionException {
232 String devicesResp = runAdbShell("dumpsys", "media_session", "|", "grep", "-A", "100", "'Sessions Stack'", "|",
233 "grep", "-A", "50", currentApp);
234 String[] mediaSessions = devicesResp.split("\n\n");
235 if (mediaSessions.length == 0) {
236 // no media session found for current app
239 boolean isPlaying = mediaSessions[0].contains("PlaybackState {state=3");
240 logger.debug("device media state playing {}", isPlaying);
244 public boolean isPlayingAudio()
245 throws AndroidDebugBridgeDeviceException, InterruptedException, TimeoutException, ExecutionException {
246 String audioDump = runAdbShell("dumpsys", "audio", "|", "grep", "ID:");
247 return audioDump.contains("state:started");
250 public VolumeInfo getMediaVolume() throws AndroidDebugBridgeDeviceException, InterruptedException,
251 AndroidDebugBridgeDeviceReadException, TimeoutException, ExecutionException {
252 return getVolume(ANDROID_MEDIA_STREAM);
255 public void setMediaVolume(int volume)
256 throws AndroidDebugBridgeDeviceException, InterruptedException, TimeoutException, ExecutionException {
257 setVolume(ANDROID_MEDIA_STREAM, volume);
260 public int getPowerWakeLock() throws InterruptedException, AndroidDebugBridgeDeviceException,
261 AndroidDebugBridgeDeviceReadException, TimeoutException, ExecutionException {
262 String lockResp = runAdbShell("dumpsys", "power", "|", "grep", "Locks", "|", "grep", "'size='");
263 if (lockResp.contains("=")) {
265 return Integer.parseInt(lockResp.replace("\n", "").split("=")[1].trim());
266 } catch (NumberFormatException e) {
267 String message = String.format("Unable to parse device wake-lock '%s'", lockResp);
268 logger.debug("{}: {}", message, e.getMessage());
269 throw new AndroidDebugBridgeDeviceReadException(message);
272 throw new AndroidDebugBridgeDeviceReadException(String.format("Unable to read wake-lock '%s'", lockResp));
275 private void setVolume(int stream, int volume)
276 throws AndroidDebugBridgeDeviceException, InterruptedException, TimeoutException, ExecutionException {
277 runAdbShell("media", "volume", "--show", "--stream", String.valueOf(stream), "--set", String.valueOf(volume));
280 public String getModel() throws AndroidDebugBridgeDeviceException, InterruptedException,
281 AndroidDebugBridgeDeviceReadException, TimeoutException, ExecutionException {
282 return getDeviceProp("ro.product.model");
285 public String getAndroidVersion() throws AndroidDebugBridgeDeviceException, InterruptedException,
286 AndroidDebugBridgeDeviceReadException, TimeoutException, ExecutionException {
287 return getDeviceProp("ro.build.version.release");
290 public String getBrand() throws AndroidDebugBridgeDeviceException, InterruptedException,
291 AndroidDebugBridgeDeviceReadException, TimeoutException, ExecutionException {
292 return getDeviceProp("ro.product.brand");
295 public String getSerialNo() throws AndroidDebugBridgeDeviceException, InterruptedException,
296 AndroidDebugBridgeDeviceReadException, TimeoutException, ExecutionException {
297 return getDeviceProp("ro.serialno");
300 public String getMacAddress() throws AndroidDebugBridgeDeviceException, InterruptedException,
301 AndroidDebugBridgeDeviceReadException, TimeoutException, ExecutionException {
302 return getDeviceProp("ro.boot.wifimacaddr").toLowerCase();
305 private String getDeviceProp(String name) throws AndroidDebugBridgeDeviceException, InterruptedException,
306 AndroidDebugBridgeDeviceReadException, TimeoutException, ExecutionException {
307 var propValue = runAdbShell("getprop", name, "&&", "sleep", "0.3").replace("\n", "").replace("\r", "");
308 if (propValue.length() == 0) {
309 throw new AndroidDebugBridgeDeviceReadException(String.format("Unable to get device property '%s'", name));
314 private VolumeInfo getVolume(int stream) throws AndroidDebugBridgeDeviceException, InterruptedException,
315 AndroidDebugBridgeDeviceReadException, TimeoutException, ExecutionException {
316 String volumeResp = runAdbShell("media", "volume", "--show", "--stream", String.valueOf(stream), "--get", "|",
318 Matcher matcher = VOLUME_PATTERN.matcher(volumeResp);
319 if (!matcher.find()) {
320 throw new AndroidDebugBridgeDeviceReadException("Unable to get volume info");
322 var volumeInfo = new VolumeInfo(Integer.parseInt(matcher.group("current")),
323 Integer.parseInt(matcher.group("min")), Integer.parseInt(matcher.group("max")));
324 logger.debug("Device {}:{} VolumeInfo: current {}, min {}, max {}", this.ip, this.port, volumeInfo.current,
325 volumeInfo.min, volumeInfo.max);
329 public String recordInputEvents()
330 throws AndroidDebugBridgeDeviceException, InterruptedException, TimeoutException, ExecutionException {
331 String out = runAdbShell(recordDuration * 2, "getevent", "&", "sleep", Integer.toString(recordDuration), "&&",
333 var matcher = INPUT_EVENT_PATTERN.matcher(out);
334 var commandList = new ArrayList<String>();
336 while (matcher.find()) {
337 String inputPath = matcher.group("input");
338 int n1 = Integer.parseInt(matcher.group("n1"), 16);
339 int n2 = Integer.parseInt(matcher.group("n2"), 16);
340 int n3 = Integer.parseInt(matcher.group("n3"), 16);
341 commandList.add(String.format("sendevent /%s %d %d %d", inputPath, n1, n2, n3));
343 } catch (NumberFormatException e) {
344 logger.warn("NumberFormatException while parsing events, aborting");
347 return String.join(" && ", commandList);
350 public void sendInputEvents(String command)
351 throws AndroidDebugBridgeDeviceException, InterruptedException, TimeoutException, ExecutionException {
352 String out = runAdbShell(command.split(" "));
353 if (out.length() != 0) {
354 logger.warn("Device event unexpected output: {}", out);
355 throw new AndroidDebugBridgeDeviceException("Device event execution fail");
359 public void rebootDevice()
360 throws AndroidDebugBridgeDeviceException, InterruptedException, TimeoutException, ExecutionException {
362 runAdbShell("reboot", "&", "sleep", "0.1", "&&", "exit");
368 public void powerOffDevice()
369 throws AndroidDebugBridgeDeviceException, InterruptedException, TimeoutException, ExecutionException {
371 runAdbShell("reboot", "-p", "&", "sleep", "0.1", "&&", "exit");
377 public boolean isConnected() {
378 var currentSocket = socket;
379 return currentSocket != null && currentSocket.isConnected();
382 public void connect() throws AndroidDebugBridgeDeviceException, InterruptedException {
384 AdbConnection adbConnection;
386 AdbCrypto crypto = adbCrypto;
387 if (crypto == null) {
388 throw new AndroidDebugBridgeDeviceException("Device not connected");
393 sock.connect(new InetSocketAddress(ip, port), (int) TimeUnit.SECONDS.toMillis(15));
394 } catch (IOException e) {
395 logger.debug("Error connecting to {}: [{}] {}", ip, e.getClass().getName(), e.getMessage());
396 if ("Socket closed".equals(e.getMessage())) {
397 // Connection aborted by us
398 throw new InterruptedException();
400 throw new AndroidDebugBridgeDeviceException("Can not open socket " + ip + ":" + port);
403 adbConnection = AdbConnection.create(sock, crypto);
404 connection = adbConnection;
405 adbConnection.connect(15, TimeUnit.SECONDS, false);
406 } catch (IOException e) {
407 logger.debug("Error connecting to {}: {}", ip, e.getMessage());
408 throw new AndroidDebugBridgeDeviceException("Can not open adb connection " + ip + ":" + port);
412 private String runAdbShell(String... args)
413 throws InterruptedException, AndroidDebugBridgeDeviceException, TimeoutException, ExecutionException {
414 return runAdbShell(timeoutSec, args);
417 private String runAdbShell(int commandTimeout, String... args)
418 throws InterruptedException, AndroidDebugBridgeDeviceException, TimeoutException, ExecutionException {
419 var adb = connection;
421 throw new AndroidDebugBridgeDeviceException("Device not connected");
425 var commandFuture = scheduler.submit(() -> {
426 var byteArrayOutputStream = new ByteArrayOutputStream();
427 String cmd = String.join(" ", args);
428 logger.debug("{} - shell:{}", ip, cmd);
429 try (AdbStream stream = adb.open("shell:" + cmd)) {
431 byteArrayOutputStream.writeBytes(stream.read());
432 } while (!stream.isClosed());
433 } catch (IOException e) {
434 if (!"Stream closed".equals(e.getMessage())) {
438 return byteArrayOutputStream.toString(StandardCharsets.US_ASCII);
440 this.commandFuture = commandFuture;
441 return commandFuture.get(commandTimeout, TimeUnit.SECONDS);
443 var commandFuture = this.commandFuture;
444 if (commandFuture != null) {
445 commandFuture.cancel(true);
446 this.commandFuture = null;
448 commandLock.unlock();
452 private static AdbBase64 getBase64Impl() {
453 Charset asciiCharset = Charset.forName("ASCII");
454 return bytes -> new String(Base64.getEncoder().encode(bytes), asciiCharset);
457 private static AdbCrypto loadKeyPair(String pubKeyFile, String privKeyFile)
458 throws NoSuchAlgorithmException, IOException, InvalidKeySpecException {
459 File pub = new File(pubKeyFile);
460 File priv = new File(privKeyFile);
463 if (pub.exists() && priv.exists()) {
465 c = AdbCrypto.loadAdbKeyPair(getBase64Impl(), priv, pub);
466 } catch (IOException ignored) {
472 c = AdbCrypto.generateAdbKeyPair(getBase64Impl());
473 c.saveAdbKeyPair(priv, pub);
478 public void disconnect() {
479 var commandFuture = this.commandFuture;
480 if (commandFuture != null && !commandFuture.isDone()) {
481 commandFuture.cancel(true);
483 var adb = connection;
488 } catch (IOException ignored) {
495 } catch (IOException ignored) {
501 public static class VolumeInfo {
506 VolumeInfo(int current, int min, int max) {
507 this.current = current;