diff --git a/libadb/src/main/java/io/github/muntashirakon/adb/AdbConnection.java b/libadb/src/main/java/io/github/muntashirakon/adb/AdbConnection.java index a21cb66..37b331c 100644 --- a/libadb/src/main/java/io/github/muntashirakon/adb/AdbConnection.java +++ b/libadb/src/main/java/io/github/muntashirakon/adb/AdbConnection.java @@ -17,6 +17,7 @@ import java.io.UnsupportedEncodingException; import java.net.ConnectException; import java.net.Socket; +import java.net.SocketTimeoutException; import java.security.PrivateKey; import java.security.cert.Certificate; import java.security.interfaces.RSAPublicKey; @@ -35,11 +36,8 @@ public class AdbConnection implements Closeable { public static final String TAG = AdbConnection.class.getSimpleName(); - /** - * The underlying socket that this class uses to communicate with the target device. - */ @NonNull - private final Socket mSocket; + private final AdbTransport mTransport; @NonNull private final String mHost; @@ -54,14 +52,14 @@ public class AdbConnection implements Closeable { private int mLastLocalId; /** - * The input stream that this class uses to read from the socket. + * The input stream that this class uses to read from the transport. */ @GuardedBy("lock") @NonNull private final InputStream mPlainInputStream; /** - * The output stream that this class uses to read from the socket. + * The output stream that this class uses to write to the transport. */ @GuardedBy("lock") @NonNull @@ -182,31 +180,66 @@ public static AdbConnection create(@NonNull String host, int port, @NonNull Priv @WorkerThread @NonNull static AdbConnection create(@NonNull String host, int port, @NonNull KeyPair keyPair, int api) throws IOException { - return new AdbConnection(host, port, keyPair, api); + return new AdbConnection(host, port, new SocketAdbTransport(host, port), keyPair, api); + } + + /** + * Creates an ADB connection over an already connected duplex transport. + * The returned connection owns {@code transport} and closes it from + * {@link #close()}. + */ + @WorkerThread + @NonNull + public static AdbConnection create(@NonNull AdbTransport transport, @NonNull PrivateKey privateKey, + @NonNull Certificate certificate) + throws IOException { + return create(transport, privateKey, certificate, Build.VERSION_CODES.BASE); + } + + /** + * Creates an ADB connection over an already connected duplex transport. + */ + @WorkerThread + @NonNull + public static AdbConnection create(@NonNull AdbTransport transport, @NonNull PrivateKey privateKey, + @NonNull Certificate certificate, int api) + throws IOException { + return new AdbConnection( + "transport", + 0, + Objects.requireNonNull(transport), + new KeyPair(Objects.requireNonNull(privateKey), Objects.requireNonNull(certificate)), + api + ); } /** * Internal constructor to initialize some internal state */ @WorkerThread - private AdbConnection(@NonNull String host, int port, @NonNull KeyPair keyPair, int api) throws IOException { + private AdbConnection(@NonNull String host, int port, @NonNull AdbTransport transport, + @NonNull KeyPair keyPair, int api) throws IOException { this.mHost = Objects.requireNonNull(host); this.mPort = port; this.mApi = api; this.mProtocolVersion = AdbProtocol.getProtocolVersion(mApi); this.mMaxData = AdbProtocol.getMaxData(api); this.mKeyPair = Objects.requireNonNull(keyPair); + this.mTransport = Objects.requireNonNull(transport); + InputStream plainInputStream; + OutputStream plainOutputStream; try { - this.mSocket = new Socket(host, port); - } catch (Throwable th) { - //noinspection UnnecessaryInitCause - throw (IOException) new IOException().initCause(th); + plainInputStream = Objects.requireNonNull(mTransport.getInputStream()); + plainOutputStream = Objects.requireNonNull(mTransport.getOutputStream()); + } catch (IOException | RuntimeException e) { + try { + mTransport.close(); + } catch (IOException ignored) { + } + throw e; } - this.mPlainInputStream = mSocket.getInputStream(); - this.mPlainOutputStream = mSocket.getOutputStream(); - - // Disable Nagle because we're sending tiny packets - mSocket.setTcpNoDelay(true); + this.mPlainInputStream = plainInputStream; + this.mPlainOutputStream = plainOutputStream; this.mOpenedStreams = new ConcurrentHashMap<>(); this.mLastLocalId = 0; @@ -236,7 +269,7 @@ private Thread createConnectionThread() { loop: while (!mConnectionThread.isInterrupted()) { try { - // Read and parse a message off the socket's input stream + // Read and parse a message from the transport. AdbProtocol.Message msg = AdbProtocol.Message.parse(getInputStream(), mProtocolVersion, mMaxData); switch (msg.command) { @@ -278,12 +311,16 @@ private Thread createConnectionThread() { break; } case AdbProtocol.A_STLS: { + if (!(mTransport instanceof SocketAdbTransport)) { + throw new IOException("ADB TLS upgrade requires a socket transport."); + } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.GINGERBREAD) { sendPacket(AdbProtocol.generateStls()); SSLContext sslContext = SslUtils.getSslContext(mKeyPair); + Socket socket = ((SocketAdbTransport) mTransport).getSocket(); SSLSocket tlsSocket = (SSLSocket) sslContext.getSocketFactory() - .createSocket(mSocket, mHost, mPort, true); + .createSocket(socket, mHost, mPort, true); tlsSocket.startHandshake(); Log.d(TAG, "Handshake succeeded."); @@ -409,10 +446,10 @@ public boolean isConnectionEstablished() { } /** - * Whether the underlying socket is connected to an ADB daemon and is not in a closed state. + * Whether the underlying transport is open. */ public boolean isConnected() { - return !mSocket.isClosed() && mSocket.isConnected(); + return mTransport.isOpen(); } /** @@ -420,7 +457,7 @@ public boolean isConnected() { * fails. * * @return {@code true} if the connection was established, or {@code false} if the connection timed out - * @throws IOException If the socket fails while connecting + * @throws IOException If the transport fails while connecting * @throws InterruptedException If timeout has reached * @throws AdbPairingRequiredException If ADB lacks pairing */ @@ -436,7 +473,7 @@ public boolean connect() throws IOException, InterruptedException, AdbPairingReq * @param throwOnUnauthorised Whether to throw an {@link AdbAuthenticationFailedException} * if the peer rejects out first authentication attempt * @return {@code true} if the connection was established, or {@code false} if the connection timed out - * @throws IOException If the socket fails while connecting + * @throws IOException If the transport fails while connecting * @throws InterruptedException If timeout has reached * @throws AdbAuthenticationFailedException If {@code throwOnUnauthorised} is {@code true} and the peer rejects the * first authentication attempt, which indicates that the peer has not @@ -482,6 +519,20 @@ public AdbStream open(@LocalServices.Services int service, @NonNull String... ar return open(LocalServices.getDestination(service, args)); } + /** + * Opens a service within a bounded timeout shared by connection and stream + * acknowledgement. + */ + @NonNull + public AdbStream open(@LocalServices.Services int service, long timeout, @NonNull TimeUnit unit, + @NonNull String... args) + throws IOException, InterruptedException, AdbPairingRequiredException { + if (service < LocalServices.SERVICE_FIRST || service > LocalServices.SERVICE_LAST) { + throw new IllegalArgumentException("Invalid service: " + service); + } + return open(LocalServices.getDestination(service, args), timeout, unit); + } + /** * Opens an AdbStream object corresponding to the specified destination. * This routine will block until the connection completes. @@ -496,13 +547,32 @@ public AdbStream open(@LocalServices.Services int service, @NonNull String... ar @NonNull public AdbStream open(@NonNull String destination) throws IOException, InterruptedException, AdbPairingRequiredException { - int localId = ++mLastLocalId; + return open(destination, Long.MAX_VALUE, TimeUnit.NANOSECONDS); + } + + /** + * Opens a destination within a bounded timeout shared by connection and + * stream acknowledgement. + * + * @throws SocketTimeoutException If the timeout expires before the peer + * acknowledges the stream. + */ + @NonNull + public AdbStream open(@NonNull String destination, long timeout, @NonNull TimeUnit unit) + throws IOException, InterruptedException, AdbPairingRequiredException { + final long deadlineNanos = deadlineNanos(timeout, Objects.requireNonNull(unit)); + final int localId; + synchronized (this) { + localId = ++mLastLocalId; + } if (!mConnectAttempted) { throw new IllegalStateException("connect() must be called first"); } - waitForConnection(Long.MAX_VALUE, TimeUnit.MILLISECONDS); + if (!waitForConnectionUntil(deadlineNanos)) { + throw new SocketTimeoutException("Timed out waiting for the ADB connection."); + } // Add this stream to this list of half-open streams AdbStream stream = new AdbStream(this, localId); @@ -512,8 +582,20 @@ public AdbStream open(@NonNull String destination) sendPacket(AdbProtocol.generateOpen(localId, Objects.requireNonNull(destination))); // Wait for the connection thread to receive the OKAY - synchronized (stream) { - stream.wait(); + try { + synchronized (stream) { + while (!stream.isOpenAcknowledged() && !stream.isClosed()) { + long remainingNanos = remainingNanos(deadlineNanos); + if (remainingNanos == 0) { + closeHalfOpenStream(localId, stream); + throw new SocketTimeoutException("Timed out waiting for the ADB stream to open."); + } + timedWait(stream, remainingNanos); + } + } + } catch (InterruptedException e) { + closeHalfOpenStream(localId, stream); + throw e; } // Check if the OPEN request was rejected @@ -527,11 +609,19 @@ public AdbStream open(@NonNull String destination) private boolean waitForConnection(long timeout, @NonNull TimeUnit unit) throws InterruptedException, IOException, AdbPairingRequiredException { + return waitForConnectionUntil(deadlineNanos(timeout, Objects.requireNonNull(unit))); + } + + private boolean waitForConnectionUntil(long deadlineNanos) + throws InterruptedException, IOException, AdbPairingRequiredException { synchronized (this) { // Block if a connection is pending, but not yet complete - long timeoutEndMillis = System.currentTimeMillis() + Objects.requireNonNull(unit).toMillis(timeout); - while (!mConnectionEstablished && mConnectAttempted && timeoutEndMillis - System.currentTimeMillis() > 0) { - wait(timeoutEndMillis - System.currentTimeMillis()); + while (!mConnectionEstablished && mConnectAttempted) { + long remainingNanos = remainingNanos(deadlineNanos); + if (remainingNanos == 0) { + return false; + } + timedWait(this, remainingNanos); } if (!mConnectionEstablished) { @@ -549,6 +639,7 @@ private boolean waitForConnection(long timeout, @NonNull TimeUnit unit) throw (AdbPairingRequiredException) (new AdbPairingRequiredException("ADB pairing is required.").initCause(connectionException)); } } + throw (IOException) new IOException("Connection failed").initCause(connectionException); } throw new IOException("Connection failed"); } @@ -558,6 +649,42 @@ private boolean waitForConnection(long timeout, @NonNull TimeUnit unit) return true; } + private void closeHalfOpenStream(int localId, @NonNull AdbStream stream) { + mOpenedStreams.remove(localId, stream); + try { + stream.close(); + } catch (IOException ignored) { + } + } + + private static long deadlineNanos(long timeout, @NonNull TimeUnit unit) { + if (timeout < 0) { + throw new IllegalArgumentException("timeout < 0"); + } + long now = System.nanoTime(); + long duration = unit.toNanos(timeout); + if (duration == Long.MAX_VALUE || now > Long.MAX_VALUE - duration) { + return Long.MAX_VALUE; + } + return now + duration; + } + + private static long remainingNanos(long deadlineNanos) { + if (deadlineNanos == Long.MAX_VALUE) { + return Long.MAX_VALUE; + } + return Math.max(0, deadlineNanos - System.nanoTime()); + } + + private static void timedWait(@NonNull Object monitor, long remainingNanos) + throws InterruptedException { + if (remainingNanos == Long.MAX_VALUE) { + monitor.wait(); + } else { + TimeUnit.NANOSECONDS.timedWait(monitor, remainingNanos); + } + } + /** * This function terminates all I/O on streams associated with this ADB connection */ @@ -573,14 +700,14 @@ private void cleanupStreams() { } /** - * This routine closes the Adb connection and underlying socket + * This routine closes the ADB connection and underlying transport. * - * @throws IOException if the socket fails to close + * @throws IOException if the transport fails to close */ @Override public void close() throws IOException { - // Closing the socket will kick the connection thread - mSocket.close(); + // Closing the transport must kick the connection thread. + mTransport.close(); // Wait for the connection thread to die mConnectionThread.interrupt(); @@ -618,6 +745,7 @@ public static class Builder { private Certificate mCertificate; private KeyPair mKeyPair; private String mDeviceName; + private AdbTransport mTransport; public Builder() { } @@ -643,6 +771,15 @@ public Builder setPort(int port) { return this; } + /** + * Use an already connected duplex transport instead of opening a TCP + * socket. The resulting connection owns and closes the transport. + */ + public Builder setTransport(@NonNull AdbTransport transport) { + this.mTransport = Objects.requireNonNull(transport); + return this; + } + /** * Set a name for the device. Default is “Unknown Device”. * @@ -687,9 +824,9 @@ Builder setKeyPair(KeyPair keyPair) { } /** - * Creates a new {@link AdbConnection} associated with the socket and crypto object specified. + * Creates a new {@link AdbConnection} using the configured transport and keys. * - * @throws IOException If there was an error while establishing a socket connection + * @throws IOException If there was an error while initializing the transport */ public AdbConnection build() throws IOException { if (mKeyPair == null) { @@ -698,7 +835,12 @@ public AdbConnection build() throws IOException { } mKeyPair = new KeyPair(mPrivateKey, mCertificate); } - AdbConnection adbConnection = create(mHost, mPort, mKeyPair, mApi); + AdbConnection adbConnection; + if (mTransport != null) { + adbConnection = new AdbConnection("transport", 0, mTransport, mKeyPair, mApi); + } else { + adbConnection = create(mHost, mPort, mKeyPair, mApi); + } if (mDeviceName != null) { adbConnection.setDeviceName(mDeviceName); } @@ -710,16 +852,21 @@ public AdbConnection build() throws IOException { * attempt fails. * * @return The underlying {@link AdbConnection} - * @throws IOException If the socket fails while connecting + * @throws IOException If the transport fails while connecting * @throws InterruptedException If timeout has reached * @throws AdbPairingRequiredException If ADB lacks pairing */ public AdbConnection connect() throws IOException, InterruptedException, AdbPairingRequiredException { AdbConnection adbConnection = build(); - if (adbConnection.connect()) { - throw new IOException("Unable to establish a new connection."); + try { + if (!adbConnection.connect()) { + throw new IOException("Unable to establish a new connection."); + } + return adbConnection; + } catch (IOException | InterruptedException | AdbPairingRequiredException | RuntimeException e) { + closeQuietly(adbConnection); + throw e; } - return adbConnection; } /** @@ -730,7 +877,7 @@ public AdbConnection connect() throws IOException, InterruptedException, AdbPair * @param throwOnUnauthorised Whether to throw an {@link AdbAuthenticationFailedException} * if the peer rejects out first authentication attempt * @return {@code true} if the connection was established, or {@code false} if the connection timed out - * @throws IOException If the socket fails while connecting + * @throws IOException If the transport fails while connecting * @throws InterruptedException If timeout has reached * @throws AdbAuthenticationFailedException If {@code throwOnUnauthorised} is {@code true} and the peer rejects * the first authentication attempt, which indicates that the peer has @@ -740,10 +887,22 @@ public AdbConnection connect() throws IOException, InterruptedException, AdbPair public AdbConnection connect(long timeout, @NonNull TimeUnit unit, boolean throwOnUnauthorised) throws IOException, InterruptedException, AdbPairingRequiredException { AdbConnection adbConnection = build(); - if (adbConnection.connect(timeout, unit, throwOnUnauthorised)) { - throw new IOException("Unable to establish a new connection."); + try { + if (!adbConnection.connect(timeout, unit, throwOnUnauthorised)) { + throw new IOException("Unable to establish a new connection."); + } + return adbConnection; + } catch (IOException | InterruptedException | AdbPairingRequiredException | RuntimeException e) { + closeQuietly(adbConnection); + throw e; + } + } + + private static void closeQuietly(@NonNull AdbConnection adbConnection) { + try { + adbConnection.close(); + } catch (IOException ignored) { } - return adbConnection; } } } diff --git a/libadb/src/main/java/io/github/muntashirakon/adb/AdbStream.java b/libadb/src/main/java/io/github/muntashirakon/adb/AdbStream.java index 6d24c06..117d921 100644 --- a/libadb/src/main/java/io/github/muntashirakon/adb/AdbStream.java +++ b/libadb/src/main/java/io/github/muntashirakon/adb/AdbStream.java @@ -112,6 +112,10 @@ void updateRemoteId(int remoteId) { this.mRemoteId = remoteId; } + boolean isOpenAcknowledged() { + return mRemoteId != 0; + } + /** * Called by the connection thread to indicate the stream is okay to send data. */ diff --git a/libadb/src/main/java/io/github/muntashirakon/adb/AdbTransport.java b/libadb/src/main/java/io/github/muntashirakon/adb/AdbTransport.java new file mode 100644 index 0000000..0749272 --- /dev/null +++ b/libadb/src/main/java/io/github/muntashirakon/adb/AdbTransport.java @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: BSD-3-Clause AND (GPL-3.0-or-later OR Apache-2.0) + +package io.github.muntashirakon.adb; + +import androidx.annotation.NonNull; + +import java.io.Closeable; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +/** + * A connected, full-duplex byte transport for ADB packets. + *

+ * Implementations own both streams. {@link #close()} must be idempotent and + * must unblock pending reads and writes. + */ +public interface AdbTransport extends Closeable { + @NonNull + InputStream getInputStream() throws IOException; + + @NonNull + OutputStream getOutputStream() throws IOException; + + /** + * Returns whether the transport can still perform I/O. + */ + boolean isOpen(); +} diff --git a/libadb/src/main/java/io/github/muntashirakon/adb/SocketAdbTransport.java b/libadb/src/main/java/io/github/muntashirakon/adb/SocketAdbTransport.java new file mode 100644 index 0000000..3656d1f --- /dev/null +++ b/libadb/src/main/java/io/github/muntashirakon/adb/SocketAdbTransport.java @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: BSD-3-Clause AND (GPL-3.0-or-later OR Apache-2.0) + +package io.github.muntashirakon.adb; + +import androidx.annotation.NonNull; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.Socket; + +final class SocketAdbTransport implements AdbTransport { + @NonNull + private final Socket mSocket; + + SocketAdbTransport(@NonNull String host, int port) throws IOException { + Socket socket = new Socket(host, port); + try { + // Disable Nagle because we're sending tiny packets. + socket.setTcpNoDelay(true); + } catch (IOException | RuntimeException e) { + try { + socket.close(); + } catch (IOException ignored) { + } + throw e; + } + mSocket = socket; + } + + @NonNull + Socket getSocket() { + return mSocket; + } + + @Override + @NonNull + public InputStream getInputStream() throws IOException { + return mSocket.getInputStream(); + } + + @Override + @NonNull + public OutputStream getOutputStream() throws IOException { + return mSocket.getOutputStream(); + } + + @Override + public boolean isOpen() { + return mSocket.isConnected() && !mSocket.isClosed(); + } + + @Override + public void close() throws IOException { + mSocket.close(); + } +} diff --git a/libadb/src/test/java/io/github/muntashirakon/adb/AdbConnectionTransportTest.java b/libadb/src/test/java/io/github/muntashirakon/adb/AdbConnectionTransportTest.java new file mode 100644 index 0000000..69ccc1d --- /dev/null +++ b/libadb/src/test/java/io/github/muntashirakon/adb/AdbConnectionTransportTest.java @@ -0,0 +1,354 @@ +// SPDX-License-Identifier: BSD-3-Clause AND (GPL-3.0-or-later OR Apache-2.0) + +package io.github.muntashirakon.adb; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.PipedInputStream; +import java.io.PipedOutputStream; +import java.net.SocketTimeoutException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.security.KeyPairGenerator; +import java.security.PublicKey; +import java.security.cert.Certificate; +import java.security.cert.CertificateEncodingException; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +public class AdbConnectionTransportTest { + @Test + public void builderAcceptsAndOwnsInjectedTransport() throws Exception { + PassiveTransport transport = new PassiveTransport(); + + AdbConnection connection = builder(transport).build(); + + assertTrue(connection.isConnected()); + connection.close(); + assertFalse(connection.isConnected()); + assertEquals(1, transport.closeCount); + } + + @Test + public void builderClosesTransportWhenStreamInitializationFails() throws Exception { + FailingInitializationTransport transport = new FailingInitializationTransport(); + try { + builder(transport).build(); + } catch (IOException expected) { + assertEquals(1, transport.closeCount); + return; + } + throw new AssertionError("Expected transport initialization to fail"); + } + + @Test + public void builderConnectReturnsEstablishedConnection() throws Exception { + ScriptedTransport transport = new ScriptedTransport(Script.CONNECT); + AdbConnection connection = null; + try { + connection = builder(transport).connect(1, TimeUnit.SECONDS, false); + + assertTrue(connection.isConnectionEstablished()); + } finally { + if (connection != null) { + connection.close(); + } else { + transport.close(); + } + } + } + + @Test + public void builderDefaultConnectKeepsItsUnboundedCompatibilityPath() throws Exception { + ScriptedTransport transport = new ScriptedTransport(Script.CONNECT); + AdbConnection connection = builder(transport).connect(); + try { + assertTrue(connection.isConnectionEstablished()); + } finally { + connection.close(); + } + } + + @Test + public void builderConnectClosesTransportAfterTimeout() throws Exception { + ScriptedTransport transport = new ScriptedTransport(Script.SILENT); + try { + builder(transport).connect(20, TimeUnit.MILLISECONDS, false); + } catch (IOException expected) { + assertFalse(transport.isOpen()); + return; + } + throw new AssertionError("Expected connection timeout"); + } + + @Test + public void boundedOpenReturnsAcknowledgedStream() throws Exception { + ScriptedTransport transport = new ScriptedTransport(Script.CONNECT_AND_ACK_OPEN); + AdbConnection connection = builder(transport).connect(1, TimeUnit.SECONDS, false); + try { + AdbStream stream = connection.open("tcp:8899", 1, TimeUnit.SECONDS); + assertFalse(stream.isClosed()); + stream.close(); + } finally { + connection.close(); + } + } + + @Test + public void openTimesOutWhenRemoteNeverAcknowledgesStream() throws Exception { + ScriptedTransport transport = new ScriptedTransport(Script.CONNECT_WITHOUT_OPEN_ACK); + AdbConnection connection = builder(transport).connect(1, TimeUnit.SECONDS, false); + long startedNanos = System.nanoTime(); + try { + connection.open("tcp:8899", 50, TimeUnit.MILLISECONDS); + } catch (SocketTimeoutException expected) { + long elapsedMillis = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedNanos); + assertTrue("open exceeded its timeout budget", elapsedMillis < 1_000); + return; + } finally { + connection.close(); + } + throw new AssertionError("Expected stream open to time out"); + } + + @Test + public void injectedTransportRejectsTlsUpgradeWithoutWritingStlsResponse() throws Exception { + ScriptedTransport transport = new ScriptedTransport(Script.REQUEST_TLS); + try { + builder(transport).connect(1, TimeUnit.SECONDS, false); + } catch (IOException expected) { + assertEquals( + "ADB TLS upgrade requires a socket transport.", + expected.getCause().getMessage() + ); + assertEquals(1, transport.clientWriteCount); + assertFalse(transport.isOpen()); + return; + } finally { + if (transport.isOpen()) { + transport.close(); + } + } + throw new AssertionError("Expected TLS upgrade to be rejected"); + } + + @Test + public void interruptingOpenClosesHalfOpenStream() throws Exception { + ScriptedTransport transport = new ScriptedTransport(Script.CONNECT_WITHOUT_OPEN_ACK); + AdbConnection connection = builder(transport).connect(1, TimeUnit.SECONDS, false); + AtomicReference failure = new AtomicReference<>(); + Thread opener = new Thread(() -> { + try { + connection.open("tcp:8899", 30, TimeUnit.SECONDS); + } catch (Throwable throwable) { + failure.set(throwable); + } + }); + try { + opener.start(); + assertTrue(transport.openSeen.await(1, TimeUnit.SECONDS)); + opener.interrupt(); + opener.join(1_000); + + assertFalse(opener.isAlive()); + assertTrue(failure.get() instanceof InterruptedException); + assertEquals(3, transport.clientWriteCount); + } finally { + connection.close(); + } + } + + private static AdbConnection.Builder builder(AdbTransport transport) throws Exception { + java.security.KeyPair keyPair = KeyPairGenerator.getInstance("RSA").generateKeyPair(); + return new AdbConnection.Builder() + .setTransport(transport) + .setPrivateKey(keyPair.getPrivate()) + .setCertificate(new PublicKeyCertificate(keyPair.getPublic())); + } + + private static final class PassiveTransport implements AdbTransport { + private final InputStream inputStream = new ByteArrayInputStream(new byte[0]); + private final OutputStream outputStream = new ByteArrayOutputStream(); + private boolean open = true; + private int closeCount; + + @Override + public InputStream getInputStream() { + return inputStream; + } + + @Override + public OutputStream getOutputStream() { + return outputStream; + } + + @Override + public boolean isOpen() { + return open; + } + + @Override + public void close() { + if (open) { + open = false; + ++closeCount; + } + } + } + + private static final class FailingInitializationTransport implements AdbTransport { + private int closeCount; + + @Override + public InputStream getInputStream() { + return new ByteArrayInputStream(new byte[0]); + } + + @Override + public OutputStream getOutputStream() throws IOException { + throw new IOException("output unavailable"); + } + + @Override + public boolean isOpen() { + return closeCount == 0; + } + + @Override + public void close() { + ++closeCount; + } + } + + private enum Script { + CONNECT, + CONNECT_AND_ACK_OPEN, + CONNECT_WITHOUT_OPEN_ACK, + REQUEST_TLS, + SILENT, + } + + private static final class ScriptedTransport implements AdbTransport { + private final PipedInputStream clientInput = new PipedInputStream(); + private final PipedOutputStream daemonOutput; + private final Script script; + private final OutputStream clientOutput = new OutputStream() { + @Override + public void write(int value) { + throw new UnsupportedOperationException(); + } + + @Override + public void write(byte[] bytes, int offset, int length) throws IOException { + ++clientWriteCount; + int command = ByteBuffer.wrap(bytes, offset, length) + .order(ByteOrder.LITTLE_ENDIAN) + .getInt(); + if (command == AdbProtocol.A_CNXN + && (script == Script.CONNECT + || script == Script.CONNECT_AND_ACK_OPEN + || script == Script.CONNECT_WITHOUT_OPEN_ACK)) { + daemonOutput.write(AdbProtocol.generateConnect(1)); + daemonOutput.flush(); + } else if (command == AdbProtocol.A_CNXN && script == Script.REQUEST_TLS) { + daemonOutput.write(AdbProtocol.generateMessage( + AdbProtocol.A_STLS, + AdbProtocol.A_STLS_VERSION, + 0, + null + )); + daemonOutput.flush(); + } else if (command == AdbProtocol.A_OPEN) { + openSeen.countDown(); + if (script == Script.CONNECT_AND_ACK_OPEN) { + int localId = ByteBuffer.wrap(bytes, offset + 4, length - 4) + .order(ByteOrder.LITTLE_ENDIAN) + .getInt(); + daemonOutput.write(AdbProtocol.generateMessage( + AdbProtocol.A_OKAY, + 42, + localId, + null + )); + daemonOutput.flush(); + } + } + } + }; + private boolean open = true; + private int clientWriteCount; + private final CountDownLatch openSeen = new CountDownLatch(1); + + private ScriptedTransport(Script script) throws IOException { + this.script = script; + daemonOutput = new PipedOutputStream(clientInput); + } + + @Override + public InputStream getInputStream() { + return clientInput; + } + + @Override + public OutputStream getOutputStream() { + return clientOutput; + } + + @Override + public boolean isOpen() { + return open; + } + + @Override + public void close() throws IOException { + if (!open) { + return; + } + open = false; + daemonOutput.close(); + clientInput.close(); + } + } + + private static final class PublicKeyCertificate extends Certificate { + private final PublicKey publicKey; + + private PublicKeyCertificate(PublicKey publicKey) { + super("test"); + this.publicKey = publicKey; + } + + @Override + public byte[] getEncoded() throws CertificateEncodingException { + return publicKey.getEncoded(); + } + + @Override + public void verify(PublicKey key) { + } + + @Override + public void verify(PublicKey key, String sigProvider) { + } + + @Override + public String toString() { + return "test"; + } + + @Override + public PublicKey getPublicKey() { + return publicKey; + } + } +}