From 0c431c41f72a9860aa665b270f0c651bd6036cea Mon Sep 17 00:00:00 2001 From: AndroidX Test Team Date: Tue, 1 Oct 2024 14:40:16 -0700 Subject: [PATCH] Integration tests for LocalSocketShellMain. PiperOrigin-RevId: 681170539 --- .../test/services/shellexecutor/BUILD | 5 + .../shellexecutor/LocalSocketShellMain.kt | 89 +++++++ .../ShellCommandLocalSocketExecutorServer.kt | 245 ++++++++++++++++++ .../shellexecutor/ShellExecutorFactory.java | 7 +- .../ShellExecutorLocalSocketImpl.kt | 86 ++++++ .../test/services/shellexecutor/BUILD | 18 ++ ...ellCommandLocalSocketExecutorServerTest.kt | 104 ++++++++ .../shellexecutor/ShellCommandTest.java | 67 +++-- .../shellexecutor/ShellExecutorTest.java | 5 +- 9 files changed, 605 insertions(+), 21 deletions(-) create mode 100644 services/shellexecutor/java/androidx/test/services/shellexecutor/LocalSocketShellMain.kt create mode 100644 services/shellexecutor/java/androidx/test/services/shellexecutor/ShellCommandLocalSocketExecutorServer.kt create mode 100644 services/shellexecutor/java/androidx/test/services/shellexecutor/ShellExecutorLocalSocketImpl.kt create mode 100644 services/shellexecutor/javatests/androidx/test/services/shellexecutor/ShellCommandLocalSocketExecutorServerTest.kt diff --git a/services/shellexecutor/java/androidx/test/services/shellexecutor/BUILD b/services/shellexecutor/java/androidx/test/services/shellexecutor/BUILD index 897bc547a..fa3373d20 100644 --- a/services/shellexecutor/java/androidx/test/services/shellexecutor/BUILD +++ b/services/shellexecutor/java/androidx/test/services/shellexecutor/BUILD @@ -60,10 +60,12 @@ kt_android_library( srcs = [ "BlockingPublish.java", "FileObserverShellMain.kt", + "LocalSocketShellMain.kt", "ShellCommand.java", "ShellCommandExecutor.java", "ShellCommandExecutorServer.java", "ShellCommandFileObserverExecutorServer.kt", + "ShellCommandLocalSocketExecutorServer.kt", "ShellExecSharedConstants.java", "ShellMain.java", ], @@ -72,6 +74,8 @@ kt_android_library( deps = [ ":coroutine_file_observer", ":file_observer_protocol", + ":local_socket_protocol", + ":local_socket_protocol_pb_java_proto_lite", "//services/speakeasy/java/androidx/test/services/speakeasy:protocol", "//services/speakeasy/java/androidx/test/services/speakeasy/client", "//services/speakeasy/java/androidx/test/services/speakeasy/client:tool_connection", @@ -94,6 +98,7 @@ kt_android_library( "ShellExecutorFactory.java", "ShellExecutorFileObserverImpl.kt", "ShellExecutorImpl.java", + "ShellExecutorLocalSocketImpl.kt", ], idl_srcs = ["Command.aidl"], visibility = [":export"], diff --git a/services/shellexecutor/java/androidx/test/services/shellexecutor/LocalSocketShellMain.kt b/services/shellexecutor/java/androidx/test/services/shellexecutor/LocalSocketShellMain.kt new file mode 100644 index 000000000..e68d4c5d0 --- /dev/null +++ b/services/shellexecutor/java/androidx/test/services/shellexecutor/LocalSocketShellMain.kt @@ -0,0 +1,89 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * 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 androidx.test.services.shellexecutor + +import android.util.Log +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import java.util.concurrent.Executors +import kotlin.time.Duration.Companion.milliseconds +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.runInterruptible + +/** Variant of ShellMain that uses a LocalSocket to communicate with the client. */ +class LocalSocketShellMain { + + suspend fun run(args: Array): Int { + val scope = CoroutineScope(Executors.newCachedThreadPool().asCoroutineDispatcher()) + val server = ShellCommandLocalSocketExecutorServer(scope = scope) + server.start() + + val processArgs = args.toMutableList() + processArgs.addAll( + processArgs.size - 1, + listOf("-e", ShellExecSharedConstants.BINDER_KEY, server.binderKey()), + ) + val pb = ProcessBuilder(processArgs.toList()) + + val exitCode: Int + + try { + val process = pb.start() + + val stdinCopier = scope.launch { copyStream("stdin", System.`in`, process.outputStream) } + val stdoutCopier = scope.launch { copyStream("stdout", process.inputStream, System.out) } + val stderrCopier = scope.launch { copyStream("stderr", process.errorStream, System.err) } + + runInterruptible { process.waitFor() } + exitCode = process.exitValue() + + stdinCopier.cancel() // System.`in`.close() does not force input.read() to return + stdoutCopier.join() + stderrCopier.join() + } finally { + server.stop(100.milliseconds) + } + return exitCode + } + + suspend fun copyStream(name: String, input: InputStream, output: OutputStream) { + val buf = ByteArray(1024) + try { + while (true) { + val size = input.read(buf) + if (size == -1) break + output.write(buf, 0, size) + } + output.flush() + } catch (x: IOException) { + Log.e(TAG, "IOException on $name. Terminating.", x) + } + } + + companion object { + private const val TAG = "LocalSocketShellMain" + + @JvmStatic + public fun main(args: Array) { + System.exit(runBlocking { LocalSocketShellMain().run(args) }) + } + } +} diff --git a/services/shellexecutor/java/androidx/test/services/shellexecutor/ShellCommandLocalSocketExecutorServer.kt b/services/shellexecutor/java/androidx/test/services/shellexecutor/ShellCommandLocalSocketExecutorServer.kt new file mode 100644 index 000000000..775f81d89 --- /dev/null +++ b/services/shellexecutor/java/androidx/test/services/shellexecutor/ShellCommandLocalSocketExecutorServer.kt @@ -0,0 +1,245 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * 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 androidx.test.services.shellexecutor + +import android.net.LocalServerSocket +import android.net.LocalSocket +import android.net.LocalSocketAddress +import android.os.Process as AndroidProcess +import android.util.Log +import androidx.test.services.shellexecutor.LocalSocketProtocol.asBinderKey +import androidx.test.services.shellexecutor.LocalSocketProtocol.readRequest +import androidx.test.services.shellexecutor.LocalSocketProtocol.sendResponse +import androidx.test.services.shellexecutor.LocalSocketProtocolProto.RunCommandRequest +import java.io.IOException +import java.io.InterruptedIOException +import java.security.SecureRandom +import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.runInterruptible +import kotlinx.coroutines.withTimeout + +/** Server that run shell commands for a client talking over a LocalSocket. */ +final class ShellCommandLocalSocketExecutorServer +@JvmOverloads +constructor( + private val scope: CoroutineScope = + CoroutineScope(Executors.newCachedThreadPool().asCoroutineDispatcher()) +) { + // Use the same secret generation as SpeakEasy does. + private val secret = java.lang.Long.toHexString(SecureRandom().nextLong()) + lateinit var socket: LocalServerSocket + lateinit var address: LocalSocketAddress + // Since LocalServerSocket.accept() has to be interrupted, we keep that in its own Job... + lateinit var serverJob: Job + // ...while all the child jobs are under a single SupervisorJob that we can join later. + val shellJobs = SupervisorJob() + val running = AtomicBoolean(true) + + /** Returns the binder key to pass to client processes. */ + fun binderKey(): String { + // The address can contain spaces, and since it gets passed through a command line, we need to + // encode it. java.net.URLEncoder is conveniently available in all SDK versions. + return address.asBinderKey(secret) + } + + /** Runs a simple server. */ + private suspend fun server() = coroutineScope { + while (running.get()) { + val connection = + try { + runInterruptible { socket.accept() } + } catch (x: Exception) { + // None of my tests have managed to trigger this one. + Log.e(TAG, "LocalServerSocket.accept() failed", x) + break + } + launch(scope.coroutineContext + shellJobs) { handleConnection(connection) } + } + } + + /** + * Relays the output of process to connection with a series of RunCommandResponses. + * + * @param process The process to relay output from. + * @param connection The connection to relay output to. + * @return false if there was a problem, true otherwise. + */ + private suspend fun relay(process: Process, connection: LocalSocket): Boolean { + // Experiment shows that 64K is *much* faster than 4K, especially on API 21-23. Streaming 1MB + // takes 3s with 4K buffers and 2s with 64K on API 23. 22 is a bit faster (2.6s -> 1.5s), + // 21 faster still (630ms -> 545ms). Higher API levels are *much* faster (24 is 119 ms -> + // 75ms). + val buffer = ByteArray(65536) + var size: Int + + // LocalSocket.isOutputShutdown() throws UnsupportedOperationException, so we can't use + // that as our loop constraint. + while (true) { + try { + size = runInterruptible { process.inputStream.read(buffer) } + if (size < 0) return true // EOF + if (size == 0) { + delay(1.milliseconds) + continue + } + } catch (x: InterruptedIOException) { + // We start getting these at API 24 when the timeout handling kicks in. + Log.i(TAG, "Interrupted while reading from ${process}: ${x.message}") + return false + } catch (x: IOException) { + Log.i(TAG, "Error reading from ${process}; did it time out?", x) + return false + } + + if (!connection.sendResponse(buffer = buffer, size = size)) { + return false + } + } + } + + /** Handle one connection. */ + private suspend fun handleConnection(connection: LocalSocket) { + // connection.localSocketAddress is always null, so no point in logging it. + + // Close the connection when done. + connection.use { + val request = connection.readRequest() + + if (request.secret.compareTo(secret) != 0) { + Log.w(TAG, "Ignoring request with wrong secret: $request") + return + } + + val pb = request.toProcessBuilder() + pb.redirectErrorStream(true) + + val process: Process + try { + process = pb.start() + } catch (x: IOException) { + Log.e(TAG, "Failed to start process", x) + connection.sendResponse( + buffer = x.stackTraceToString().toByteArray(), + exitCode = EXIT_CODE_FAILED_TO_START, + ) + return + } + + // We will not be writing anything to the process' stdin. + process.outputStream.close() + + // Close the process' stdout when we're done reading. + process.inputStream.use { + // Launch a coroutine to relay the process' output to the client. If it times out, kill the + // process and cancel the job. This is more coroutine-friendly than using waitFor() to + // handle timeouts. + val ioJob = scope.async { relay(process, connection) } + + try { + withTimeout(request.timeout()) { + if (!ioJob.await()) { + Log.w(TAG, "Relaying ${process} output failed") + } + runInterruptible { process.waitFor() } + } + } catch (x: TimeoutCancellationException) { + Log.e(TAG, "Process ${process} timed out after ${request.timeout()}") + process.destroy() + ioJob.cancel() + connection.sendResponse(exitCode = EXIT_CODE_TIMED_OUT) + return + } + + connection.sendResponse(exitCode = process.exitValue()) + } + } + } + + /** Starts the server. */ + fun start() { + socket = LocalServerSocket("androidx.test.services ${AndroidProcess.myPid()}") + address = socket.localSocketAddress + Log.i(TAG, "Starting server on ${address.name}") + + // Launch a coroutine to call socket.accept() + serverJob = scope.launch { server() } + } + + /** Stops the server. */ + fun stop(timeout: Duration) { + running.set(false) + // Closing the socket does not interrupt accept()... + socket.close() + runBlocking(scope.coroutineContext) { + try { + // ...so we simply cancel that job... + serverJob.cancel() + // ...and play nicely with all the shell jobs underneath. + withTimeout(timeout) { + shellJobs.complete() + shellJobs.join() + } + } catch (x: TimeoutCancellationException) { + Log.w(TAG, "Shell jobs did not stop after $timeout", x) + shellJobs.cancel() + } + } + } + + private fun RunCommandRequest.timeout(): Duration = + if (timeoutMs <= 0) { + Duration.INFINITE + } else { + timeoutMs.milliseconds + } + + /** + * Sets up a ProcessBuilder with information from the request; other configuration is up to the + * caller. + */ + private fun RunCommandRequest.toProcessBuilder(): ProcessBuilder { + val pb = ProcessBuilder(argvList) + val redacted = argvList.map { it.replace(secret, "(SECRET)") } // Don't log the secret! + Log.i(TAG, "Command to execute: [${redacted.joinToString("] [")}] within ${timeout()}") + if (environmentMap.isNotEmpty()) { + pb.environment().putAll(environmentMap) + val env = environmentMap.entries.map { (k, v) -> "$k=$v" }.joinToString(", ") + Log.i(TAG, "Environment: $env") + } + return pb + } + + private companion object { + const val TAG = "SCLSEServer" // up to 23 characters + + const val EXIT_CODE_FAILED_TO_START = -1 + const val EXIT_CODE_TIMED_OUT = -2 + } +} diff --git a/services/shellexecutor/java/androidx/test/services/shellexecutor/ShellExecutorFactory.java b/services/shellexecutor/java/androidx/test/services/shellexecutor/ShellExecutorFactory.java index bc4ab81c2..f0ea4e287 100644 --- a/services/shellexecutor/java/androidx/test/services/shellexecutor/ShellExecutorFactory.java +++ b/services/shellexecutor/java/androidx/test/services/shellexecutor/ShellExecutorFactory.java @@ -31,8 +31,11 @@ public ShellExecutorFactory(Context context, String binderKey) { public ShellExecutor create() { // Binder keys for SpeakEasy are a string of hex digits. Binder keys for the FileObserver - // protocol are the absolute path of the directory that the server is watching. - if (binderKey.startsWith("/")) { + // protocol are the absolute path of the directory that the server is watching. Binder keys for + // the LocalSocket protocol start and end with a colon. + if (LocalSocketProtocol.isBinderKey(binderKey)) { + return new ShellExecutorLocalSocketImpl(binderKey); + } else if (binderKey.startsWith("/")) { return new ShellExecutorFileObserverImpl(binderKey); } else { return new ShellExecutorImpl(context, binderKey); diff --git a/services/shellexecutor/java/androidx/test/services/shellexecutor/ShellExecutorLocalSocketImpl.kt b/services/shellexecutor/java/androidx/test/services/shellexecutor/ShellExecutorLocalSocketImpl.kt new file mode 100644 index 000000000..7f903442e --- /dev/null +++ b/services/shellexecutor/java/androidx/test/services/shellexecutor/ShellExecutorLocalSocketImpl.kt @@ -0,0 +1,86 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * 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 androidx.test.services.shellexecutor + +import java.io.InputStream +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +/** ShellExecutor that talks to LocalSocketShellMain. */ +class ShellExecutorLocalSocketImpl(private val binderKey: String) : ShellExecutor { + + /** {@inheritDoc} */ + override fun getBinderKey() = binderKey + + /** {@inheritDoc} */ + @kotlin.time.ExperimentalTime + override fun executeShellCommandSync( + command: String?, + parameters: List?, + shellEnv: Map?, + executeThroughShell: Boolean, + timeoutMs: Long, + ): String = + executeShellCommand(command, parameters, shellEnv, executeThroughShell, timeoutMs).use { + it.readBytes().toString(Charsets.UTF_8) + } + + /** {@inheritDoc} */ + @kotlin.time.ExperimentalTime + override fun executeShellCommandSync( + command: String?, + parameters: List?, + shellEnv: Map?, + executeThroughShell: Boolean, + ) = executeShellCommandSync(command, parameters, shellEnv, executeThroughShell, TIMEOUT_FOREVER) + + /** {@inheritDoc} */ + @kotlin.time.ExperimentalTime + override fun executeShellCommand( + command: String?, + parameters: List?, + shellEnv: Map?, + executeThroughShell: Boolean, + timeoutMs: Long, + ): InputStream { + if (command == null || command.isEmpty()) { + throw IllegalArgumentException("Null or empty command") + } + val client = ShellCommandLocalSocketClient(binderKey) + val timeout = + if (timeoutMs > 0) { + timeoutMs.milliseconds + } else { + Duration.INFINITE + } + return client.request(command, parameters, shellEnv, executeThroughShell, timeout) + } + + /** {@inheritDoc} */ + @kotlin.time.ExperimentalTime + override fun executeShellCommand( + command: String?, + parameters: List?, + shellEnv: Map?, + executeThroughShell: Boolean, + ) = executeShellCommand(command, parameters, shellEnv, executeThroughShell, TIMEOUT_FOREVER) + + private companion object { + const val TAG = "ShellExecutorLocalSocketImpl" + const val TIMEOUT_FOREVER = -1L + } +} diff --git a/services/shellexecutor/javatests/androidx/test/services/shellexecutor/BUILD b/services/shellexecutor/javatests/androidx/test/services/shellexecutor/BUILD index 0b69391ab..2c8ee95c6 100644 --- a/services/shellexecutor/javatests/androidx/test/services/shellexecutor/BUILD +++ b/services/shellexecutor/javatests/androidx/test/services/shellexecutor/BUILD @@ -36,6 +36,7 @@ axt_android_library_test( "//runner/android_junit_runner", "//services/shellexecutor:exec_client", "//services/shellexecutor:exec_server", + "//services/shellexecutor/java/androidx/test/services/shellexecutor:local_socket_protocol", "@maven//:com_google_code_findbugs_jsr305", "@maven//:com_google_guava_guava", "@maven//:com_google_truth_truth", @@ -109,6 +110,23 @@ axt_android_library_test( ], ) +axt_android_library_test( + name = "ShellCommandLocalSocketExecutorServerTest", + srcs = [ + "ShellCommandLocalSocketExecutorServerTest.kt", + ], + deps = [ + "//runner/monitor", + "//services/shellexecutor:exec_server", + "//services/shellexecutor/java/androidx/test/services/shellexecutor:local_socket_protocol", + "//services/shellexecutor/java/androidx/test/services/shellexecutor:local_socket_protocol_pb_java_proto_lite", + "@com_google_protobuf//:protobuf_javalite", + "@maven//:com_google_truth_truth", + "@maven//:junit_junit", + "@maven//:org_jetbrains_kotlinx_kotlinx_coroutines_android", + ], +) + axt_android_library_test( name = "ShellExecutorTest", srcs = [ diff --git a/services/shellexecutor/javatests/androidx/test/services/shellexecutor/ShellCommandLocalSocketExecutorServerTest.kt b/services/shellexecutor/javatests/androidx/test/services/shellexecutor/ShellCommandLocalSocketExecutorServerTest.kt new file mode 100644 index 000000000..230ea9820 --- /dev/null +++ b/services/shellexecutor/javatests/androidx/test/services/shellexecutor/ShellCommandLocalSocketExecutorServerTest.kt @@ -0,0 +1,104 @@ +package androidx.test.services.shellexecutor + +import android.net.LocalSocket +import android.os.Build +import androidx.test.services.shellexecutor.LocalSocketProtocol.addressFromBinderKey +import androidx.test.services.shellexecutor.LocalSocketProtocol.hasExited +import androidx.test.services.shellexecutor.LocalSocketProtocol.readResponse +import androidx.test.services.shellexecutor.LocalSocketProtocol.secretFromBinderKey +import androidx.test.services.shellexecutor.LocalSocketProtocol.sendRequest +import androidx.test.services.shellexecutor.LocalSocketProtocolProto.RunCommandResponse +import com.google.common.truth.Truth.assertThat +import kotlin.time.Duration.Companion.milliseconds +import kotlinx.coroutines.runBlocking +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 + +@RunWith(JUnit4::class) +class ShellCommandLocalSocketExecutorServerTest { + + @Test + fun success_simple() { + val responses = mutableListOf() + runBlocking { + val server = ShellCommandLocalSocketExecutorServer() + server.start() + val client = LocalSocket(LocalSocket.SOCKET_STREAM) + client.connect(addressFromBinderKey(server.binderKey())) + client.sendRequest( + secretFromBinderKey(server.binderKey()), + listOf("echo", "\${POTRZEBIE}"), + mapOf("POTRZEBIE" to "furshlugginer"), + 1000.milliseconds, + ) + do { + client.readResponse()?.let { responses.add(it) } + } while (!responses.last().hasExited()) + server.stop(100.milliseconds) + } + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.LOLLIPOP_MR1) { + // On API 21 and 22, echo only exists as a shell builtin! + assertThat(responses).hasSize(1) + assertThat(responses[0].exitCode).isEqualTo(-1) + assertThat(responses[0].buffer.toStringUtf8()).contains("Permission denied") + } else { + // On rare occasions, the output of the command will come back in two packets! So to keep + // this test from being 1% flaky: + val stdout = buildString { + for (response in responses) { + if (response.buffer.size() > 0) append(response.buffer.toStringUtf8()) + } + } + assertThat(stdout).isEqualTo("\${POTRZEBIE}\n") + assertThat(responses.last().hasExited()).isTrue() + assertThat(responses.last().exitCode).isEqualTo(0) + } + } + + @Test + fun success_shell_expansion() { + val responses = mutableListOf() + runBlocking { + val server = ShellCommandLocalSocketExecutorServer() + server.start() + val client = LocalSocket(LocalSocket.SOCKET_STREAM) + client.connect(addressFromBinderKey(server.binderKey())) + client.sendRequest( + secretFromBinderKey(server.binderKey()), + listOf("sh", "-c", "echo \${POTRZEBIE}"), + mapOf("POTRZEBIE" to "furshlugginer"), + 1000.milliseconds, + ) + do { + client.readResponse()?.let { responses.add(it) } + } while (!responses.last().hasExited()) + server.stop(100.milliseconds) + } + val stdout = buildString { + for (response in responses) { + if (response.buffer.size() > 0) append(response.buffer.toStringUtf8()) + } + } + assertThat(stdout).isEqualTo("furshlugginer\n") + assertThat(responses.last().hasExited()).isTrue() + assertThat(responses.last().exitCode).isEqualTo(0) + } + + @Test + fun failure_bad_secret() { + runBlocking { + val server = ShellCommandLocalSocketExecutorServer() + server.start() + val client = LocalSocket(LocalSocket.SOCKET_STREAM) + client.connect(addressFromBinderKey(server.binderKey())) + client.sendRequest( + "potrzebie!", + listOf("sh", "-c", "echo \${POTRZEBIE}"), + mapOf("POTRZEBIE" to "furshlugginer"), + 1000.milliseconds, + ) + assertThat(client.inputStream.read()).isEqualTo(-1) + } + } +} diff --git a/services/shellexecutor/javatests/androidx/test/services/shellexecutor/ShellCommandTest.java b/services/shellexecutor/javatests/androidx/test/services/shellexecutor/ShellCommandTest.java index b2d5f3734..cbad25900 100644 --- a/services/shellexecutor/javatests/androidx/test/services/shellexecutor/ShellCommandTest.java +++ b/services/shellexecutor/javatests/androidx/test/services/shellexecutor/ShellCommandTest.java @@ -24,7 +24,9 @@ import androidx.test.ext.junit.rules.ActivityScenarioRule; import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.platform.app.InstrumentationRegistry; +import java.io.ByteArrayOutputStream; import java.io.InputStream; +import java.time.Duration; import java.util.Arrays; import java.util.HashMap; import java.util.List; @@ -48,14 +50,52 @@ private static String getSecret() { private static String execShellCommand( String command, List params, Map env, boolean executeThroughShell) throws Exception { - return ShellCommandClient.execOnServerSync( - InstrumentationRegistry.getInstrumentation().getContext(), - getSecret(), - command, - params, - env, - executeThroughShell, - 0L); + if (LocalSocketProtocol.isBinderKey(getSecret())) { + ShellCommandLocalSocketClient client = new ShellCommandLocalSocketClient(getSecret()); + InputStream is = + client.request(command, params, env, executeThroughShell, Duration.ofSeconds(10)); + ByteArrayOutputStream result = new ByteArrayOutputStream(); + try { + byte[] buffer = new byte[ShellExecSharedConstants.BUFFER_SIZE]; + int length; + while ((length = is.read(buffer)) != -1) { + result.write(buffer, 0, length); + } + } finally { + if (is != null) { + is.close(); + } + } + return result.toString("UTF-8"); + + } else { + return ShellCommandClient.execOnServerSync( + InstrumentationRegistry.getInstrumentation().getContext(), + getSecret(), + command, + params, + env, + executeThroughShell, + 0L); + } + } + + private static InputStream execShellCommandAsync( + String command, List params, Map env, boolean executeThroughShell) + throws Exception { + if (LocalSocketProtocol.isBinderKey(getSecret())) { + ShellCommandLocalSocketClient client = new ShellCommandLocalSocketClient(getSecret()); + return client.request(command, params, env, executeThroughShell, Duration.ofMinutes(2)); + } else { + return ShellCommandClient.execOnServer( + InstrumentationRegistry.getInstrumentation().getContext(), + getSecret(), + command, + params, + env, + executeThroughShell, + 0L); + } } @Test @@ -104,7 +144,7 @@ public void run() { } }); - spinlock.run(); + spinlock.start(); execShellCommand("setprop testing 1", null, null, true); try { @@ -123,14 +163,7 @@ public void testLargeFileDump() throws Exception { // handle. If the buffer blocks and overflows this test will timeout. InputStream stream = - ShellCommandClient.execOnServer( - InstrumentationRegistry.getInstrumentation().getContext(), - getSecret(), - "dd if=/dev/urandom bs=2048 count=16384", - null, - null, - true, - 0L); + execShellCommandAsync("dd if=/dev/urandom bs=2048 count=16384", null, null, true); boolean weReadSomething = false; diff --git a/services/shellexecutor/javatests/androidx/test/services/shellexecutor/ShellExecutorTest.java b/services/shellexecutor/javatests/androidx/test/services/shellexecutor/ShellExecutorTest.java index 179d3a7bf..704c55573 100644 --- a/services/shellexecutor/javatests/androidx/test/services/shellexecutor/ShellExecutorTest.java +++ b/services/shellexecutor/javatests/androidx/test/services/shellexecutor/ShellExecutorTest.java @@ -36,10 +36,11 @@ public class ShellExecutorTest { @Before public void initShellExec() { - this.shellExecutor = - new ShellExecutorImpl( + ShellExecutorFactory factory = + new ShellExecutorFactory( InstrumentationRegistry.getInstrumentation().getContext(), InstrumentationRegistry.getArguments().getString(ShellExecSharedConstants.BINDER_KEY)); + this.shellExecutor = factory.create(); } @Test