blob: 97eded2ab0d27cc72eaf8c1a99a9bea25de29d1f [file] [log] [blame]
/*
* Copyright (C) 2022 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 com.android.adblib.tools
import com.android.adblib.AdbChannel
import com.android.adblib.AdbServerChannelProvider
import com.android.adblib.AdbSession
import com.android.adblib.adbLogger
import com.android.adblib.testing.FakeAdbSession
import com.android.adblib.toChannelReader
import com.android.adblib.utils.ResizableBuffer
import kotlinx.coroutines.runBlocking
import java.net.InetAddress
import java.net.InetSocketAddress
import java.nio.channels.AsynchronousCloseException
import java.nio.charset.StandardCharsets
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.Paths
import java.util.concurrent.CancellationException
/**
* A connection to an emulator's console, allowing control of the emulator.
*/
class EmulatorConsole constructor(
private val adbChannel: AdbChannel,
) : AutoCloseable {
private val workBuffer = ResizableBuffer()
private val channelReader =
adbChannel.toChannelReader(EMULATOR_CONSOLE_CHARSET, EMULATOR_CONSOLE_NEWLINE)
suspend fun authenticate(authToken: String): EmulatorCommandResult {
return sendCommand("auth $authToken")
}
/** Returns the AVD name. */
suspend fun avdName(): String =
sendCommand("avd name").throwOnError().outputLines.firstOrNull()
?: throw EmulatorCommandException("No output from \"avd name\"")
/**
* Returns the absolute path to the virtual device in the file system. The path is operating system
* dependent; it will have / name separators on Linux and \ separators on Windows.
*
* @throws EmulatorCommandException If the command failed or if the emulator's version is older than 30.0.18
*/
suspend fun avdPath(): Path =
Paths.get(
sendCommand("avd path").throwOnError().outputLines.firstOrNull()
?: throw EmulatorCommandException("No output from \"avd path\"")
)
/**
* Checks if the virtual machine of Android emulator was stopped.
*/
suspend fun isVmStopped(): Boolean {
val status = (sendCommand("avd status").throwOnError().outputLines.firstOrNull()
?: throw EmulatorCommandException("No output from \"avd status\""))
return status.endsWith(" stopped")
}
suspend fun kill() {
sendCommand("kill")
// Ignore errors from kill; it may have caused the socket to close
}
/**
* Starts recording the emulator screen to the given path. The path must end with .webm and have
* no whitespace.
*/
suspend fun startScreenRecording(path: Path, vararg options: String) {
val pathString = path.toString()
if (pathString.chars().anyMatch(Character::isWhitespace)) {
throw EmulatorCommandException("Whitespace not allowed in path string")
}
sendCommand("screenrecord start ${options.joinToString(" ")} $pathString").throwOnError()
}
suspend fun stopScreenRecording() {
sendCommand("screenrecord stop").throwOnError()
}
/**
* Sends the given command string, then parses and returns the response.
*/
suspend fun sendCommand(command: String): EmulatorCommandResult {
workBuffer.clear()
workBuffer.appendString(command, EMULATOR_CONSOLE_CHARSET)
workBuffer.appendString(EMULATOR_CONSOLE_NEWLINE, EMULATOR_CONSOLE_CHARSET)
adbChannel.writeExactly(workBuffer.forChannelWrite())
return readResponse()
}
internal suspend fun readResponse(): EmulatorCommandResult {
val outputLines = mutableListOf<String>()
while (true) {
val line =
try {
channelReader.readLine() ?: return EmulatorCommandResult(
outputLines,
"Connection closed unexpectedly"
)
} catch (e: CancellationException) {
// adblib wraps AsynchronousCloseException in CancellationException.
// Handle these, but allow ordinary CancellationExceptions to propagate.
if (e.nonCancellationCause() is AsynchronousCloseException) {
return EmulatorCommandResult(
outputLines,
"Connection closed")
}
throw e
}
if (line.startsWith("KO: ")) {
return EmulatorCommandResult(outputLines, line.substring(4).trim())
} else if (line.startsWith("OK")) {
return EmulatorCommandResult(outputLines, null)
} else {
outputLines.add(line)
}
}
}
private fun Throwable.nonCancellationCause(): Throwable? =
when (val cause = cause) {
is CancellationException -> cause.nonCancellationCause()
else -> cause
}
override fun close() {
adbChannel.close()
}
/**
* The result of invoking an emulator command. Emulator responses contain zero or more lines
* of output, followed by "OK" on success, or "KO: <error message>" on error.
*
* @property outputLines the output of the command prior to the success / error code, without
* line terminators.
* @property error if present, indicates command failure and contains the error message.
*/
class EmulatorCommandResult(val outputLines: List<String>, val error: String?) {
fun isOk() = error == null
fun isError() = error != null
fun throwOnError(): EmulatorCommandResult {
if (error != null) {
throw EmulatorCommandException(error)
}
return this
}
}
}
private const val AUTH_REQUIRED = "Android Console: Authentication required"
private val EMULATOR_CONSOLE_CHARSET = StandardCharsets.UTF_8
private const val EMULATOR_CONSOLE_NEWLINE = "\r\n"
/**
* Attempts to connect to an emulator console at the supplied address, authenticating
* if required.
*
* @throws IOExeception if we're unable to read the auth token
* @throws EmulatorCommandException if authentication fails
*/
suspend fun AdbSession.openEmulatorConsole(address: InetSocketAddress): EmulatorConsole {
val channelProvider =
AdbServerChannelProvider.createConnectAddresses(host) {
listOf(address)
}
val console = EmulatorConsole(channelProvider.createChannel())
val result = console.readResponse().throwOnError()
if (result.outputLines.any { it.contains(AUTH_REQUIRED) }) {
// The following lines are output by the emulator, and expected to remain stable:
// Android Console: Authentication required
// Android Console: type 'auth <auth_token>' to authenticate
// Android Console: you can find your <auth_token> in
// '/<path-to-home>/.emulator_console_auth_token'
// OK
val authTokenPromptIdx =
result.outputLines.indexOfFirst { it.contains("you can find your <auth_token> in") }
if (authTokenPromptIdx >= 0 && authTokenPromptIdx < result.outputLines.size - 1) {
val authTokenPath = Path.of(result.outputLines[authTokenPromptIdx + 1].trimQuotes())
val authToken =
channelFactory.openFile(authTokenPath).use {
it.toChannelReader().readLine()?.trim() ?: ""
}
console.authenticate(authToken).throwOnError()
} else {
throw EmulatorCommandException("Unable to authenticate to emulator: auth token location not provided by emulator")
}
}
return console
}
fun localConsoleAddress(port: Int) =
InetSocketAddress(InetAddress.getLoopbackAddress(), port)
private fun String.trimQuotes() =
substringAfter('\'').substringBeforeLast('\'')
/** Simple wrapper around EmulatorConsole for manual integration testing. */
fun main(args: Array<String>) {
runBlocking {
FakeAdbSession().openEmulatorConsole(localConsoleAddress(args[0].toInt())).use {
println("Connected to emulator")
println("AVD name: ${it.avdName()}")
println("AVD path: ${it.avdPath()}")
}
}
}
class EmulatorCommandException(error: String, cause: Throwable? = null) : Exception(error, cause)