ShellUtils.kt

/*
 * Copyright 2020 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.benchmark.macro.perfetto

import androidx.benchmark.Outputs
import androidx.test.uiautomator.UiDevice
import java.io.File
import java.io.InputStream

internal data class ShellOutput(val stdout: String, val stderr: String)

/**
 * Provides shell scripting functionality, as well as stdin/stderr capabilities (for the first/last
 * command, respectively)
 *
 * A better way to implement stdin/stderr may be to have an additional wrapper script when
 * stdin/stderr is required, so that all stderr can be captured (instead of redirecting the
 * last command), and stdin can be read by other commands in the script (instead of just the 1st).
 */
private fun UiDevice.executeShellScript(
    script: String,
    stdin: String?,
    includeStderr: Boolean
): Pair<String, String?> {
    // dirUsableByAppAndShell is writable, but we can't execute there (as of Q),
    // so we copy to /data/local/tmp
    val externalDir = Outputs.dirUsableByAppAndShell
    val writableScriptFile = File.createTempFile("temporaryScript", ".sh", externalDir)
    val runnableScriptPath = "/data/local/tmp/" + writableScriptFile.name

    // only create/read/delete stdin/stderr files if they are needed
    val stdinFile = stdin?.run {
        File.createTempFile("temporaryStdin", null, externalDir)
    }
    val stderrPath = if (includeStderr) {
        // we use a modified runnableScriptPath (as opposed to externalDir) because some shell
        // commands fail to redirect stderr to externalDir (notably, `am start`).
        // This also means we need to `cat` the file to read it, and `rm` to remove it.
        runnableScriptPath + "_stderr"
    } else {
        null
    }

    try {
        var scriptText: String = script
        if (stdinFile != null) {
            stdinFile.writeText(stdin)
            scriptText = "cat ${stdinFile.absolutePath} | $scriptText"
        }
        if (stderrPath != null) {
            scriptText = "$scriptText 2> $stderrPath"
        }
        writableScriptFile.writeText(scriptText)

        // Note: we don't check for return values from the below, since shell based file
        // permission errors generally crash our process.
        executeShellCommand("cp ${writableScriptFile.absolutePath} $runnableScriptPath")
        executeShellCommand("chmod +x $runnableScriptPath")

        val stdout = executeShellCommand(runnableScriptPath)
        val stderr = stderrPath?.run { executeShellCommand("cat $stderrPath") }

        return Pair(stdout, stderr)
    } finally {
        stdinFile?.delete()
        stderrPath?.run {
            executeShellCommand("rm $stderrPath")
        }
        writableScriptFile.delete()
        executeShellCommand("rm $runnableScriptPath")
    }
}

/**
 * Convenience wrapper around [UiDevice.executeShellCommand()] which enables redirects, piping, and
 * all other shell script functionality, and captures stderr of last command.
 *
 * Unlike [UiDevice.executeShellCommand()], this method supports arbitrary multi-line shell
 * expressions, as it creates and executes a shell script in `/data/local/tmp/`.
 *
 * Note that shell scripting capabilities differ based on device version. To see which utilities
 * are available on which platform versions,see
 * [Android's shell and utilities](https://android.googlesource.com/platform/system/core/+/master/shell_and_utilities/README.md#)
 *
 * @param script Script content to run
 * @param stdin String to pass in as stdin to first command in script
 *
 * @return ShellOutput, including stdout of full script, and stderr of last command.
 */
internal fun UiDevice.executeShellScriptWithStderr(
    script: String,
    stdin: String? = null
): ShellOutput {
    return executeShellScript(
        script = script,
        stdin = stdin,
        includeStderr = true
    ).run {
        ShellOutput(first, second!!)
    }
}

/**
 * Convenience wrapper around [UiDevice.executeShellCommand()] which enables redirects, piping, and
 * all other shell script functionality.
 *
 * Unlike [UiDevice.executeShellCommand()], this method supports arbitrary multi-line shell
 * expressions, as it creates and executes a shell script in `/data/local/tmp/`.
 *
 * Note that shell scripting capabilities differ based on device version. To see which utilities
 * are available on which platform versions,see
 * [Android's shell and utilities](https://android.googlesource.com/platform/system/core/+/master/shell_and_utilities/README.md#)
 *
 * @param script Script content to run
 * @param stdin String to pass in as stdin to first command in script
 *
 * @return Stdout string
 */
internal fun UiDevice.executeShellScript(script: String, stdin: String? = null): String {
    return executeShellScript(script, stdin, false).first
}

/**
 * Writes the inputStream to an executable file with the given name in `/data/local/tmp`
 */
internal fun UiDevice.createRunnableExecutable(name: String, inputStream: InputStream): String {
    // dirUsableByAppAndShell is writable, but we can't execute there (as of Q),
    // so we copy to /data/local/tmp
    val externalDir = Outputs.dirUsableByAppAndShell
    val writableExecutableFile = File.createTempFile(
        /* prefix */ "temporary_$name",
        /* suffix */ null,
        /* directory */ externalDir
    )
    val runnableExecutablePath = "/data/local/tmp/$name"

    try {
        writableExecutableFile.outputStream().use {
            inputStream.copyTo(it)
        }
        moveToTmpAndMakeExecutable(
            src = writableExecutableFile.absolutePath,
            dst = runnableExecutablePath
        )
    } finally {
        writableExecutableFile.delete()
    }

    return runnableExecutablePath
}

/**
 * Returns true if the shell session is rooted, and thus root commands can be run (e.g. atrace
 * commands with root-only tags)
 */
internal fun UiDevice.isShellSessionRooted(): Boolean {
    return executeShellCommand("getprop service.adb.root").trim() == "1"
}

private fun UiDevice.moveToTmpAndMakeExecutable(src: String, dst: String) {
    // Note: we don't check for return values from the below, since shell based file
    // permission errors generally crash our process.
    executeShellCommand("cp $src $dst")
    executeShellCommand("chmod +x $dst")
}