[androiddebugbridge] Add channels for record events, open urls and doc improvements (#11692)
* Add channels for record events, open urls and doc improvements Signed-off-by: Miguel Álvarez Díez <miguelwork92@gmail.com>
This commit is contained in:
@@ -37,6 +37,7 @@ public class AndroidDebugBridgeBindingConstants {
|
||||
public static final String KEY_EVENT_CHANNEL = "key-event";
|
||||
public static final String TEXT_CHANNEL = "text";
|
||||
public static final String TAP_CHANNEL = "tap";
|
||||
public static final String URL_CHANNEL = "url";
|
||||
public static final String MEDIA_VOLUME_CHANNEL = "media-volume";
|
||||
public static final String MEDIA_CONTROL_CHANNEL = "media-control";
|
||||
public static final String START_PACKAGE_CHANNEL = "start-package";
|
||||
@@ -47,7 +48,8 @@ public class AndroidDebugBridgeBindingConstants {
|
||||
public static final String WAKE_LOCK_CHANNEL = "wake-lock";
|
||||
public static final String SCREEN_STATE_CHANNEL = "screen-state";
|
||||
public static final String SHUTDOWN_CHANNEL = "shutdown";
|
||||
|
||||
public static final String RECORD_INPUT_CHANNEL = "record-input";
|
||||
public static final String RECORDED_INPUT_CHANNEL = "recorded-input";
|
||||
// List of all Parameters
|
||||
public static final String PARAMETER_IP = "ip";
|
||||
public static final String PARAMETER_PORT = "port";
|
||||
|
||||
@@ -38,6 +38,10 @@ public class AndroidDebugBridgeConfiguration {
|
||||
* Command timeout seconds.
|
||||
*/
|
||||
public int timeout = 5;
|
||||
/**
|
||||
* Record input duration in seconds.
|
||||
*/
|
||||
public int recordDuration = 5;
|
||||
/**
|
||||
* Configure media state detection behavior by package
|
||||
*/
|
||||
|
||||
@@ -22,6 +22,7 @@ import java.nio.charset.Charset;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.spec.InvalidKeySpecException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Base64;
|
||||
import java.util.concurrent.*;
|
||||
@@ -55,6 +56,10 @@ public class AndroidDebugBridgeDevice {
|
||||
private static final Pattern TAP_EVENT_PATTERN = Pattern.compile("(?<x>\\d+),(?<y>\\d+)");
|
||||
private static final Pattern PACKAGE_NAME_PATTERN = Pattern
|
||||
.compile("^([A-Za-z]{1}[A-Za-z\\d_]*\\.)+[A-Za-z][A-Za-z\\d_]*$");
|
||||
private static final Pattern URL_PATTERN = Pattern.compile(
|
||||
"https?:\\/\\/(www\\.)?[-a-zA-Z0-9@:%._\\+~#=]{2,256}\\.[a-z]{2,4}\\b([-a-zA-Z0-9@:%_\\+.~#?&//=]*)$");
|
||||
private static final Pattern INPUT_EVENT_PATTERN = Pattern
|
||||
.compile("/(?<input>\\S+): (?<n1>\\S+) (?<n2>\\S+) (?<n3>\\S+)$", Pattern.MULTILINE);
|
||||
|
||||
private static @Nullable AdbCrypto adbCrypto;
|
||||
|
||||
@@ -78,6 +83,7 @@ public class AndroidDebugBridgeDevice {
|
||||
private String ip = "127.0.0.1";
|
||||
private int port = 5555;
|
||||
private int timeoutSec = 5;
|
||||
private int recordDuration;
|
||||
private @Nullable Socket socket;
|
||||
private @Nullable AdbConnection connection;
|
||||
private @Nullable Future<String> commandFuture;
|
||||
@@ -86,10 +92,11 @@ public class AndroidDebugBridgeDevice {
|
||||
this.scheduler = scheduler;
|
||||
}
|
||||
|
||||
public void configure(String ip, int port, int timeout) {
|
||||
public void configure(String ip, int port, int timeout, int recordDuration) {
|
||||
this.ip = ip;
|
||||
this.port = port;
|
||||
this.timeoutSec = timeout;
|
||||
this.recordDuration = recordDuration;
|
||||
}
|
||||
|
||||
public void sendKeyEvent(String eventCode)
|
||||
@@ -111,18 +118,68 @@ public class AndroidDebugBridgeDevice {
|
||||
runAdbShell("input", "mouse", "tap", match.group("x"), match.group("y"));
|
||||
}
|
||||
|
||||
public void openUrl(String url)
|
||||
throws InterruptedException, AndroidDebugBridgeDeviceException, TimeoutException, ExecutionException {
|
||||
var match = URL_PATTERN.matcher(url);
|
||||
if (!match.matches()) {
|
||||
throw new AndroidDebugBridgeDeviceException("Unable to parse url");
|
||||
}
|
||||
runAdbShell("am", "start", "-a", url);
|
||||
}
|
||||
|
||||
public void startPackage(String packageName)
|
||||
throws InterruptedException, AndroidDebugBridgeDeviceException, TimeoutException, ExecutionException {
|
||||
if (packageName.contains("/")) {
|
||||
startPackageWithActivity(packageName);
|
||||
return;
|
||||
}
|
||||
if (!PACKAGE_NAME_PATTERN.matcher(packageName).matches()) {
|
||||
logger.warn("{} is not a valid package name", packageName);
|
||||
return;
|
||||
}
|
||||
var out = runAdbShell("monkey", "--pct-syskeys", "0", "-p", packageName, "-v", "1");
|
||||
if (out.contains("monkey aborted")) {
|
||||
startTVPackage(packageName);
|
||||
}
|
||||
}
|
||||
|
||||
private void startTVPackage(String packageName)
|
||||
throws InterruptedException, AndroidDebugBridgeDeviceException, TimeoutException, ExecutionException {
|
||||
// https://developer.android.com/training/tv/start/start
|
||||
String result = runAdbShell("monkey", "--pct-syskeys", "0", "-c", "android.intent.category.LEANBACK_LAUNCHER",
|
||||
"-p", packageName, "1");
|
||||
if (result.contains("monkey aborted")) {
|
||||
throw new AndroidDebugBridgeDeviceException("Unable to open package");
|
||||
}
|
||||
}
|
||||
|
||||
public void startPackageWithActivity(String packageWithActivity)
|
||||
throws InterruptedException, AndroidDebugBridgeDeviceException, TimeoutException, ExecutionException {
|
||||
var parts = packageWithActivity.split("/");
|
||||
if (parts.length != 2) {
|
||||
logger.warn("{} is not a valid package", packageWithActivity);
|
||||
return;
|
||||
}
|
||||
var packageName = parts[0];
|
||||
var activityName = parts[1];
|
||||
if (!PACKAGE_NAME_PATTERN.matcher(packageName).matches()) {
|
||||
logger.warn("{} is not a valid package name", packageName);
|
||||
return;
|
||||
}
|
||||
if (!PACKAGE_NAME_PATTERN.matcher(activityName).matches()) {
|
||||
logger.warn("{} is not a valid activity name", activityName);
|
||||
return;
|
||||
}
|
||||
var out = runAdbShell("am", "start", "-n", packageWithActivity);
|
||||
if (out.contains("usage: am")) {
|
||||
out = runAdbShell("am", "start", packageWithActivity);
|
||||
}
|
||||
if (out.contains("usage: am") || out.contains("Exception")) {
|
||||
logger.warn("open {} fail; retrying to open without activity info", packageWithActivity);
|
||||
startPackage(packageName);
|
||||
}
|
||||
}
|
||||
|
||||
public void stopPackage(String packageName)
|
||||
throws AndroidDebugBridgeDeviceException, InterruptedException, TimeoutException, ExecutionException {
|
||||
if (!PACKAGE_NAME_PATTERN.matcher(packageName).matches()) {
|
||||
@@ -160,7 +217,7 @@ public class AndroidDebugBridgeDevice {
|
||||
var state = devicesResp.split("=")[1].trim();
|
||||
return state.equals("ON");
|
||||
} catch (NumberFormatException e) {
|
||||
logger.debug("Unable to parse device wake lock: {}", e.getMessage());
|
||||
logger.debug("Unable to parse device screen state: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
throw new AndroidDebugBridgeDeviceReadException("Unable to read screen state");
|
||||
@@ -258,6 +315,36 @@ public class AndroidDebugBridgeDevice {
|
||||
return volumeInfo;
|
||||
}
|
||||
|
||||
public String recordInputEvents()
|
||||
throws AndroidDebugBridgeDeviceException, InterruptedException, TimeoutException, ExecutionException {
|
||||
String out = runAdbShell(recordDuration * 2, "getevent", "&", "sleep", Integer.toString(recordDuration), "&&",
|
||||
"exit");
|
||||
var matcher = INPUT_EVENT_PATTERN.matcher(out);
|
||||
var commandList = new ArrayList<String>();
|
||||
try {
|
||||
while (matcher.find()) {
|
||||
String inputPath = matcher.group("input");
|
||||
int n1 = Integer.parseInt(matcher.group("n1"), 16);
|
||||
int n2 = Integer.parseInt(matcher.group("n2"), 16);
|
||||
int n3 = Integer.parseInt(matcher.group("n3"), 16);
|
||||
commandList.add(String.format("sendevent /%s %d %d %d", inputPath, n1, n2, n3));
|
||||
}
|
||||
} catch (NumberFormatException e) {
|
||||
logger.warn("NumberFormatException while parsing events, aborting");
|
||||
return "";
|
||||
}
|
||||
return String.join(" && ", commandList);
|
||||
}
|
||||
|
||||
public void sendInputEvents(String command)
|
||||
throws AndroidDebugBridgeDeviceException, InterruptedException, TimeoutException, ExecutionException {
|
||||
String out = runAdbShell(command.split(" "));
|
||||
if (out.length() != 0) {
|
||||
logger.warn("Device event unexpected output: {}", out);
|
||||
throw new AndroidDebugBridgeDeviceException("Device event execution fail");
|
||||
}
|
||||
}
|
||||
|
||||
public void rebootDevice()
|
||||
throws AndroidDebugBridgeDeviceException, InterruptedException, TimeoutException, ExecutionException {
|
||||
try {
|
||||
@@ -313,6 +400,11 @@ public class AndroidDebugBridgeDevice {
|
||||
|
||||
private String runAdbShell(String... args)
|
||||
throws InterruptedException, AndroidDebugBridgeDeviceException, TimeoutException, ExecutionException {
|
||||
return runAdbShell(timeoutSec, args);
|
||||
}
|
||||
|
||||
private String runAdbShell(int commandTimeout, String... args)
|
||||
throws InterruptedException, AndroidDebugBridgeDeviceException, TimeoutException, ExecutionException {
|
||||
var adb = connection;
|
||||
if (adb == null) {
|
||||
throw new AndroidDebugBridgeDeviceException("Device not connected");
|
||||
@@ -337,7 +429,7 @@ public class AndroidDebugBridgeDevice {
|
||||
return byteArrayOutputStream.toString(StandardCharsets.US_ASCII);
|
||||
});
|
||||
this.commandFuture = commandFuture;
|
||||
return commandFuture.get(timeoutSec, TimeUnit.SECONDS);
|
||||
return commandFuture.get(commandTimeout, TimeUnit.SECONDS);
|
||||
} finally {
|
||||
var commandFuture = this.commandFuture;
|
||||
if (commandFuture != null) {
|
||||
|
||||
@@ -129,7 +129,7 @@ public class AndroidDebugBridgeDiscoveryService extends AbstractDiscoveryService
|
||||
private void discoverWithADB(String ip, int port) throws InterruptedException, AndroidDebugBridgeDeviceException,
|
||||
AndroidDebugBridgeDeviceReadException, TimeoutException, ExecutionException {
|
||||
var device = new AndroidDebugBridgeDevice(scheduler);
|
||||
device.configure(ip, port, 10);
|
||||
device.configure(ip, port, 10, 0);
|
||||
try {
|
||||
device.connect();
|
||||
logger.debug("connected adb at {}:{}", ip, port);
|
||||
|
||||
@@ -20,6 +20,7 @@ import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.ScheduledFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
@@ -61,6 +62,7 @@ public class AndroidDebugBridgeHandler extends BaseThingHandler {
|
||||
private static final String SHUTDOWN_POWER_OFF = "POWER_OFF";
|
||||
private static final String SHUTDOWN_REBOOT = "REBOOT";
|
||||
private static final Gson GSON = new Gson();
|
||||
private static final Pattern RECORD_NAME_PATTERN = Pattern.compile("^[A-Za-z0-9_]*$");
|
||||
private final Logger logger = LoggerFactory.getLogger(AndroidDebugBridgeHandler.class);
|
||||
private final AndroidDebugBridgeDevice adbConnection;
|
||||
private int maxMediaVolume = 0;
|
||||
@@ -116,6 +118,9 @@ public class AndroidDebugBridgeHandler extends BaseThingHandler {
|
||||
case TAP_CHANNEL:
|
||||
adbConnection.sendTap(command.toFullString());
|
||||
break;
|
||||
case URL_CHANNEL:
|
||||
adbConnection.openUrl(command.toFullString());
|
||||
break;
|
||||
case MEDIA_VOLUME_CHANNEL:
|
||||
handleMediaVolume(channelUID, command);
|
||||
break;
|
||||
@@ -170,9 +175,51 @@ public class AndroidDebugBridgeHandler extends BaseThingHandler {
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.GONE, "Rebooting");
|
||||
break;
|
||||
}
|
||||
break;
|
||||
case RECORD_INPUT_CHANNEL:
|
||||
recordDeviceInput(command);
|
||||
break;
|
||||
case RECORDED_INPUT_CHANNEL:
|
||||
String recordName = getRecordPropertyName(command);
|
||||
var inputCommand = this.getThing().getProperties().get(recordName);
|
||||
if (inputCommand != null) {
|
||||
adbConnection.sendInputEvents(inputCommand);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void recordDeviceInput(Command recordNameCommand)
|
||||
throws AndroidDebugBridgeDeviceException, InterruptedException, TimeoutException, ExecutionException {
|
||||
var recordName = recordNameCommand.toFullString();
|
||||
if (!RECORD_NAME_PATTERN.matcher(recordName).matches()) {
|
||||
logger.warn("Invalid record name, accepts alphanumeric values with '_'.");
|
||||
return;
|
||||
}
|
||||
String recordPropertyName = getRecordPropertyName(recordName);
|
||||
logger.debug("RECORD: {}", recordPropertyName);
|
||||
var eventCommand = adbConnection.recordInputEvents();
|
||||
if (eventCommand.isEmpty()) {
|
||||
logger.debug("No events recorded");
|
||||
if (this.getThing().getProperties().containsKey(recordPropertyName)) {
|
||||
this.getThing().setProperty(recordPropertyName, null);
|
||||
updateProperties(editProperties());
|
||||
logger.debug("Record {} deleted", recordName);
|
||||
}
|
||||
} else {
|
||||
updateProperty(recordPropertyName, eventCommand);
|
||||
logger.debug("New record {}: {}", recordName, eventCommand);
|
||||
}
|
||||
}
|
||||
|
||||
private String getRecordPropertyName(String recordName) {
|
||||
return String.format("input-record:%s", recordName);
|
||||
}
|
||||
|
||||
private String getRecordPropertyName(Command recordNameCommand) {
|
||||
return getRecordPropertyName(recordNameCommand.toFullString());
|
||||
}
|
||||
|
||||
private void handleMediaVolume(ChannelUID channelUID, Command command)
|
||||
throws InterruptedException, AndroidDebugBridgeDeviceReadException, AndroidDebugBridgeDeviceException,
|
||||
TimeoutException, ExecutionException {
|
||||
@@ -264,7 +311,8 @@ public class AndroidDebugBridgeHandler extends BaseThingHandler {
|
||||
if (mediaStateJSONConfig != null && !mediaStateJSONConfig.isEmpty()) {
|
||||
loadMediaStateConfig(mediaStateJSONConfig);
|
||||
}
|
||||
adbConnection.configure(currentConfig.ip, currentConfig.port, currentConfig.timeout);
|
||||
adbConnection.configure(currentConfig.ip, currentConfig.port, currentConfig.timeout,
|
||||
currentConfig.recordDuration);
|
||||
updateStatus(ThingStatus.UNKNOWN);
|
||||
connectionCheckerSchedule = scheduler.scheduleWithFixedDelay(this::checkConnection, 0,
|
||||
currentConfig.refreshTime, TimeUnit.SECONDS);
|
||||
@@ -331,7 +379,8 @@ public class AndroidDebugBridgeHandler extends BaseThingHandler {
|
||||
awakeState = adbConnection.isAwake();
|
||||
deviceAwake = awakeState;
|
||||
} catch (TimeoutException e) {
|
||||
logger.warn("Unable to refresh awake state: Timeout");
|
||||
// happen a lot when device is sleeping; abort refresh other channels
|
||||
logger.debug("Unable to refresh awake state: Timeout; aborting channels refresh");
|
||||
return;
|
||||
}
|
||||
var awakeStateChannelUID = new ChannelUID(this.thing.getUID(), AWAKE_STATE_CHANNEL);
|
||||
@@ -339,6 +388,7 @@ public class AndroidDebugBridgeHandler extends BaseThingHandler {
|
||||
updateState(awakeStateChannelUID, OnOffType.from(awakeState));
|
||||
}
|
||||
if (!awakeState && !prevDeviceAwake) {
|
||||
// abort refresh channels while device is sleeping, throws many timeouts
|
||||
logger.debug("device {} is sleeping", config.ip);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
<channel id="key-event" typeId="key-event-channel"/>
|
||||
<channel id="text" typeId="text-channel"/>
|
||||
<channel id="tap" typeId="tap-channel"/>
|
||||
<channel id="url" typeId="url-channel"/>
|
||||
<channel id="record-input" typeId="record-input-channel"/>
|
||||
<channel id="recorded-input" typeId="recorded-input-channel"/>
|
||||
<channel id="media-volume" typeId="system.volume"/>
|
||||
<channel id="media-control" typeId="system.media-control"/>
|
||||
<channel id="start-package" typeId="start-package-channel"/>
|
||||
@@ -44,6 +47,11 @@
|
||||
<description>Command timeout seconds.</description>
|
||||
<default>5</default>
|
||||
</parameter>
|
||||
<parameter name="recordDuration" type="integer" required="true" min="3" max="20">
|
||||
<label>Record Duration</label>
|
||||
<description>How much time the record-input channel wait for events to record.</description>
|
||||
<default>5</default>
|
||||
</parameter>
|
||||
<parameter name="mediaStateJSONConfig" type="text">
|
||||
<label>Media State Config</label>
|
||||
<description>JSON config that allows to modify the media state detection strategy for each app. Refer to the binding
|
||||
@@ -363,6 +371,24 @@
|
||||
<description>Send tap event to android device</description>
|
||||
</channel-type>
|
||||
|
||||
<channel-type id="url-channel">
|
||||
<item-type>String</item-type>
|
||||
<label>Open Url</label>
|
||||
<description>Open url in the browser</description>
|
||||
</channel-type>
|
||||
|
||||
<channel-type id="record-input-channel">
|
||||
<item-type>String</item-type>
|
||||
<label>Record Input</label>
|
||||
<description>Record input events under provided name</description>
|
||||
</channel-type>
|
||||
|
||||
<channel-type id="recorded-input-channel">
|
||||
<item-type>String</item-type>
|
||||
<label>Recorded Input</label>
|
||||
<description>Send previous recorded input events by name</description>
|
||||
</channel-type>
|
||||
|
||||
<channel-type id="start-package-channel">
|
||||
<item-type>String</item-type>
|
||||
<label>Start Package</label>
|
||||
|
||||
Reference in New Issue
Block a user