diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f1868e..5822684 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +### 2.0.0 + +* **Breaking:** redesigned error model. Transport failures now throw a typed `AdbException` hierarchy (`AdbConnectException`, `AdbAuthException`, `AdbStreamOpenException`, `AdbConnectionClosedException`, `AdbTimeoutException`, `AdbProtocolException`), all extending `IOException`. +* **Breaking:** operation outcomes are now returned instead of thrown: `install`/`installMultiple` return `InstallResult`, `uninstall` returns `UninstallResult`, `push`/`pull` return `SyncResult`, `root`/`unroot` return `RootResult`. +* **Breaking:** a connection dropped mid-stream now throws `AdbConnectionClosedException` instead of presenting as a clean end-of-stream. +* **Breaking:** a read or write that exceeds its timeout now throws `AdbTimeoutException` instead of a raw `SocketTimeoutException`. +* `pmInstall` (the non-`cmd` install path) now checks the `pm install` result instead of ignoring it. +* Added opt-in result helpers — `onSuccess`/`onFailure`/`orThrow` on each `…Result` type. `orThrow` raises `AdbOperationFailedException`, which is **not** an `AdbException`, so a transport-level `catch (AdbException)` won't catch an opted-in operation-failure throw. + ### 1.2.10 * Creating configuration for enabling keep alive on dadb socket diff --git a/README.md b/README.md index 7e77433..6657bd9 100644 --- a/README.md +++ b/README.md @@ -20,12 +20,26 @@ Connect to `emulator-5554` and install `apkFile`: ```kotlin Dadb.create("localhost", 5555).use { dadb -> - dadb.install(apkFile) + when (val result = dadb.install(apkFile)) { + is InstallResult.Success -> println("installed") + is InstallResult.Failure -> println("install failed: ${result.reason}") + } } ``` *Note: Connect to the odd adb daemon port (5555), not the even emulator console port (5554)* +### Error handling + +dadb distinguishes *transport* failures from *operation* failures: + +- **Transport failures throw** an `AdbException` (an `IOException`): `AdbConnectException`, + `AdbAuthException`, `AdbStreamOpenException`, `AdbConnectionClosedException`, `AdbProtocolException`. + Catch `AdbException` to decide whether to reconnect. +- **Operation outcomes are returned**, never thrown: `install`/`installMultiple` → `InstallResult`, + `uninstall` → `UninstallResult`, `push`/`pull` → `SyncResult`, `root`/`unroot` → `RootResult`. + A non-zero `shell` exit code stays a value on `AdbShellResponse`. + ### Discover a Device The following discovers and returns a connected device or emulator. If there are multiple it returns the first one found. diff --git a/dadb/src/main/kotlin/dadb/AdbConnection.kt b/dadb/src/main/kotlin/dadb/AdbConnection.kt index 2b2c67e..d71eaab 100644 --- a/dadb/src/main/kotlin/dadb/AdbConnection.kt +++ b/dadb/src/main/kotlin/dadb/AdbConnection.kt @@ -25,6 +25,7 @@ import org.jetbrains.annotations.TestOnly import java.io.Closeable import java.io.IOException import java.net.Socket +import java.net.SocketTimeoutException import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicInteger @@ -40,7 +41,7 @@ internal class AdbConnection internal constructor( private val nextLocalId = AtomicInteger(0) private val messageQueue = AdbMessageQueue(adbReader) - @Throws(IOException::class) + @Throws(AdbException::class) fun open(destination: String): AdbStream { val localId = newId() messageQueue.startListening(localId) @@ -49,6 +50,19 @@ internal class AdbConnection internal constructor( val message = messageQueue.take(localId, Constants.CMD_OKAY) val remoteId = message.arg0 return AdbStreamImpl(messageQueue, adbWriter, maxPayloadSize, localId, remoteId) + } catch (e: AdbStreamClosed) { + // adbd answered A_OPEN with A_CLSE: it refused this service. Connection is still alive. + messageQueue.stopListening(localId) + throw AdbStreamOpenException(destination, "adbd refused to open stream: $destination", e) + } catch (e: AdbException) { + // Already a typed transport fault — don't re-wrap. + messageQueue.stopListening(localId) + throw e + } catch (e: IOException) { + // Raw socket fault while opening: a timeout (adbd unresponsive) vs the connection dying. + messageQueue.stopListening(localId) + throw if (e is SocketTimeoutException) AdbTimeoutException("Timed out opening stream: $destination", e) + else AdbConnectionClosedException("Connection lost while opening stream: $destination", e) } catch (e: Throwable) { messageQueue.stopListening(localId) throw e @@ -101,12 +115,20 @@ internal class AdbConnection internal constructor( return connect(source, sink, keyPair, socket) } - private fun connect(source: Source, sink: Sink, keyPair: AdbKeyPair? = null, closeable: Closeable? = null): AdbConnection { + internal fun connect(source: Source, sink: Sink, keyPair: AdbKeyPair? = null, closeable: Closeable? = null): AdbConnection { val adbReader = AdbReader(source) val adbWriter = AdbWriter(sink) try { return connect(adbReader, adbWriter, keyPair, closeable) + } catch (e: AdbException) { + adbReader.close() + adbWriter.close() + throw e + } catch (e: IOException) { + adbReader.close() + adbWriter.close() + throw AdbConnectException("Connection handshake failed", e) } catch (t: Throwable) { adbReader.close() adbWriter.close() @@ -120,8 +142,8 @@ internal class AdbConnection internal constructor( var message = adbReader.readMessage() if (message.command == Constants.CMD_AUTH) { - checkNotNull(keyPair) { "Authentication required but no KeyPair provided" } - check(message.arg0 == Constants.AUTH_TYPE_TOKEN) { "Unsupported auth type: $message" } + if (keyPair == null) throw AdbAuthException("Authentication required but no key pair was provided") + if (message.arg0 != Constants.AUTH_TYPE_TOKEN) throw AdbProtocolException("Unsupported auth type: $message") val signature = keyPair.signPayload(message) adbWriter.writeAuth(Constants.AUTH_TYPE_SIGNATURE, signature) @@ -133,7 +155,11 @@ internal class AdbConnection internal constructor( } } - if (message.command != Constants.CMD_CNXN) throw IOException("Connection failed: $message") + if (message.command != Constants.CMD_CNXN) { + // A trailing AUTH means the device rejected our key / stayed unauthorized. + if (message.command == Constants.CMD_AUTH) throw AdbAuthException("Device rejected authentication (unauthorized)") + throw AdbConnectException("Connection failed: $message") + } val connectionString = parseConnectionString(String(message.payload)) val version = message.arg0 @@ -149,7 +175,7 @@ internal class AdbConnection internal constructor( .map { it.split("=") } .mapNotNull { if (it.size != 2) null else it[0] to it[1] } .toMap() - if ("features" !in keyValues) throw IOException("Failed to parse features from connection string: $connectionString") + if ("features" !in keyValues) throw AdbConnectException("Failed to parse features from connection string: $connectionString") val features = keyValues.getValue("features").split(",").toSet() return ConnectionString(features) } diff --git a/dadb/src/main/kotlin/dadb/AdbException.kt b/dadb/src/main/kotlin/dadb/AdbException.kt new file mode 100644 index 0000000..2f3d957 --- /dev/null +++ b/dadb/src/main/kotlin/dadb/AdbException.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2021 mobile.dev inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package dadb + +import java.io.IOException + +/** + * Root of all dadb transport/connection failures. Extends [IOException] because these are genuine, + * recoverable socket I/O faults, consistent with the okio stream surface dadb exposes. Every + * transport fault thrown from a public method is one of these subtypes — no bare IOException leaks. + */ +sealed class AdbException(message: String, cause: Throwable? = null) : IOException(message, cause) + +/** Could not establish the connection: TCP connect/timeout, or the A_CNXN handshake failed. + * Nothing ran — safe to retry by reconnecting. */ +class AdbConnectException(message: String, cause: Throwable? = null) : AdbException(message, cause) + +/** A_AUTH was rejected (no key, or device left UNAUTHORIZED). Reconnecting with the same key will + * not help — fix or accept the key. */ +class AdbAuthException(message: String, cause: Throwable? = null) : AdbException(message, cause) + +/** adbd refused to OPEN the stream (A_OPEN answered with A_CLSE): unknown/forbidden service string, + * or device offline. The underlying connection is still usable. */ +class AdbStreamOpenException( + val destination: String, + message: String, + cause: Throwable? = null +) : AdbException(message, cause) + +/** An established connection/stream died unexpectedly (socket EOF/RST mid-operation). Reconnect — + * but the operation MAY have partially executed; re-check state before retrying non-idempotent work. */ +class AdbConnectionClosedException(message: String, cause: Throwable? = null) : AdbException(message, cause) + +/** An operation exceeded its deadline with adbd unresponsive: a read past socketTimeout, or a write + * past the write timeout (okio closes the socket on expiry). Distinct from a peer-driven EOF/RST. + * Reconnect; like a dropped connection the operation may have partially executed. */ +class AdbTimeoutException(message: String, cause: Throwable? = null) : AdbException(message, cause) + +/** The peer sent a malformed or unexpected apacket (desync). No reliable way to re-sync — the + * connection must be closed and re-established. */ +class AdbProtocolException(message: String, cause: Throwable? = null) : AdbException(message, cause) diff --git a/dadb/src/main/kotlin/dadb/AdbOperationFailedException.kt b/dadb/src/main/kotlin/dadb/AdbOperationFailedException.kt new file mode 100644 index 0000000..6bc71f1 --- /dev/null +++ b/dadb/src/main/kotlin/dadb/AdbOperationFailedException.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2021 mobile.dev inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package dadb + +/** + * Thrown by the opt-in `orThrow()` helpers when an operation returned a `Failure`. This is an + * *operation outcome* surfaced as an exception by caller choice — the transport is healthy. + * + * It is deliberately **not** an [AdbException]: a `catch (AdbException)` that exists to reconnect on + * transport death will not catch this, so the two axes (transport died vs. operation rejected) stay + * distinct even for callers who opt into the throwing convenience. Catch it explicitly when you use + * `orThrow()`. + * + * [exitCode] is populated only for operations whose failure carries one (e.g. `uninstall`). + */ +class AdbOperationFailedException( + val reason: String, + val exitCode: Int? = null, +) : RuntimeException( + if (exitCode != null) "Operation failed (exit $exitCode): $reason" else "Operation failed: $reason" +) diff --git a/dadb/src/main/kotlin/dadb/AdbStream.kt b/dadb/src/main/kotlin/dadb/AdbStream.kt index dfec53c..ff3277e 100644 --- a/dadb/src/main/kotlin/dadb/AdbStream.kt +++ b/dadb/src/main/kotlin/dadb/AdbStream.kt @@ -19,6 +19,7 @@ package dadb import okio.* import java.lang.Integer.min +import java.net.SocketTimeoutException import java.nio.ByteBuffer interface AdbStream : AutoCloseable { @@ -117,9 +118,16 @@ internal class AdbStreamImpl internal constructor( private fun nextMessage(command: Int): AdbMessage? { return try { messageQueue.take(localId, command) + } catch (e: AdbStreamClosed) { + // Peer sent A_CLSE: normal end-of-stream (e.g. shell/exec output finished). EOF is correct. + close() + null } catch (e: IOException) { + // Mid-stream fault: a timeout (adbd unresponsive) vs the transport dying. Either way the + // stream is done. close() - return null + throw if (e is SocketTimeoutException) AdbTimeoutException("Read timed out on stream $localId; device unresponsive", e) + else AdbConnectionClosedException("Connection lost while reading stream $localId", e) } } diff --git a/dadb/src/main/kotlin/dadb/AdbSync.kt b/dadb/src/main/kotlin/dadb/AdbSync.kt index 821145d..14e1762 100644 --- a/dadb/src/main/kotlin/dadb/AdbSync.kt +++ b/dadb/src/main/kotlin/dadb/AdbSync.kt @@ -22,6 +22,11 @@ import java.io.IOException import java.nio.charset.StandardCharsets import kotlin.jvm.Throws +/** Internal signal that an adbd sync operation returned FAIL (e.g. file not found / permission + * denied). Caught by Dadb.push/pull and folded into a [SyncResult.Failure]; never surfaced raw + * from the high-level API. Not an AdbException — the transport is healthy. */ +internal class AdbSyncFailException(val reason: String) : IOException(reason) + internal const val LIST = "LIST" internal const val RECV = "RECV" internal const val SEND = "SEND" @@ -67,7 +72,11 @@ class AdbSyncStream( stream.sink.flush() val packet = readPacket() - if (packet.id != OKAY) throw IOException("Unexpected sync packet id: ${packet.id}") + if (packet.id == FAIL) { + val message = stream.source.readString(packet.arg.toLong(), StandardCharsets.UTF_8) + throw AdbSyncFailException(message) + } + if (packet.id != OKAY) throw AdbProtocolException("Unexpected sync packet id: ${packet.id}") } @Throws(IOException::class) @@ -85,9 +94,9 @@ class AdbSyncStream( if (packet.id == DONE) break if (packet.id == FAIL) { val message = stream.source.readString(packet.arg.toLong(), StandardCharsets.UTF_8) - throw IOException("Sync failed: $message") + throw AdbSyncFailException(message) } - if (packet.id != DATA) throw IOException("Unexpected sync packet id: ${packet.id}") + if (packet.id != DATA) throw AdbProtocolException("Unexpected sync packet id: ${packet.id}") val chunkSize = packet.arg stream.source.readFully(buffer, chunkSize.toLong()) buffer.readAll(sink) diff --git a/dadb/src/main/kotlin/dadb/AdbWriter.kt b/dadb/src/main/kotlin/dadb/AdbWriter.kt index ef5dbcf..830e5df 100644 --- a/dadb/src/main/kotlin/dadb/AdbWriter.kt +++ b/dadb/src/main/kotlin/dadb/AdbWriter.kt @@ -19,6 +19,8 @@ package dadb import okio.Sink import okio.buffer +import java.io.IOException +import java.net.SocketTimeoutException import java.nio.ByteBuffer internal class AdbWriter(sink: Sink) : AutoCloseable { @@ -73,24 +75,32 @@ internal class AdbWriter(sink: Sink) : AutoCloseable { length: Int ) { log { "(${Thread.currentThread().name}) > ${AdbMessage(command, arg0, arg1, length, 0, 0, payload ?: ByteArray(0))}" } - synchronized(bufferedSink) { - bufferedSink.apply { - writeIntLe(command) - writeIntLe(arg0) - writeIntLe(arg1) - if (payload == null) { - writeIntLe(0) - writeIntLe(0) - } else { - writeIntLe(length) - writeIntLe(payloadChecksum(payload)) + try { + synchronized(bufferedSink) { + bufferedSink.apply { + writeIntLe(command) + writeIntLe(arg0) + writeIntLe(arg1) + if (payload == null) { + writeIntLe(0) + writeIntLe(0) + } else { + writeIntLe(length) + writeIntLe(payloadChecksum(payload)) + } + writeIntLe(command xor -0x1) + if (payload != null) { + write(payload, offset, length) + } + flush() } - writeIntLe(command xor -0x1) - if (payload != null) { - write(payload, offset, length) - } - flush() } + } catch (e: IOException) { + // A failed socket write means the transport is gone. A timeout is a stall (adbd stopped + // draining); anything else is a dropped connection. This is the single funnel for every + // write, so no raw IOException leaks out either way. + throw if (e is SocketTimeoutException) AdbTimeoutException("Write timed out; device unresponsive", e) + else AdbConnectionClosedException("Connection lost while writing to device", e) } } diff --git a/dadb/src/main/kotlin/dadb/Dadb.kt b/dadb/src/main/kotlin/dadb/Dadb.kt index 2b3e157..0fd68ba 100644 --- a/dadb/src/main/kotlin/dadb/Dadb.kt +++ b/dadb/src/main/kotlin/dadb/Dadb.kt @@ -25,110 +25,119 @@ import okio.* interface Dadb : AutoCloseable { - @Throws(IOException::class) + @Throws(AdbException::class) fun open(destination: String): AdbStream fun supportsFeature(feature: String): Boolean - @Throws(IOException::class) + @Throws(AdbException::class) fun shell(command: String): AdbShellResponse { openShell(command).use { stream -> return stream.readAll() } } - @Throws(IOException::class) + @Throws(AdbException::class) fun openShell(command: String = ""): AdbShellStream { val stream = open("shell,v2,raw:$command") return AdbShellStream(stream) } - @Throws(IOException::class) - fun push(src: File, remotePath: String, mode: Int = readMode(src), lastModifiedMs: Long = src.lastModified()) { - push(src.source(), remotePath, mode, lastModifiedMs) + @Throws(AdbException::class) + fun push(src: File, remotePath: String, mode: Int = readMode(src), lastModifiedMs: Long = src.lastModified()): SyncResult { + return push(src.source(), remotePath, mode, lastModifiedMs) } - @Throws(IOException::class) - fun push(source: Source, remotePath: String, mode: Int, lastModifiedMs: Long) { + @Throws(AdbException::class) + fun push(source: Source, remotePath: String, mode: Int, lastModifiedMs: Long): SyncResult { openSync().use { stream -> - stream.send(source, remotePath, mode, lastModifiedMs) + return try { + stream.send(source, remotePath, mode, lastModifiedMs) + SyncResult.Success + } catch (e: AdbSyncFailException) { + SyncResult.Failure(e.reason) + } } } - @Throws(IOException::class) - fun pull(dst: File, remotePath: String) { - pull(dst.sink(append = false), remotePath) + @Throws(AdbException::class) + fun pull(dst: File, remotePath: String): SyncResult { + return pull(dst.sink(append = false), remotePath) } - @Throws(IOException::class) - fun pull(sink: Sink, remotePath: String) { + @Throws(AdbException::class) + fun pull(sink: Sink, remotePath: String): SyncResult { openSync().use { stream -> - stream.recv(sink, remotePath) + return try { + stream.recv(sink, remotePath) + SyncResult.Success + } catch (e: AdbSyncFailException) { + SyncResult.Failure(e.reason) + } } } - @Throws(IOException::class) + @Throws(AdbException::class) fun openSync(): AdbSyncStream { val stream = open("sync:") return AdbSyncStream(stream) } - @Throws(IOException::class) - fun install(file: File, vararg options: String) { - if (supportsFeature("cmd")) { + @Throws(AdbException::class) + fun install(file: File, vararg options: String): InstallResult { + return if (supportsFeature("cmd")) { install(file.source(), file.length(), *options) } else { pmInstall(file, *options) } } - @Throws(IOException::class) - fun install(source: Source, size: Long, vararg options: String) { + @Throws(AdbException::class) + fun install(source: Source, size: Long, vararg options: String): InstallResult { if (supportsFeature("cmd")) { execCmd("package", "install", "-S", size.toString(), *options).use { stream -> stream.sink.writeAll(source) stream.sink.flush() val response = stream.source.readString(Charsets.UTF_8) - if (!response.startsWith("Success")) { - throw IOException("Install failed: $response") - } + return if (response.startsWith("Success")) InstallResult.Success else InstallResult.Failure(response) } } else { val tempFile = kotlin.io.path.createTempFile() val fileSink = tempFile.sink().buffer() fileSink.writeAll(source) fileSink.flush() - pmInstall(tempFile.toFile(), *options) + return pmInstall(tempFile.toFile(), *options) } } - private fun pmInstall(file: File, vararg options: String) { - val fileName = file.name - val remotePath = "/data/local/tmp/$fileName" - push(file, remotePath) - shell("pm install ${options.joinToString(" ")} \"$remotePath\"") + private fun pmInstall(file: File, vararg options: String): InstallResult { + val remotePath = "/data/local/tmp/${file.name}" + when (val pushResult = push(file, remotePath)) { + is SyncResult.Failure -> return InstallResult.Failure("push failed: ${pushResult.reason}") + SyncResult.Success -> Unit + } + val response = shell("pm install ${options.joinToString(" ")} \"$remotePath\"") + return if (response.allOutput.startsWith("Success")) InstallResult.Success else InstallResult.Failure(response.allOutput) } - @Throws(IOException::class) - fun installMultiple(apks: List, vararg options: String) { + @Throws(AdbException::class) + fun installMultiple(apks: List, vararg options: String): InstallResult { // http://aospxref.com/android-12.0.0_r3/xref/packages/modules/adb/client/adb_install.cpp#538 if (supportsFeature("cmd")) { - val totalLength = apks.map { it.length() }.reduce { acc, l -> acc + l } + val totalLength = apks.map { it.length() }.reduce { acc, l -> acc + l } execCmd("package", "install-create", "-S", totalLength.toString(), *options).use { createStream -> val response = createStream.source.readString(Charsets.UTF_8) - if (!response.startsWith("Success")) { - throw IOException("connect error for create: $response") - } + if (!response.startsWith("Success")) return InstallResult.Failure("create session failed: $response") + val pattern = """\[(\w+)]""".toRegex() - val sessionId = pattern.find(response)?.groups?.get(1)?.value ?: throw IOException("failed to create session") + val sessionId = pattern.find(response)?.groups?.get(1)?.value + ?: return InstallResult.Failure("failed to parse session id: $response") var error: String? = null - apks.forEach { apk-> - // install write every apk file to stream - execCmd("package", "install-write", "-S", apk.length().toString(), sessionId, apk.name, "-", *options).use { writeStream-> + apks.forEach { apk -> + execCmd("package", "install-write", "-S", apk.length().toString(), sessionId, apk.name, "-", *options).use { writeStream -> writeStream.sink.writeAll(apk.source()) writeStream.sink.flush() - val writeResponse = writeStream.source.readString(Charsets.UTF_8) if (!writeResponse.startsWith("Success")) { error = writeResponse @@ -137,48 +146,36 @@ interface Dadb : AutoCloseable { } } - // commit the session val finalCommand = if (error == null) "install-commit" else "install-abandon" - execCmd("package", finalCommand, sessionId, *options).use { commitStream-> + execCmd("package", finalCommand, sessionId, *options).use { commitStream -> val finalResponse = commitStream.source.readString(Charsets.UTF_8) - if (!finalResponse.startsWith("Success")) { - throw IOException("failed to finalize session: $commitStream") - } + if (!finalResponse.startsWith("Success")) return InstallResult.Failure("failed to finalize session: $finalResponse") } - if (error != null) { - throw IOException("Install failed: $error") - } + return if (error == null) InstallResult.Success else InstallResult.Failure(error!!) } } else { - val totalLength = apks.map { it.length() }.reduce { acc, l -> acc + l } - // step1: create session + val totalLength = apks.map { it.length() }.reduce { acc, l -> acc + l } val response = shell("pm install-create -S $totalLength ${options.joinToString(" ")}") - if (!response.allOutput.startsWith("Success")) { - throw IOException("pm create session failed: $response") - } + if (!response.allOutput.startsWith("Success")) return InstallResult.Failure("pm create session failed: ${response.allOutput}") val pattern = """\[(\w+)]""".toRegex() - val sessionId = pattern.find(response.allOutput)?.groups?.get(1)?.value ?: throw IOException("failed to create session") + val sessionId = pattern.find(response.allOutput)?.groups?.get(1)?.value + ?: return InstallResult.Failure("failed to parse session id: ${response.allOutput}") var error: String? = null val fileNames = apks.map { it.name } val remotePaths = fileNames.map { "/data/local/tmp/$it" } - // step2: write apk to the session apks.zip(remotePaths).forEachIndexed { index, pair -> val apk = pair.first val remotePath = pair.second - try { - // we should push the apk files to device, when push failed, it would stop the installation - push(apk, remotePath) - } catch (t: IOException) { - error = t.message - return@forEachIndexed + when (val pushResult = push(apk, remotePath)) { + is SyncResult.Failure -> { error = "push failed: ${pushResult.reason}"; return@forEachIndexed } + SyncResult.Success -> Unit } - // pm install-write -S APK_SIZE SESSION_ID INDEX PATH val writeResponse = shell("pm install-write -S ${apk.length()} $sessionId $index $remotePath") if (!writeResponse.allOutput.startsWith("Success")) { error = writeResponse.allOutput @@ -186,56 +183,48 @@ interface Dadb : AutoCloseable { } } - // step3: commit or abandon the session val finalCommand = if (error == null) "pm install-commit $sessionId" else "pm install-abandon $sessionId" val finalResponse = shell(finalCommand) - if (!finalResponse.allOutput.startsWith("Success")) { - throw IOException("failed to finalize session: $finalResponse") - } - if (error != null) { - throw IOException("Install failed: $error"); - } + if (!finalResponse.allOutput.startsWith("Success")) return InstallResult.Failure("failed to finalize session: ${finalResponse.allOutput}") + return if (error == null) InstallResult.Success else InstallResult.Failure(error!!) } } - @Throws(IOException::class) - fun uninstall(packageName: String) { + @Throws(AdbException::class) + fun uninstall(packageName: String): UninstallResult { val response = shell("cmd package uninstall $packageName") - if (response.exitCode != 0) { - throw IOException("Uninstall failed: ${response.allOutput}") + return if (response.exitCode == 0) { + UninstallResult.Success + } else { + UninstallResult.Failure(reason = response.allOutput, exitCode = response.exitCode) } } - @Throws(IOException::class) + @Throws(AdbException::class) fun execCmd(vararg command: String): AdbStream { if (!supportsFeature("cmd")) throw UnsupportedOperationException("cmd is not supported on this version of Android") val destination = (listOf("exec:cmd") + command).joinToString(" ") return open(destination) } - @Throws(IOException::class) + @Throws(AdbException::class) fun abbExec(vararg command: String): AdbStream { if (!supportsFeature("abb_exec")) throw UnsupportedOperationException("abb_exec is not supported on this version of Android") val destination = "abb_exec:${command.joinToString("\u0000")}" return open(destination) } - @Throws(IOException::class) - fun root() { - val response = restartAdb(this, "root:") - if (!response.startsWith("restarting") && !response.contains("already")) { - throw IOException("Failed to restart adb as root: $response") - } - waitRootOrClose(this, root = true) - } + @Throws(AdbException::class) + fun root(): RootResult = restartAdbd("root:", root = true) { it.startsWith("restarting") || it.contains("already") } - @Throws(IOException::class) - fun unroot() { - val response = restartAdb(this, "unroot:") - if (!response.startsWith("restarting") && !response.contains("not running as root")) { - throw IOException("Failed to restart adb as root: $response") - } - waitRootOrClose(this, root = false) + @Throws(AdbException::class) + fun unroot(): RootResult = restartAdbd("unroot:", root = false) { it.startsWith("restarting") || it.contains("not running as root") } + + private fun restartAdbd(service: String, root: Boolean, isSuccess: (String) -> Boolean): RootResult { + val response = restartAdb(this, service) + if (!isSuccess(response)) return RootResult.Failure(response) + waitRootOrClose(this, root) + return RootResult.Success } @Throws(InterruptedException::class) diff --git a/dadb/src/main/kotlin/dadb/DadbImpl.kt b/dadb/src/main/kotlin/dadb/DadbImpl.kt index 83857c8..419eba8 100644 --- a/dadb/src/main/kotlin/dadb/DadbImpl.kt +++ b/dadb/src/main/kotlin/dadb/DadbImpl.kt @@ -18,6 +18,7 @@ package dadb import org.jetbrains.annotations.TestOnly +import java.io.IOException import java.net.InetSocketAddress import java.net.Socket import kotlin.jvm.Throws @@ -87,7 +88,13 @@ internal class DadbImpl @Throws(IllegalArgumentException::class) constructor( val socketAddress = InetSocketAddress(host, port) val socket = Socket() socket.soTimeout = socketTimeout - socket.connect(socketAddress, connectTimeout) + try { + socket.connect(socketAddress, connectTimeout) + } catch (e: IOException) { + // A failed TCP connect (refused / unreachable / connect timeout) means nothing was + // established. Surface it as a typed AdbException, not a raw java.net exception. + throw AdbConnectException("Failed to connect to $host:$port", e) + } if (keepAlive) { socket.keepAlive = true } diff --git a/dadb/src/main/kotlin/dadb/InstallResult.kt b/dadb/src/main/kotlin/dadb/InstallResult.kt new file mode 100644 index 0000000..9078000 --- /dev/null +++ b/dadb/src/main/kotlin/dadb/InstallResult.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2021 mobile.dev inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package dadb + +/** Outcome of [Dadb.install] / [Dadb.installMultiple]. */ +sealed interface InstallResult { + object Success : InstallResult + /** Raw pm/`cmd package` verdict, surfaced opaquely — exactly as the canonical adb client does + * (client/adb_install.cpp checks `strncmp("Success", buf, 7)` and prints the rest verbatim). + * `INSTALL_FAILED_*` codes are a frameworks/base concept, not the adbd contract, so they are + * not parsed into an enum. */ + data class Failure(val reason: String) : InstallResult +} + +/** Run [block] if this is a [InstallResult.Success]; returns the receiver for chaining. */ +inline fun InstallResult.onSuccess(block: () -> Unit): InstallResult { + if (this is InstallResult.Success) block() + return this +} + +/** Run [block] if this is a [InstallResult.Failure]; returns the receiver for chaining. */ +inline fun InstallResult.onFailure(block: (InstallResult.Failure) -> Unit): InstallResult { + if (this is InstallResult.Failure) block(this) + return this +} + +/** Fail-fast: throw [AdbOperationFailedException] if this is a [InstallResult.Failure]. */ +fun InstallResult.orThrow() { + if (this is InstallResult.Failure) throw AdbOperationFailedException(reason) +} diff --git a/dadb/src/main/kotlin/dadb/RootResult.kt b/dadb/src/main/kotlin/dadb/RootResult.kt new file mode 100644 index 0000000..33a54dd --- /dev/null +++ b/dadb/src/main/kotlin/dadb/RootResult.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2021 mobile.dev inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package dadb + +/** Outcome of [Dadb.root] / [Dadb.unroot]. [Failure.reason] is the adbd root:/unroot: response line. */ +sealed interface RootResult { + object Success : RootResult + data class Failure(val reason: String) : RootResult +} + +/** Run [block] if this is a [RootResult.Success]; returns the receiver for chaining. */ +inline fun RootResult.onSuccess(block: () -> Unit): RootResult { + if (this is RootResult.Success) block() + return this +} + +/** Run [block] if this is a [RootResult.Failure]; returns the receiver for chaining. */ +inline fun RootResult.onFailure(block: (RootResult.Failure) -> Unit): RootResult { + if (this is RootResult.Failure) block(this) + return this +} + +/** Fail-fast: throw [AdbOperationFailedException] if this is a [RootResult.Failure]. */ +fun RootResult.orThrow() { + if (this is RootResult.Failure) throw AdbOperationFailedException(reason) +} diff --git a/dadb/src/main/kotlin/dadb/SyncResult.kt b/dadb/src/main/kotlin/dadb/SyncResult.kt new file mode 100644 index 0000000..d311cce --- /dev/null +++ b/dadb/src/main/kotlin/dadb/SyncResult.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2021 mobile.dev inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package dadb + +/** Outcome of [Dadb.push] / [Dadb.pull]. [Failure.reason] is the adbd sync FAIL message. */ +sealed interface SyncResult { + object Success : SyncResult + data class Failure(val reason: String) : SyncResult +} + +/** Run [block] if this is a [SyncResult.Success]; returns the receiver for chaining. */ +inline fun SyncResult.onSuccess(block: () -> Unit): SyncResult { + if (this is SyncResult.Success) block() + return this +} + +/** Run [block] if this is a [SyncResult.Failure]; returns the receiver for chaining. */ +inline fun SyncResult.onFailure(block: (SyncResult.Failure) -> Unit): SyncResult { + if (this is SyncResult.Failure) block(this) + return this +} + +/** Fail-fast: throw [AdbOperationFailedException] if this is a [SyncResult.Failure]. */ +fun SyncResult.orThrow() { + if (this is SyncResult.Failure) throw AdbOperationFailedException(reason) +} diff --git a/dadb/src/main/kotlin/dadb/UninstallResult.kt b/dadb/src/main/kotlin/dadb/UninstallResult.kt new file mode 100644 index 0000000..adb9c42 --- /dev/null +++ b/dadb/src/main/kotlin/dadb/UninstallResult.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2021 mobile.dev inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package dadb + +/** Outcome of [Dadb.uninstall]. `uninstall` is `cmd package uninstall` over the shell service, so + * [Failure] preserves adbd's actual process exit code and combined output — the wrapper hides nothing. */ +sealed interface UninstallResult { + object Success : UninstallResult + data class Failure(val reason: String, val exitCode: Int) : UninstallResult +} + +/** Run [block] if this is a [UninstallResult.Success]; returns the receiver for chaining. */ +inline fun UninstallResult.onSuccess(block: () -> Unit): UninstallResult { + if (this is UninstallResult.Success) block() + return this +} + +/** Run [block] if this is a [UninstallResult.Failure]; returns the receiver for chaining. */ +inline fun UninstallResult.onFailure(block: (UninstallResult.Failure) -> Unit): UninstallResult { + if (this is UninstallResult.Failure) block(this) + return this +} + +/** Fail-fast: throw [AdbOperationFailedException] (carrying the process [exitCode]) if this is a + * [UninstallResult.Failure]. */ +fun UninstallResult.orThrow() { + if (this is UninstallResult.Failure) throw AdbOperationFailedException(reason, exitCode) +} diff --git a/dadb/src/test/kotlin/dadb/AdbConnectionHandshakeTest.kt b/dadb/src/test/kotlin/dadb/AdbConnectionHandshakeTest.kt new file mode 100644 index 0000000..50404e6 --- /dev/null +++ b/dadb/src/test/kotlin/dadb/AdbConnectionHandshakeTest.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2021 mobile.dev inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package dadb + +import okio.Buffer +import kotlin.test.Test +import kotlin.test.assertFailsWith + +internal class AdbConnectionHandshakeTest { + + @Test + fun nonCnxnResponseThrowsConnectException() { + // Device replies with an unexpected OKAY instead of CNXN. + val device = Buffer() + AdbWriter(device).writeOkay(0, 0) + assertFailsWith { + AdbConnection.connect(device, Buffer(), null) + } + } + + @Test + fun authRequiredWithoutKeyPairThrowsAuthException() { + // Device demands auth; we have no key pair. + val device = Buffer() + AdbWriter(device).writeAuth(Constants.AUTH_TYPE_TOKEN, ByteArray(20)) + assertFailsWith { + AdbConnection.connect(device, Buffer(), null) + } + } + + @Test + fun truncatedHandshakeThrowsConnectException() { + // Empty device stream: the first readMessage hits EOF. + assertFailsWith { + AdbConnection.connect(Buffer(), Buffer(), null) + } + } +} diff --git a/dadb/src/test/kotlin/dadb/AdbExceptionTest.kt b/dadb/src/test/kotlin/dadb/AdbExceptionTest.kt new file mode 100644 index 0000000..ddd546a --- /dev/null +++ b/dadb/src/test/kotlin/dadb/AdbExceptionTest.kt @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2021 mobile.dev inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package dadb + +import com.google.common.truth.Truth.assertThat +import java.io.IOException +import java.net.ServerSocket +import kotlin.test.Test +import kotlin.test.assertFailsWith + +internal class AdbExceptionTest { + + @Test + fun allTypesAreIOExceptions() { + val exceptions: List = listOf( + AdbConnectException("x"), + AdbAuthException("x"), + AdbStreamOpenException("shell:", "x"), + AdbConnectionClosedException("x"), + AdbTimeoutException("x"), + AdbProtocolException("x"), + ) + exceptions.forEach { assertThat(it).isInstanceOf(IOException::class.java) } + } + + @Test + fun streamOpenExceptionCarriesDestination() { + val e = AdbStreamOpenException("exec:cmd package install", "refused") + assertThat(e.destination).isEqualTo("exec:cmd package install") + } + + @Test + fun preservesCause() { + val cause = IOException("socket reset") + assertThat(AdbConnectException("x", cause).cause).isSameInstanceAs(cause) + } + + @Test + fun refusedConnectThrowsAdbConnectException() { + // Bind an ephemeral port then release it: nothing is listening, so the TCP connect is refused. + val closedPort = ServerSocket(0).use { it.localPort } + val dadb = DadbImpl(host = "localhost", port = closedPort, connectTimeout = 1000, socketTimeout = 1000) + + // A refused connect must surface as the typed AdbConnectException, not a raw java.net exception. + val thrown = assertFailsWith { dadb.shell("echo hi") } + assertThat(thrown.cause).isNotNull() + } +} diff --git a/dadb/src/test/kotlin/dadb/AdbStreamTest.kt b/dadb/src/test/kotlin/dadb/AdbStreamTest.kt index a027269..16ed778 100644 --- a/dadb/src/test/kotlin/dadb/AdbStreamTest.kt +++ b/dadb/src/test/kotlin/dadb/AdbStreamTest.kt @@ -19,7 +19,11 @@ package dadb import com.google.common.truth.Truth import okio.Buffer +import okio.Source +import okio.Timeout +import java.net.SocketTimeoutException import kotlin.test.Test +import kotlin.test.assertFailsWith internal class AdbStreamTest { @@ -33,9 +37,51 @@ internal class AdbStreamTest { Truth.assertThat(stream.source.readByteArray()).isEqualTo(payload) } + @Test + fun peerCloseIsCleanEof() { + // Reader yields a CLSE for localId=1 and nothing else. + val buffer = Buffer() + AdbWriter(buffer).write(Constants.CMD_CLSE, 2, 1, null, 0, 0) + val messageQueue = AdbMessageQueue(AdbReader(buffer)) + messageQueue.startListening(1) + val stream = AdbStreamImpl(messageQueue, AdbWriter(Buffer()), 1024, 1, 2) + + // A clean peer close reads as EOF (empty), NOT an exception. + Truth.assertThat(stream.source.readByteArray()).isEqualTo(ByteArray(0)) + } + + @Test + fun socketFaultThrowsConnectionClosed() { + // Empty reader: the next read hits EOF on the socket itself (not a protocol CLSE). + val messageQueue = AdbMessageQueue(AdbReader(Buffer())) + messageQueue.startListening(1) + val stream = AdbStreamImpl(messageQueue, AdbWriter(Buffer()), 1024, 1, 2) + + assertFailsWith { stream.source.readByteArray() } + } + + @Test + fun readTimeoutThrowsAdbTimeout() { + // Reader whose socket read trips SO_TIMEOUT instead of returning data or EOF: a stall, which + // surfaces as AdbTimeoutException rather than AdbConnectionClosedException. + val timingOutReader = AdbReader(object : Source { + override fun read(sink: Buffer, byteCount: Long): Long = throw SocketTimeoutException("Read timed out") + override fun timeout(): Timeout = Timeout.NONE + override fun close() {} + }) + val messageQueue = AdbMessageQueue(timingOutReader) + messageQueue.startListening(1) + val stream = AdbStreamImpl(messageQueue, AdbWriter(Buffer()), 1024, 1, 2) + + assertFailsWith { stream.source.readByteArray() } + } + private fun createAdbReader(localId: Int, remoteId: Int, writePayload: ByteArray): AdbReader { val source = Buffer() - AdbWriter(source).write(Constants.CMD_WRTE, remoteId, localId, writePayload, 0, writePayload.size) + val writer = AdbWriter(source) + writer.write(Constants.CMD_WRTE, remoteId, localId, writePayload, 0, writePayload.size) + // Append a proper A_CLSE so the stream ends cleanly, matching real device behaviour. + writer.write(Constants.CMD_CLSE, remoteId, localId, null, 0, 0) return AdbReader(source) } -} \ No newline at end of file +} diff --git a/dadb/src/test/kotlin/dadb/AdbWriterTest.kt b/dadb/src/test/kotlin/dadb/AdbWriterTest.kt new file mode 100644 index 0000000..09fc3d4 --- /dev/null +++ b/dadb/src/test/kotlin/dadb/AdbWriterTest.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2021 mobile.dev inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package dadb + +import com.google.common.truth.Truth.assertThat +import okio.Buffer +import okio.Sink +import okio.Timeout +import java.io.IOException +import java.net.SocketTimeoutException +import kotlin.test.Test +import kotlin.test.assertFailsWith + +internal class AdbWriterTest { + + // A sink that fails every write the way okio's socket write-timeout does on a wedged adbd. + private class ThrowingSink(private val error: IOException) : Sink { + override fun write(source: Buffer, byteCount: Long) { throw error } + override fun flush() {} + override fun timeout(): Timeout = Timeout.NONE + override fun close() {} + } + + @Test + fun payloadWrite_onWriteTimeout_surfacesAsAdbTimeout() { + // The wedged-push path: AdbStream.sink.flush() -> writeWrite(...). okio's write timeout on a + // stalled adbd is a timeout, not a dropped connection, so it surfaces as AdbTimeoutException. + val cause = SocketTimeoutException("timeout") + val writer = AdbWriter(ThrowingSink(cause)) + + val thrown = assertFailsWith { + writer.writeWrite(localId = 1, remoteId = 2, payload = ByteArray(8), offset = 0, length = 8) + } + assertThat(thrown.cause).isSameInstanceAs(cause) + } + + @Test + fun ackWrite_onNonTimeoutFault_surfacesAsConnectionClosed() { + // A non-timeout write fault (e.g. a broken pipe) is a dropped connection, not a stall, so it + // surfaces as AdbConnectionClosedException. Either way no raw IOException leaves the funnel. + val writer = AdbWriter(ThrowingSink(IOException("broken pipe"))) + + assertFailsWith { + writer.writeOkay(localId = 1, remoteId = 2) + } + } +} diff --git a/dadb/src/test/kotlin/dadb/DadbResultTest.kt b/dadb/src/test/kotlin/dadb/DadbResultTest.kt new file mode 100644 index 0000000..31e7b8c --- /dev/null +++ b/dadb/src/test/kotlin/dadb/DadbResultTest.kt @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2021 mobile.dev inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package dadb + +import com.google.common.truth.Truth.assertThat +import okio.Buffer +import kotlin.test.Test +import kotlin.test.assertFailsWith + +internal class DadbResultTest { + + @Test + fun syncRecvFailThrowsSyncFailException() { + val stream = FakeAdbStream(syncFailBuffer("No such file or directory")) + val sync = AdbSyncStream(stream) + val e = assertFailsWith { sync.recv(Buffer(), "/missing") } + assertThat(e.reason).isEqualTo("No such file or directory") + } + + @Test + fun syncRecvUnexpectedPacketThrowsProtocolException() { + // adbd sends a packet id that is neither DATA, DONE, nor FAIL — a protocol desync. + val bogus = Buffer().also { it.writeUtf8("OKAY"); it.writeIntLe(0) } + val sync = AdbSyncStream(FakeAdbStream(bogus)) + assertFailsWith { sync.recv(Buffer(), "/somewhere") } + } + + @Test + fun pullMissingFileReturnsSyncFailure() { + val dadb = FakeDadb { FakeAdbStream(syncFailBuffer("No such file or directory")) } + val result = dadb.pull(Buffer(), "/missing") + assertThat(result).isInstanceOf(SyncResult.Failure::class.java) + assertThat((result as SyncResult.Failure).reason).isEqualTo("No such file or directory") + } + + @Test + fun pushSuccessReturnsSyncSuccess() { + val dadb = FakeDadb { FakeAdbStream(syncOkayBuffer()) } + val result = dadb.push(Buffer().also { it.writeUtf8("hi") }, "/data/local/tmp/hi", 0b110_100_100, 0L) + assertThat(result).isInstanceOf(SyncResult.Success::class.java) + } + + @Test + fun uninstallNonZeroExitReturnsFailureWithExitCode() { + val dadb = FakeDadb { + FakeAdbStream(shellV2Buffer(stdout = "Failure [DELETE_FAILED_INTERNAL_ERROR]\n", exitCode = 1)) + } + val result = dadb.uninstall("com.example.absent") + assertThat(result).isInstanceOf(UninstallResult.Failure::class.java) + result as UninstallResult.Failure + assertThat(result.exitCode).isEqualTo(1) + assertThat(result.reason).contains("DELETE_FAILED_INTERNAL_ERROR") + } + + @Test + fun uninstallZeroExitReturnsSuccess() { + val dadb = FakeDadb { FakeAdbStream(shellV2Buffer(stdout = "Success\n", exitCode = 0)) } + assertThat(dadb.uninstall("com.example")).isInstanceOf(UninstallResult.Success::class.java) + } + + @Test + fun installCmdPathFailureReturnsInstallFailure() { + val dadb = FakeDadb(features = setOf("cmd")) { + FakeAdbStream(Buffer().also { it.writeUtf8("Failure [INSTALL_FAILED_INVALID_APK]") }) + } + val result = dadb.install(Buffer().also { it.writeUtf8("apk") }, 3L) + assertThat(result).isInstanceOf(InstallResult.Failure::class.java) + assertThat((result as InstallResult.Failure).reason).contains("INSTALL_FAILED_INVALID_APK") + } + + @Test + fun installCmdPathSuccessReturnsSuccess() { + val dadb = FakeDadb(features = setOf("cmd")) { + FakeAdbStream(Buffer().also { it.writeUtf8("Success\n") }) + } + val result = dadb.install(Buffer().also { it.writeUtf8("apk") }, 3L) + assertThat(result).isInstanceOf(InstallResult.Success::class.java) + } + + @Test + fun rootFailureReturnsRootFailure() { + val dadb = FakeDadb { + FakeAdbStream(Buffer().also { it.writeUtf8("adbd cannot run as root in production builds\n") }) + } + val result = dadb.root() + assertThat(result).isInstanceOf(RootResult.Failure::class.java) + assertThat((result as RootResult.Failure).reason).contains("production builds") + } +} diff --git a/dadb/src/test/kotlin/dadb/DadbTest.kt b/dadb/src/test/kotlin/dadb/DadbTest.kt index a656b26..f3ccab1 100644 --- a/dadb/src/test/kotlin/dadb/DadbTest.kt +++ b/dadb/src/test/kotlin/dadb/DadbTest.kt @@ -126,10 +126,10 @@ internal abstract class DadbTest : BaseConcurrencyTest() { val content = randomString() val source = ByteArrayInputStream(content.toByteArray()).source() - dadb.push(source, remotePath, 439, System.currentTimeMillis()) + assertThat(dadb.push(source, remotePath, 439, System.currentTimeMillis())).isInstanceOf(SyncResult.Success::class.java) val buffer = Buffer() - dadb.pull(buffer, remotePath) + assertThat(dadb.pull(buffer, remotePath)).isInstanceOf(SyncResult.Success::class.java) val pulledContent = buffer.readString(StandardCharsets.UTF_8) assertThat(pulledContent).isEqualTo(content) @@ -146,10 +146,10 @@ internal abstract class DadbTest : BaseConcurrencyTest() { dadb.shell("rm -rf $remoteDir") val source = ByteArrayInputStream(content.toByteArray()).source() - dadb.push(source, remotePath, 439, System.currentTimeMillis()) + assertThat(dadb.push(source, remotePath, 439, System.currentTimeMillis())).isInstanceOf(SyncResult.Success::class.java) val buffer = Buffer() - dadb.pull(buffer, remotePath) + assertThat(dadb.pull(buffer, remotePath)).isInstanceOf(SyncResult.Success::class.java) val pulledContent = buffer.readString(StandardCharsets.UTF_8) assertThat(pulledContent).isEqualTo(content) @@ -164,7 +164,7 @@ internal abstract class DadbTest : BaseConcurrencyTest() { dadb.shell("fallocate -l ${sizeMb}M $remotePath") val buffer = Buffer() - dadb.pull(buffer, remotePath) + assertThat(dadb.pull(buffer, remotePath)).isInstanceOf(SyncResult.Success::class.java) val pulledContent = buffer.readByteArray() assertThat(pulledContent).hasLength(sizeMb * 1024 * 1024) @@ -177,10 +177,10 @@ internal abstract class DadbTest : BaseConcurrencyTest() { val content = randomString() val localSrcFile = temporaryFolder.newFile().apply { writeText(content) } - dadb.push(localSrcFile, remotePathWithCJK, 439, System.currentTimeMillis()) + assertThat(dadb.push(localSrcFile, remotePathWithCJK, 439, System.currentTimeMillis())).isInstanceOf(SyncResult.Success::class.java) val localDstFile = temporaryFolder.newFile() - dadb.pull(localDstFile, remotePathWithCJK) + assertThat(dadb.pull(localDstFile, remotePathWithCJK)).isInstanceOf(SyncResult.Success::class.java) assertThat(localDstFile.readText()).isEqualTo(content) } @@ -192,10 +192,10 @@ internal abstract class DadbTest : BaseConcurrencyTest() { val content = randomString() val localSrcFile = temporaryFolder.newFile().apply { writeText(content) } - dadb.push(localSrcFile, remotePath, 439, System.currentTimeMillis()) + assertThat(dadb.push(localSrcFile, remotePath, 439, System.currentTimeMillis())).isInstanceOf(SyncResult.Success::class.java) val localDstFile = temporaryFolder.newFile() - dadb.pull(localDstFile, remotePath) + assertThat(dadb.pull(localDstFile, remotePath)).isInstanceOf(SyncResult.Success::class.java) assertThat(localDstFile.readText()).isEqualTo(content) } @@ -204,7 +204,7 @@ internal abstract class DadbTest : BaseConcurrencyTest() { @Test fun install() { localEmulator { dadb -> - dadb.install(TestApk.FILE) + assertThat(dadb.install(TestApk.FILE)).isInstanceOf(InstallResult.Success::class.java) val response = dadb.shell("pm list packages ${TestApk.PACKAGE_NAME}") assertShellResponse(response, 0, "package:${TestApk.PACKAGE_NAME}\n") } @@ -214,7 +214,7 @@ internal abstract class DadbTest : BaseConcurrencyTest() { fun installStream() { localEmulator { dadb -> val inputStream = FileInputStream(TestApk.FILE).source() - dadb.install(inputStream, TestApk.FILE.length()) + assertThat(dadb.install(inputStream, TestApk.FILE.length())).isInstanceOf(InstallResult.Success::class.java) val response = dadb.shell("pm list packages ${TestApk.PACKAGE_NAME}") assertShellResponse(response, 0, "package:${TestApk.PACKAGE_NAME}\n") } @@ -223,7 +223,7 @@ internal abstract class DadbTest : BaseConcurrencyTest() { @Test fun installMultiple() { localEmulator { dadb -> - dadb.installMultiple(TestApk.SPLIT_FILES) + assertThat(dadb.installMultiple(TestApk.SPLIT_FILES)).isInstanceOf(InstallResult.Success::class.java) val response = dadb.shell("pm path ${TestApk.SPLIT_PACKAGE_NAME} | wc -l") assertShellResponse(response, 0, "${TestApk.SPLIT_FILES.size}\n") } @@ -232,12 +232,12 @@ internal abstract class DadbTest : BaseConcurrencyTest() { @Test internal fun install_grantPermissions() { localEmulator { dadb -> - dadb.install(TestApk.FILE) + assertThat(dadb.install(TestApk.FILE)).isInstanceOf(InstallResult.Success::class.java) var response = dadb.shell("dumpsys package ${TestApk.PACKAGE_NAME}") assertThat(response.exitCode).isEqualTo(0) assertThat(response.output).doesNotContain("android.permission.RECORD_AUDIO: granted=true") - dadb.install(TestApk.FILE, "-g") + assertThat(dadb.install(TestApk.FILE, "-g")).isInstanceOf(InstallResult.Success::class.java) response = dadb.shell("dumpsys package ${TestApk.PACKAGE_NAME}") assertThat(response.exitCode).isEqualTo(0) assertThat(response.output).contains("android.permission.RECORD_AUDIO: granted=true") @@ -247,8 +247,8 @@ internal abstract class DadbTest : BaseConcurrencyTest() { @Test fun uninstall() { localEmulator { dadb -> - dadb.install(TestApk.FILE) - dadb.uninstall(TestApk.PACKAGE_NAME) + assertThat(dadb.install(TestApk.FILE)).isInstanceOf(InstallResult.Success::class.java) + assertThat(dadb.uninstall(TestApk.PACKAGE_NAME)).isInstanceOf(UninstallResult.Success::class.java) val response = dadb.shell("pm list packages ${TestApk.PACKAGE_NAME}") assertShellResponse(response, 0, "") } @@ -290,8 +290,8 @@ internal abstract class DadbTest : BaseConcurrencyTest() { @Ignore @Test fun root() { - localEmulator(Dadb::unroot) - localEmulator(Dadb::root) + localEmulator { assertThat(it.unroot()).isInstanceOf(RootResult.Success::class.java) } + localEmulator { assertThat(it.root()).isInstanceOf(RootResult.Success::class.java) } localEmulator { dadb -> val response = dadb.shell("getprop service.adb.root") assertShellResponse(response, 0, "1\n") @@ -301,8 +301,8 @@ internal abstract class DadbTest : BaseConcurrencyTest() { @Ignore @Test fun unroot() { - localEmulator(Dadb::root) - localEmulator(Dadb::unroot) + localEmulator { assertThat(it.root()).isInstanceOf(RootResult.Success::class.java) } + localEmulator { assertThat(it.unroot()).isInstanceOf(RootResult.Success::class.java) } localEmulator { dadb -> val response = dadb.shell("getprop service.adb.root") assertShellResponse(response, 0, "0\n") diff --git a/dadb/src/test/kotlin/dadb/DadbTestImpl.kt b/dadb/src/test/kotlin/dadb/DadbTestImpl.kt index fa858a5..39406b0 100644 --- a/dadb/src/test/kotlin/dadb/DadbTestImpl.kt +++ b/dadb/src/test/kotlin/dadb/DadbTestImpl.kt @@ -3,6 +3,7 @@ package dadb import org.junit.jupiter.api.Test import java.net.Socket import kotlin.test.assertFails +import kotlin.test.assertFailsWith import kotlin.test.assertNotNull internal class DadbTestImpl : DadbTest() { @@ -60,4 +61,16 @@ internal class DadbTestImpl : DadbTest() { } } + // AdbStreamOpenException is a direct-adbd concept; it lives here (direct connection) rather than + // in the shared DadbTest, because the adb-server path (AdbServerTest) still surfaces a generic + // IOException for a refused service (that path is out of scope for the error-model redesign). + @Test + fun open_invalidService_throwsStreamOpenException() { + localEmulator { dadb -> + assertFailsWith { + dadb.open("definitely-not-a-real-service:") + } + } + } + } diff --git a/dadb/src/test/kotlin/dadb/FakeDadb.kt b/dadb/src/test/kotlin/dadb/FakeDadb.kt new file mode 100644 index 0000000..eaf274e --- /dev/null +++ b/dadb/src/test/kotlin/dadb/FakeDadb.kt @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2021 mobile.dev inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package dadb + +import okio.Buffer +import java.nio.charset.StandardCharsets + +/** An [AdbStream] backed by in-memory buffers. [source] is pre-loaded with device bytes; + * [sink] captures whatever the code under test writes. */ +internal class FakeAdbStream( + override val source: Buffer, + override val sink: Buffer = Buffer() +) : AdbStream { + override fun close() {} +} + +/** A [Dadb] whose `open(destination)` returns a caller-supplied [AdbStream]. Records every + * destination opened, in order, in [opened]. */ +internal class FakeDadb( + private val features: Set = setOf("shell_v2", "cmd"), + private val streamFor: (destination: String) -> AdbStream +) : Dadb { + val opened = mutableListOf() + + override fun open(destination: String): AdbStream { + opened += destination + return streamFor(destination) + } + + override fun supportsFeature(feature: String) = feature in features + + override fun close() {} +} + +/** Builds a shell,v2 byte stream: optional stdout/stderr packets followed by an exit packet. */ +internal fun shellV2Buffer(stdout: String = "", stderr: String = "", exitCode: Int): Buffer { + val buffer = Buffer() + if (stdout.isNotEmpty()) { + val bytes = stdout.toByteArray() + buffer.writeByte(ID_STDOUT); buffer.writeIntLe(bytes.size); buffer.write(bytes) + } + if (stderr.isNotEmpty()) { + val bytes = stderr.toByteArray() + buffer.writeByte(ID_STDERR); buffer.writeIntLe(bytes.size); buffer.write(bytes) + } + buffer.writeByte(ID_EXIT); buffer.writeIntLe(1); buffer.writeByte(exitCode) + return buffer +} + +/** Builds a single sync `FAIL` packet (4-char id + LE length + message). */ +internal fun syncFailBuffer(message: String): Buffer { + val buffer = Buffer() + buffer.writeString("FAIL", StandardCharsets.UTF_8) + buffer.writeIntLe(message.toByteArray().size) + buffer.writeString(message, StandardCharsets.UTF_8) + return buffer +} + +/** Builds a single sync `OKAY` packet (success terminator for SEND). */ +internal fun syncOkayBuffer(): Buffer { + val buffer = Buffer() + buffer.writeString("OKAY", StandardCharsets.UTF_8) + buffer.writeIntLe(0) + return buffer +} diff --git a/dadb/src/test/kotlin/dadb/ResultHelpersTest.kt b/dadb/src/test/kotlin/dadb/ResultHelpersTest.kt new file mode 100644 index 0000000..99ce5dc --- /dev/null +++ b/dadb/src/test/kotlin/dadb/ResultHelpersTest.kt @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2021 mobile.dev inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package dadb + +import com.google.common.truth.Truth.assertThat +import kotlin.test.Test +import kotlin.test.assertFailsWith + +internal class ResultHelpersTest { + + @Test + fun onSuccessRunsOnlyForSuccess() { + var ran = false + (InstallResult.Success as InstallResult).onSuccess { ran = true } + assertThat(ran).isTrue() + + ran = false + (InstallResult.Failure("nope") as InstallResult).onSuccess { ran = true } + assertThat(ran).isFalse() + } + + @Test + fun onFailureReceivesTheFailurePayload() { + var reason: String? = null + (SyncResult.Failure("No such file or directory") as SyncResult).onFailure { reason = it.reason } + assertThat(reason).isEqualTo("No such file or directory") + + reason = "untouched" + (SyncResult.Success as SyncResult).onFailure { reason = it.reason } + assertThat(reason).isEqualTo("untouched") + } + + @Test + fun helpersReturnReceiverForChaining() { + var sawFailure = false + val result: InstallResult = InstallResult.Failure("rejected") + result.onSuccess { error("should not run") }.onFailure { sawFailure = true } + assertThat(sawFailure).isTrue() + } + + @Test + fun orThrowIsNoOpOnSuccess() { + (RootResult.Success as RootResult).orThrow() + } + + @Test + fun orThrowRaisesNonAdbExceptionOnFailure() { + val e = assertFailsWith { + (InstallResult.Failure("Failure [INSTALL_FAILED_INVALID_APK]") as InstallResult).orThrow() + } + assertThat(e.reason).contains("INSTALL_FAILED") + // The whole point: an opted-in operation throw is NOT a transport fault. + assertThat(e).isNotInstanceOf(AdbException::class.java) + } + + @Test + fun uninstallOrThrowCarriesExitCode() { + val e = assertFailsWith { + (UninstallResult.Failure(reason = "DELETE_FAILED_INTERNAL_ERROR", exitCode = 1) as UninstallResult).orThrow() + } + assertThat(e.exitCode).isEqualTo(1) + } +} diff --git a/dadb/src/test/kotlin/dadb/ResultTypesTest.kt b/dadb/src/test/kotlin/dadb/ResultTypesTest.kt new file mode 100644 index 0000000..6d887d1 --- /dev/null +++ b/dadb/src/test/kotlin/dadb/ResultTypesTest.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2021 mobile.dev inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package dadb + +import com.google.common.truth.Truth.assertThat +import kotlin.test.Test + +internal class ResultTypesTest { + + @Test + fun installResultVariants() { + val ok: InstallResult = InstallResult.Success + val fail: InstallResult = InstallResult.Failure("Failure [INSTALL_FAILED_INVALID_APK]") + assertThat(ok).isInstanceOf(InstallResult.Success::class.java) + assertThat((fail as InstallResult.Failure).reason).contains("INSTALL_FAILED") + } + + @Test + fun uninstallFailureCarriesExitCodeAndReason() { + val fail = UninstallResult.Failure(reason = "Failure [DELETE_FAILED_INTERNAL_ERROR]", exitCode = 1) + assertThat(fail.exitCode).isEqualTo(1) + assertThat(fail.reason).contains("DELETE_FAILED") + } + + @Test + fun syncAndRootVariants() { + assertThat(SyncResult.Failure("No such file or directory").reason).contains("No such file") + assertThat(RootResult.Failure("adbd cannot run as root in production builds").reason) + .contains("production builds") + val syncOk: SyncResult = SyncResult.Success + val rootOk: RootResult = RootResult.Success + assertThat(syncOk).isInstanceOf(SyncResult.Success::class.java) + assertThat(rootOk).isInstanceOf(RootResult.Success::class.java) + } +} diff --git a/dadb/src/test/kotlin/dadb/WriteTimeoutTest.kt b/dadb/src/test/kotlin/dadb/WriteTimeoutTest.kt index 1c10c05..a4ed24e 100644 --- a/dadb/src/test/kotlin/dadb/WriteTimeoutTest.kt +++ b/dadb/src/test/kotlin/dadb/WriteTimeoutTest.kt @@ -25,7 +25,6 @@ import java.io.Closeable import java.net.InetSocketAddress import java.net.ServerSocket import java.net.Socket -import java.net.SocketTimeoutException import java.time.Duration import java.util.Collections import kotlin.concurrent.thread @@ -108,7 +107,7 @@ class WriteTimeoutTest { } @Test - fun `a wedged write fails fast with SocketTimeoutException and the connection recovers`() { + fun `a wedged write fails fast with AdbTimeoutException and the connection recovers`() { Relay(targetPort = 5555).use { relay -> // writeTimeoutMillis is the internal test seam; production always uses WRITE_TIMEOUT_MILLIS. val dadb = DadbImpl( @@ -126,7 +125,9 @@ class WriteTimeoutTest { // Wedge: relay stops draining the client side, so a large write stalls. relay.wedgeWrites() val bigCommand = "x".repeat(32 * 1024 * 1024) // overflows the OS socket buffers (incl. larger Linux defaults) - assertThrows { + // okio's write timeout fires on the stalled write and surfaces as a typed timeout, + // not a raw SocketTimeoutException. + assertThrows { assertTimeoutPreemptively(Duration.ofSeconds(8)) { dadb.shell(bigCommand) } } @@ -150,7 +151,8 @@ class WriteTimeoutTest { // Wedge: adbd's responses stop reaching dadb, so the next op's read blocks until // SO_TIMEOUT fires (the connection stays open, so this is a timeout, not an EOF). relay.wedgeReads() - assertThrows { + // SO_TIMEOUT trips the read and surfaces as a typed timeout. + assertThrows { assertTimeoutPreemptively(Duration.ofSeconds(8)) { dadb.shell("echo hi") } } } finally {