diff --git a/pom.xml b/pom.xml index 498ecc6..9870a35 100644 --- a/pom.xml +++ b/pom.xml @@ -30,7 +30,7 @@ UTF-8 1.7 1.7 - 2.7.17 + 2.28.2 @@ -116,7 +116,7 @@ org.jacoco jacoco-maven-plugin - 0.7.7.201606060606 + 0.8.4 @@ -196,7 +196,7 @@ org.apache.maven.plugins maven-javadoc-plugin - 2.10.3 + 2.10.4 attach-javadocs diff --git a/src/se/vidstige/jadb/JadbDevice.java b/src/se/vidstige/jadb/JadbDevice.java index a16c648..7e56e25 100644 --- a/src/se/vidstige/jadb/JadbDevice.java +++ b/src/se/vidstige/jadb/JadbDevice.java @@ -57,7 +57,7 @@ public class JadbDevice { } } - private Transport getTransport() throws IOException, JadbException { + Transport getTransport() throws IOException, JadbException { Transport transport = transportFactory.createTransport(); // Do not use try-with-resources here. We want to return unclosed Transport and it is up to caller // to close it. Here we close it only in case of exception. @@ -116,6 +116,19 @@ public class JadbDevice { } } + /**

Execute a shell command.

+ * + *

This method supports separate stdin, stdout, and stderr streams, as well as a return code. The shell command + * is not executed until calling {@link ShellProcessBuilder#start()}, which returns a {@link Process}.

+ * + * @param command main command to run, e.g. "screencap" + * @param args arguments to the command, e.g. "-p". + * @return a {@link ShellProcessBuilder} + */ + public ShellProcessBuilder shellProcessBuilder(String command, String... args) { + return new ShellProcessBuilder(this, buildCmdLine(command, args).toString()); + } + /**

Execute a command with raw binary output.

* *

Support for this command was added in Lollipop (Android 5.0), and is the recommended way to transmit binary diff --git a/src/se/vidstige/jadb/ShellProcess.java b/src/se/vidstige/jadb/ShellProcess.java new file mode 100644 index 0000000..94f58f3 --- /dev/null +++ b/src/se/vidstige/jadb/ShellProcess.java @@ -0,0 +1,111 @@ +package se.vidstige.jadb; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.concurrent.*; + +public class ShellProcess extends Process { + + private static final int KILLED_STATUS_CODE = 9; + private final OutputStream outputStream; + private final InputStream inputStream; + private final InputStream errorStream; + private final Future exitCodeFuture; + private final ShellProtocolTransport transport; + private Integer exitCode = null; + + ShellProcess(OutputStream outputStream, InputStream inputStream, InputStream errorStream, Future exitCodeFuture, ShellProtocolTransport transport) { + this.outputStream = outputStream; + this.inputStream = inputStream; + this.errorStream = errorStream; + this.exitCodeFuture = exitCodeFuture; + this.transport = transport; + } + + @Override + public OutputStream getOutputStream() { + return outputStream; + } + + @Override + public InputStream getInputStream() { + return inputStream; + } + + @Override + public InputStream getErrorStream() { + return errorStream; + } + + @Override + public int waitFor() throws InterruptedException { + if (exitCode == null) { + try { + exitCode = exitCodeFuture.get(); + } catch (CancellationException e) { + exitCode = KILLED_STATUS_CODE; + } catch (ExecutionException e) { + throw new RuntimeException(e); + } + } + return exitCode; + } + + /* For 1.8 */ + public boolean waitFor(long timeout, TimeUnit unit) throws InterruptedException { + if (exitCode == null) { + try { + exitCode = exitCodeFuture.get(timeout, unit); + } catch (CancellationException e) { + exitCode = KILLED_STATUS_CODE; + } catch (ExecutionException e) { + throw new RuntimeException(e); + } catch (TimeoutException e) { + return false; + } + } + return true; + } + + @Override + public int exitValue() { + if (exitCode != null) { + return exitCode; + } + if (exitCodeFuture.isDone()) { + try { + exitCode = exitCodeFuture.get(0, TimeUnit.SECONDS); + return exitCode; + } catch (CancellationException e) { + exitCode = KILLED_STATUS_CODE; + } catch (ExecutionException e) { + throw new RuntimeException(e); + } catch (TimeoutException e) { + // fallthrough, but should never happen + } catch (InterruptedException e) { + // fallthrough, but should never happen + Thread.currentThread().interrupt(); + } + } + throw new IllegalThreadStateException(); + } + + @Override + public void destroy() { + if (isAlive()) { + try { + exitCodeFuture.cancel(true); + // interrupt (usually) doesn't work for blocking read -- this will cause SocketException + transport.close(); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + } + + /* For 1.8 */ + public boolean isAlive() { + return !exitCodeFuture.isDone(); + } +} \ No newline at end of file diff --git a/src/se/vidstige/jadb/ShellProcessBuilder.java b/src/se/vidstige/jadb/ShellProcessBuilder.java new file mode 100644 index 0000000..bc1fd26 --- /dev/null +++ b/src/se/vidstige/jadb/ShellProcessBuilder.java @@ -0,0 +1,242 @@ +package se.vidstige.jadb; + +import java.io.*; +import java.nio.file.Files; +import java.nio.file.StandardOpenOption; +import java.util.concurrent.*; + +/** + * A builder of a {@link Process} corresponding to an ADB shell command. + * + *

This builder allows for configuration of the {@link Process}'s output and error streams as well as the + * {@link Executor} to use when starting the shell process. The output and error streams may be either be redirected + * (using {@link java.lang.ProcessBuilder.Redirect}) or given an explicit {@link OutputStream}. You may also combine + * the output and error streams via {@link #redirectErrorStream(boolean) redirectErrorStream(true)}.

+ * + *

Use {@link #start()} to execute the command, and then use {@link Process#waitFor()} to wait for the command to + * complete.

+ * + *

Warning: If stdout and stderr are both set to {@link java.lang.ProcessBuilder.Redirect#PIPE} (the default), + * you must read from their InputStreams ({@link Process#getInputStream()} and {@link Process#getErrorStream()}) + * concurrently. This requires having two separate threads to read the input streams separately. Otherwise, + * the process may deadlock. To avoid using threads, you can use {@link #redirectErrorStream(boolean)}, in which case + * you must read all output from {@link Process#getInputStream()} before calling {@link Process#waitFor()}: + * + *

{@code
+ *   Process process = jadbDevice.shellProcessBuilder("command")
+ *       .redirectErrorStream(errorStream)
+ *       .start();
+ *   String stdoutAndStderr = new Scanner(process.getInputStream()).useDelimiter("\\A").next();
+ *   int exitCode = process.waitFor();
+ * }
+ *

+ * You can also use one of the {@code redirectOutput} methods to have the output automatically redirected. For example, + * to buffer all of stdout and stderr separately, you can use {@link java.io.ByteArrayOutputStream}: + * + *

{@code
+ *   ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
+ *   ByteArrayOutputStream errorStream = new ByteArrayOutputStream();
+ *   Process process = jadbDevice.shellProcessBuilder("command")
+ *       .redirectOutput(outputStream)
+ *       .redirectError(errorStream)
+ *       .start();
+ *   int exitCode = process.waitFor();
+ *   String stdout = outputStream.toString(StandardCharsets.UTF_8.name());
+ *   String stderr = errorStream.toString(StandardCharsets.UTF_8.name());
+ * }
+ */ +public class ShellProcessBuilder { + + private JadbDevice device; + private String command; + private ProcessBuilder.Redirect outRedirect = ProcessBuilder.Redirect.PIPE; + private OutputStream outOs = null; + private ProcessBuilder.Redirect errRedirect = ProcessBuilder.Redirect.PIPE; + private OutputStream errOs = null; + private boolean redirectErrorStream; + private Executor executor = null; + + ShellProcessBuilder(JadbDevice device, String command) { + this.device = device; + this.command = command; + } + + private void checkValidForWrite(ProcessBuilder.Redirect destination) { + if (destination.type() == ProcessBuilder.Redirect.Type.READ) { + throw new IllegalArgumentException("Redirect invalid for writing: " + destination); + } + } + + /** + * Redirect stdout to the given destination. If set to anything other than + * {@link java.lang.ProcessBuilder.Redirect#PIPE} (the default), {@link Process#getInputStream()} does nothing. + * + * @param destination where to redirect + * @return this + */ + public ShellProcessBuilder redirectOutput(ProcessBuilder.Redirect destination) { + checkValidForWrite(destination); + outRedirect = destination; + outOs = null; + return this; + } + + /** + * Redirect stdout directly to the given output stream. + *

Note: this output steam will be called from a separate thread.

+ * + * @param destination OutputStream to write + * @return this + */ + public ShellProcessBuilder redirectOutput(OutputStream destination) { + outRedirect = null; + outOs = destination; + return this; + } + + /** + * Redirect stderr to the given destination. If set to anything other than + * {@link java.lang.ProcessBuilder.Redirect#PIPE} (the default), {@link Process#getErrorStream()} does nothing. + * + * @param destination where to redirect + * @return this + */ + public ShellProcessBuilder redirectError(ProcessBuilder.Redirect destination) { + checkValidForWrite(destination); + errRedirect = destination; + errOs = null; + return this; + } + + /** + * Redirect stderr directly to the given output stream. + *

Note: this output steam will be called from a separate thread.

+ * + * @param destination OutputStream to write + * @return this + */ + public ShellProcessBuilder redirectError(OutputStream destination) { + errRedirect = null; + errOs = destination; + return this; + } + + /** + * Set redirecting of the error stream directly to the output stream. If set, any {@code redirectError} calls are + * ignored, and the returned Process + * + * @param redirectErrorStream true to enable redirecting of the error stream + * @return this + */ + public ShellProcessBuilder redirectErrorStream(boolean redirectErrorStream) { + this.redirectErrorStream = redirectErrorStream; + return this; + } + + /** + * Set the {@link Executor} to use to run the process handling thread. If not set, uses + * {@link Executors#newSingleThreadExecutor()}. + * + * @param executor An executor + * @return this + */ + public ShellProcessBuilder useExecutor(Executor executor) { + this.executor = executor; + return this; + } + + /** + * Starts the shell command. + * + * @return a {@link Process} + * @throws IOException + * @throws JadbException + */ + public ShellProcess start() throws IOException, JadbException { + Transport transport = null; + try { + final OutputStream outOs = getOutputStream(this.outOs, this.outRedirect, System.out); + InputStream outIs = getConnectedPipe(outOs); + + final OutputStream errOs; + InputStream errIs; + if (redirectErrorStream) { + errOs = outOs; + errIs = NullInputStream.INSTANCE; + } else { + errOs = getOutputStream(this.errOs, this.errRedirect, System.err); + errIs = getConnectedPipe(errOs); + } + + transport = device.getTransport(); + final ShellProtocolTransport shellProtocolTransport = transport.startShellProtocol(this.command); + OutputStream inOs = shellProtocolTransport.getOutputStream(); + + FutureTask transportTask = new FutureTask<>(new Callable() { + @Override + public Integer call() throws Exception { + try (ShellProtocolTransport unused1 = shellProtocolTransport; OutputStream unused2 = outOs; OutputStream unused3 = errOs) { + return shellProtocolTransport.demuxOutput(outOs, errOs); + } + } + }); + + if (executor == null) { + ExecutorService service = Executors.newSingleThreadExecutor(); + service.execute(transportTask); + service.shutdown(); + } else { + executor.execute(transportTask); + } + + return new ShellProcess(inOs, outIs, errIs, transportTask, shellProtocolTransport); + } catch (IOException | JadbException | RuntimeException e) { + if (transport != null) { + transport.close(); + } + throw e; + } + } + + private OutputStream getOutputStream(OutputStream os, ProcessBuilder.Redirect destination, OutputStream inherit) throws IOException { + if (os != null) { + return os; + } + switch (destination.type()) { + case PIPE: + return new PipedOutputStream(); + case INHERIT: + return inherit; + case READ: + throw new IllegalArgumentException("Redirect invalid for writing: " + destination); + case WRITE: + return Files.newOutputStream(destination.file().toPath()); + case APPEND: + return Files.newOutputStream(destination.file().toPath(), StandardOpenOption.CREATE, StandardOpenOption.APPEND, StandardOpenOption.WRITE); + default: + throw new IllegalArgumentException("Unknown redirect type: " + destination); + } + } + + private InputStream getConnectedPipe(OutputStream os) throws IOException { + if (os instanceof PipedOutputStream) { + return new PipedInputStream((PipedOutputStream) os); + } + return NullInputStream.INSTANCE; + } + + static class NullInputStream extends InputStream { + static final NullInputStream INSTANCE = new NullInputStream(); + + private NullInputStream() { + } + + public int read() { + return -1; + } + + public int available() { + return 0; + } + } +} \ No newline at end of file diff --git a/src/se/vidstige/jadb/ShellProtocolTransport.java b/src/se/vidstige/jadb/ShellProtocolTransport.java new file mode 100644 index 0000000..4ccd281 --- /dev/null +++ b/src/se/vidstige/jadb/ShellProtocolTransport.java @@ -0,0 +1,154 @@ +package se.vidstige.jadb; + +import java.io.*; + +class ShellProtocolTransport implements Closeable { + private final DataOutputStream output; + private final DataInputStream input; + + ShellProtocolTransport(DataOutputStream outputStream, DataInputStream inputStream) { + output = outputStream; + input = inputStream; + } + + // replace with Integer.toUnsignedLong in Java 8 + private static long integerToUnsignedLong(int i) { + return ((long) (int) i) & 0xffffffffL; + } + + // replace with Byte.toUnsignedInt in Java 8 + private static int byteToUnsignedInt(byte b) { + return ((int) b) & 0xff; + } + + private ShellMessageType readMessageType() throws IOException { + return ShellMessageType.fromId(input.readByte()); + } + + private long readDataLength() throws IOException { + return integerToUnsignedLong(Integer.reverseBytes(input.readInt())); + } + + private void readDataTo(OutputStream out, long dataLength, byte[] buffer) throws IOException { + long remaining = dataLength; + while (remaining > 0) { + int len = (int) Math.min(remaining, buffer.length); + input.readFully(buffer, 0, len); + out.write(buffer, 0, len); + remaining -= len; + } + out.flush(); + } + + int demuxOutput(OutputStream outputStream, OutputStream errorStream) throws JadbException, IOException { + int exitCode = 0; + byte[] buf = new byte[256 * 1024]; + + try { + while (true) { + ShellMessageType messageType = readMessageType(); + long length = readDataLength(); + switch (messageType) { + case STDOUT: + readDataTo(outputStream, length, buf); + break; + case STDERR: + readDataTo(errorStream, length, buf); + break; + case EXIT: + if (length != 1) { + throw new JadbException("Expected only one byte for exitCode"); + } + exitCode = byteToUnsignedInt(input.readByte()); + break; + default: + // ignore; + break; + } + } + } catch (EOFException e) { + // ignore + } + + return exitCode; + } + + private void writeData(ShellMessageType type, byte[] buf, int off, int len) throws IOException { + output.writeByte(byteToUnsignedInt(type.getId())); + output.writeInt(Integer.reverseBytes(len)); + output.write(buf, off, len); + } + + OutputStream getOutputStream() { + return new ShellProtocolOutputStream(this); + } + + @Override + public void close() throws IOException { + output.close(); + input.close(); + } + + enum ShellMessageType { + STDIN((byte) 0), STDOUT((byte) 1), STDERR((byte) 2), EXIT((byte) 3), CLOSE_STDIN((byte) 4), WINDOW_SIZE_CHANGE((byte) 5), UNKNOWN(Byte.MIN_VALUE); + + private final byte id; + + ShellMessageType(byte id) { + this.id = id; + } + + public static ShellMessageType fromId(byte b) { + switch (b) { + case 0: + return STDIN; + case 1: + return STDOUT; + case 2: + return STDERR; + case 3: + return EXIT; + case 4: + return CLOSE_STDIN; + case 5: + // unused + return WINDOW_SIZE_CHANGE; + default: + return UNKNOWN; + } + } + + public byte getId() { + return id; + } + } + + private static class ShellProtocolOutputStream extends OutputStream { + + private final ShellProtocolTransport transport; + + ShellProtocolOutputStream(ShellProtocolTransport transport) { + this.transport = transport; + } + + @Override + public void write(int b) throws IOException { + write(new byte[]{(byte) b}); + } + + @Override + public void write(byte[] b, int off, int len) throws IOException { + transport.writeData(ShellMessageType.STDIN, b, off, len); + } + + @Override + public void flush() throws IOException { + transport.output.flush(); + } + + @Override + public void close() throws IOException { + transport.writeData(ShellMessageType.CLOSE_STDIN, new byte[0], 0, 0); + } + } +} \ No newline at end of file diff --git a/src/se/vidstige/jadb/Transport.java b/src/se/vidstige/jadb/Transport.java index 72a828c..2d17b12 100644 --- a/src/se/vidstige/jadb/Transport.java +++ b/src/se/vidstige/jadb/Transport.java @@ -67,6 +67,12 @@ class Transport implements Closeable { return new SyncTransport(dataOutput, dataInput); } + public ShellProtocolTransport startShellProtocol(String command) throws IOException, JadbException { + send("shell,v2,raw:" + command); + verifyResponse(); + return new ShellProtocolTransport(dataOutput, dataInput); + } + @Override public void close() throws IOException { dataInput.close(); diff --git a/src/se/vidstige/jadb/server/AdbProtocolHandler.java b/src/se/vidstige/jadb/server/AdbProtocolHandler.java index dc27407..dc3b658 100644 --- a/src/se/vidstige/jadb/server/AdbProtocolHandler.java +++ b/src/se/vidstige/jadb/server/AdbProtocolHandler.java @@ -63,7 +63,7 @@ class AdbProtocolHandler implements Runnable { hostTransport(output, command); } else if ("sync:".equals(command)) { sync(output, input); - } else if (command.startsWith("shell:")) { + } else if (command.startsWith("shell")) { shell(input, output, command); return false; } else if ("host:get-state".equals(command)) { @@ -118,7 +118,7 @@ class AdbProtocolHandler implements Runnable { } private void shell(DataInput input, DataOutputStream output, String command) throws IOException { - String shellCommand = command.substring("shell:".length()); + String shellCommand = command.split(":", 2)[1]; output.writeBytes("OKAY"); shell(shellCommand, output, input); } diff --git a/test/se/vidstige/jadb/test/integration/RealDeviceTestCases.java b/test/se/vidstige/jadb/test/integration/RealDeviceTestCases.java index 9913e9d..8e05c87 100644 --- a/test/se/vidstige/jadb/test/integration/RealDeviceTestCases.java +++ b/test/se/vidstige/jadb/test/integration/RealDeviceTestCases.java @@ -1,31 +1,26 @@ package se.vidstige.jadb.test.integration; -import org.junit.Assert; -import org.junit.Before; -import org.junit.BeforeClass; -import org.junit.Rule; -import org.junit.Test; +import org.junit.*; import org.junit.rules.TemporaryFolder; import se.vidstige.jadb.*; -import java.io.ByteArrayOutputStream; -import java.io.File; -import java.io.FileOutputStream; -import java.io.IOException; -import java.io.InputStream; +import java.io.*; import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; import java.util.List; +import java.util.Scanner; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertTrue; +import static org.junit.Assert.*; public class RealDeviceTestCases { - private JadbConnection jadb; - @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder(); //Must be public + private JadbConnection jadb; @BeforeClass public static void tryToStartAdbServer() { @@ -99,13 +94,74 @@ public class RealDeviceTestCases { } @SuppressWarnings("deprecation") - @Test + @Test public void testShellExecuteTwice() throws Exception { JadbDevice any = jadb.getAnyDevice(); ByteArrayOutputStream bout = new ByteArrayOutputStream(); any.executeShell(bout, "ls /"); any.executeShell(bout, "ls", "-la", "/"); - System.out.write(bout.toByteArray()); + byte[] buf = bout.toByteArray(); + System.out.write(buf, 0, buf.length); + } + + @Test + public void testShellProcessBuilderStart() throws Exception { + JadbDevice any = jadb.getAnyDevice(); + Process process = any.shellProcessBuilder("ls /").start(); + AtomicReference stdout = new AtomicReference<>(); + AtomicReference stderr = new AtomicReference<>(); + Thread thread1 = gobbler(process.getInputStream(), stdout); + Thread thread2 = gobbler(process.getErrorStream(), stderr); + thread1.start(); + thread2.start(); + process.waitFor(); + thread1.join(); + thread2.join(); + System.out.println(stdout.get()); + System.out.println(stderr.get()); + } + + private Thread gobbler(final InputStream stream, final AtomicReference out) { + return new Thread(new Runnable() { + @Override + public void run() { + out.set(new Scanner(stream).useDelimiter("\\A").next()); + } + }); + } + + @Test + public void testShellExecuteProcessRedirectToOutputStream() throws Exception { + JadbDevice any = jadb.getAnyDevice(); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + ByteArrayOutputStream err = new ByteArrayOutputStream(); + Process process = any.shellProcessBuilder("ls /") + .redirectOutput(out) + .redirectError(err) + .start(); + process.waitFor(); + System.out.println(out.toString(StandardCharsets.UTF_8.name())); + System.out.println(err.toString(StandardCharsets.UTF_8.name())); + } + + @Test + public void testShellExecuteProcessRedirectErrorStream() throws Exception { + JadbDevice any = jadb.getAnyDevice(); + Process process = any.shellProcessBuilder("ls /").redirectErrorStream(true).start(); + String stdout = new Scanner(process.getInputStream()).useDelimiter("\\A").next(); + process.waitFor(); + System.out.println(stdout); + } + + @Test + public void testShellExecuteProcessDestroy() throws Exception { + JadbDevice anyDevice = jadb.getAnyDevice(); + ExecutorService executor = Executors.newSingleThreadExecutor(); + ShellProcess process = anyDevice.shellProcessBuilder("sleep 30").redirectErrorStream(true).useExecutor(executor).start(); + process.destroy(); + assertEquals(process.waitFor(), 9); + executor.shutdown(); + assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS)); } @Test @@ -135,11 +191,10 @@ public class RealDeviceTestCases { } /** - * @see #testConnectionToTcpDevice() - * * @throws IOException * @throws JadbException * @throws ConnectionToRemoteDeviceException + * @see #testConnectionToTcpDevice() */ @Test public void testDisconnectionToTcpDevice() throws IOException, JadbException, ConnectionToRemoteDeviceException { diff --git a/test/se/vidstige/jadb/test/unit/MockedTestCases.java b/test/se/vidstige/jadb/test/unit/MockedTestCases.java index 1a886a6..d316ee5 100644 --- a/test/se/vidstige/jadb/test/unit/MockedTestCases.java +++ b/test/se/vidstige/jadb/test/unit/MockedTestCases.java @@ -12,18 +12,55 @@ import se.vidstige.jadb.test.fakes.FakeAdbServer; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; +import java.io.DataOutputStream; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.text.DateFormat; import java.text.ParseException; import java.text.SimpleDateFormat; import java.util.List; +import java.util.Scanner; public class MockedTestCases { private FakeAdbServer server; private JadbConnection connection; + private static long parseDate(String date) throws ParseException { + DateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm"); + return dateFormat.parse(date).getTime(); + } + + private static void assertHasFile(String expPath, int expSize, long expModifyTime, List actualFiles) { + for (RemoteFile file : actualFiles) { + if (expPath.equals(file.getPath())) { + if (file.isDirectory()) { + Assert.fail("File " + expPath + " was listed as a dir!"); + } else if (expSize != file.getSize() || expModifyTime != file.getLastModified()) { + Assert.fail("File " + expPath + " exists but has incorrect properties!"); + } else { + return; + } + } + } + Assert.fail("File " + expPath + " could not be found!"); + } + + private static void assertHasDir(String expPath, long expModifyTime, List actualFiles) { + for (RemoteFile file : actualFiles) { + if (expPath.equals(file.getPath())) { + if (!file.isDirectory()) { + Assert.fail("Dir " + expPath + " was listed as a file!"); + } else if (expModifyTime != file.getLastModified()) { + Assert.fail("Dir " + expPath + " exists but has incorrect properties!"); + } else { + return; + } + } + } + Assert.fail("Dir " + expPath + " could not be found!"); + } + @Before public void setUp() throws Exception { server = new FakeAdbServer(15037); @@ -130,6 +167,39 @@ public class MockedTestCases { device.executeShell("echo", "h¡t]&poli"); } + @Test + public void testExecuteShellProtocol() throws Exception { + server.add("serial-123"); + server.expectShell("serial-123", "ls -l").returns(buildStream(null, null, 0)); + server.expectShell("serial-123", "ls foobar").returns(buildStream("123", "456", 0)); + JadbDevice device = connection.getDevices().get(0); + + Assert.assertEquals(device.shellProcessBuilder("ls", "-l").start().waitFor(), 0); + + Process process = device.shellProcessBuilder("ls", "foobar").redirectErrorStream(true).start(); + Assert.assertEquals(new Scanner(process.getInputStream()).useDelimiter("\\A").next(), "123456"); + Assert.assertEquals(process.waitFor(), 0); + } + + private String buildStream(String out, String err, int exitCode) throws Exception { + ByteArrayOutputStream os = new ByteArrayOutputStream(); + DataOutputStream dos = new DataOutputStream(os); + if (out != null) { + os.write(1); + dos.writeInt(Integer.reverseBytes(out.length())); + os.write(out.getBytes(StandardCharsets.US_ASCII)); + } + if (err != null) { + os.write(2); + dos.writeInt(Integer.reverseBytes(err.length())); + os.write(err.getBytes(StandardCharsets.US_ASCII)); + } + os.write(3); // exitcode stream + dos.writeInt(Integer.reverseBytes(1)); + os.write(exitCode); + return os.toString(StandardCharsets.US_ASCII.name()); + } + @Test public void testFileList() throws Exception { server.add("serial-123"); @@ -150,39 +220,4 @@ public class MockedTestCases { assertHasFile("effective java vol. 7.epub", 0xCAFE, 0xBABE, files); assertHasFile("\uB9AC\uADF8 \uC624\uBE0C \uB808\uC804\uB4DC", 240, 9001, files); } - - private static long parseDate(String date) throws ParseException { - DateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm"); - return dateFormat.parse(date).getTime(); - } - - private static void assertHasFile(String expPath, int expSize, long expModifyTime, List actualFiles) { - for (RemoteFile file : actualFiles) { - if (expPath.equals(file.getPath())) { - if (file.isDirectory()) { - Assert.fail("File " + expPath + " was listed as a dir!"); - } else if (expSize != file.getSize() || expModifyTime != file.getLastModified()) { - Assert.fail("File " + expPath + " exists but has incorrect properties!"); - } else { - return; - } - } - } - Assert.fail("File " + expPath + " could not be found!"); - } - - private static void assertHasDir(String expPath, long expModifyTime, List actualFiles) { - for (RemoteFile file : actualFiles) { - if (expPath.equals(file.getPath())) { - if (!file.isDirectory()) { - Assert.fail("Dir " + expPath + " was listed as a file!"); - } else if (expModifyTime != file.getLastModified()) { - Assert.fail("Dir " + expPath + " exists but has incorrect properties!"); - } else { - return; - } - } - } - Assert.fail("Dir " + expPath + " could not be found!"); - } }