MacrobenchmarkScope.kt

/*
 * Copyright 2021 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

import android.content.Context
import android.content.Intent
import android.os.Build
import android.util.Log
import androidx.annotation.RequiresApi
import androidx.benchmark.DeviceInfo
import androidx.benchmark.Shell
import androidx.benchmark.macro.MacrobenchmarkScope.Companion.Api24Helper.shaderDir
import androidx.benchmark.macro.perfetto.forceTrace
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.uiautomator.UiDevice
import androidx.tracing.trace
import java.io.File

/**
 * Provides access to common operations in app automation, such as killing the app,
 * or navigating home.
 */
public class MacrobenchmarkScope(
    /**
     * ApplicationId / Package name of the app being tested.
     */
    val packageName: String,
    /**
     * Controls whether launches will automatically set [Intent.FLAG_ACTIVITY_CLEAR_TASK].
     *
     * Default to true, so Activity launches go through full creation lifecycle stages, instead of
     * just resume.
     */
    private val launchWithClearTask: Boolean
) {
    private val instrumentation = InstrumentationRegistry.getInstrumentation()
    private val context = instrumentation.context

    /**
     * Current Macrobenchmark measurement iteration, or null if measurement is not yet enabled.
     *
     * Non-measurement iterations can occur due to warmup a [CompilationMode], or prior to the first
     * iteration for [StartupMode.WARM] or [StartupMode.HOT], to create the Process or Activity
     * ahead of time.
     */
    @get:Suppress("AutoBoxing") // low frequency, non-perf-relevant part of test
    var iteration: Int? = null
        internal set

    /**
     * Get the [UiDevice] instance, to use in reading target app UI state, or interacting with the
     * UI via touches, scrolls, or other inputs.
     *
     * Convenience for `UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())`
     */
    val device: UiDevice = UiDevice.getInstance(instrumentation)

    /**
     * Start an activity, by default the launcher activity of the package, and wait until
     * its launch completes.
     *
     * This call will ignore any parcelable extras on the intent, as the start is performed by
     * converting the Intent to a URI, and starting via `am start` shell command. Note that from
     * api 33 the launch intent needs to have category {@link android.intent.category.LAUNCHER}.
     *
     * @throws IllegalStateException if unable to acquire intent for package.
     *
     * @param block Allows customization of the intent used to launch the activity.
     */
    @JvmOverloads
    public fun startActivityAndWait(
        block: (Intent) -> Unit = {}
    ) {
        val intent = context.packageManager.getLaunchIntentForPackage(packageName)
            ?: context.packageManager.getLeanbackLaunchIntentForPackage(packageName)
            ?: throw IllegalStateException("Unable to acquire intent for package $packageName")

        block(intent)
        startActivityAndWait(intent)
    }

    /**
     * Start an activity with the provided intent, and wait until its launch completes.
     *
     * This call will ignore any parcelable extras on the intent, as the start is performed by
     * converting the Intent to a URI, and starting via `am start` shell command.
     *
     * @param intent Specifies which app/Activity should be launched.
     */
    public fun startActivityAndWait(intent: Intent): Unit = forceTrace("startActivityAndWait") {
        // Must launch with new task, as we're not launching from an existing task
        intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
        if (launchWithClearTask) {
            intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK)
        }

        // Note: intent.toUri(0) produces a String that can't be parsed by `am start-activity`.
        // intent.toUri(Intent.URI_ANDROID_APP_SCHEME) also works though.
        startActivityImpl(intent.toUri(Intent.URI_INTENT_SCHEME))
    }

    private fun startActivityImpl(uri: String) {
        val ignoredUniqueNames = if (!launchWithClearTask) {
            emptyList()
        } else {
            // ignore existing names, as we expect a new window
            getFrameStats().map { it.uniqueName }
        }
        val preLaunchTimestampNs = System.nanoTime()

        val cmd = "am start -W \"$uri\""
        Log.d(TAG, "Starting activity with command: $cmd")

        // executeShellScript used to access stderr, and avoid need to escape special chars like `;`
        val result = Shell.executeScriptCaptureStdoutStderr(cmd)

        val outputLines = result.stdout
            .split("\n")
            .map { it.trim() }

        // Check for errors
        outputLines.forEach {
            if (it.startsWith("Error:")) {
                throw IllegalStateException(it)
            }
        }

        if (result.stderr.contains("java.lang.SecurityException")) {
            throw SecurityException(result.stderr)
        }
        if (result.stderr.isNotEmpty()) {
            throw IllegalStateException(result.stderr)
        }

        Log.d(TAG, "Result: ${result.stdout}")

        if (outputLines.any { it.startsWith("Warning: Activity not started") }) {
            // Intent was sent to running activity, which may not produce a new frame.
            // Since we can't be sure, simply sleep and hope launch has completed.
            Log.d(TAG, "Unable to safely detect Activity launch, waiting 2s")
            Thread.sleep(2000)
            return
        }

        // `am start -W` doesn't reliably wait for process to complete and renderthread to produce
        // a new frame (b/226179160), so we use `dumpsys gfxinfo <package> framestats` to determine
        // when the next frame is produced.
        var lastFrameStats: List<FrameStatsResult> = emptyList()
        repeat(100) {
            lastFrameStats = getFrameStats()
            if (lastFrameStats.any {
                    it.uniqueName !in ignoredUniqueNames &&
                        it.lastFrameNs != null &&
                        it.lastFrameNs > preLaunchTimestampNs
                }) {
                return // success, launch observed!
            }

            trace("wait for $packageName to draw") {
                // Note - sleep must not be long enough to miss activity initial draw in 120 frame
                // internal ring buffer of `dumpsys gfxinfo <pkg> framestats`.
                Thread.sleep(100)
            }
        }
        throw IllegalStateException("Unable to confirm activity launch completion $lastFrameStats" +
            " Please report a bug with the output of" +
            " `adb shell dumpsys gfxinfo $packageName framestats`")
    }

    /**
     * Uses `dumpsys gfxinfo <pkg> framestats` to detect the initial timestamp of the most recently
     * completed (fully rendered) activity launch frame.
     */
    internal fun getFrameStats(): List<FrameStatsResult> {
        // iterate through each subprocess, since UI may not be in primary process
        return Shell.getRunningProcessesForPackage(packageName).flatMap { processName ->
            val frameStatsOutput = trace("dumpsys gfxinfo framestats") {
                // we use framestats here because it gives us not just frame counts, but actual
                // timestamps for new activity starts. Frame counts would mostly work, but would
                // have false positives if some window of the app is still animating/rendering.
                Shell.executeScriptCaptureStdout("dumpsys gfxinfo $processName framestats")
            }
            FrameStatsResult.parse(frameStatsOutput)
        }
    }

    /**
     * Perform a home button click.
     *
     * Useful for resetting the test to a base condition in cases where the app isn't killed in
     * each iteration.
     */
    @JvmOverloads
    public fun pressHome(delayDurationMs: Long = 0) {
        device.pressHome()

        // This delay is unnecessary, since UiAutomator's pressHome already waits for device idle.
        // This sleep remains just for API stability.
        Thread.sleep(delayDurationMs)
    }

    /**
     * Force-stop the process being measured.
     *
     *@param useKillAll should be set to `true` for System apps or pre-installed apps.
     */
    @JvmOverloads
    public fun killProcess(useKillAll: Boolean = false) {
        Log.d(TAG, "Killing process $packageName")
        if (useKillAll) {
            device.executeShellCommand("killall $packageName")
        } else {
            device.executeShellCommand("am force-stop $packageName")
        }
    }

    /**
     * Deletes the shader cache for an application.
     *
     * Used by `measureRepeated(startupMode = StartupMode.COLD)` to remove compiled shaders for each
     * measurement, to ensure their cost is captured each time.
     *
     * Requires `profileinstaller` 1.3.0-alpha02 to be used by the target, or a rooted device.
     *
     * @throws IllegalStateException if the device is not rooted, and the target app cannot be
     * signalled to drop its shader cache.
     */
    public fun dropShaderCache() {
        Log.d(TAG, "Dropping shader cache for $packageName")
        val dropError = ProfileInstallBroadcast.dropShaderCache(packageName)
        if (dropError != null) {
            if (Shell.isSessionRooted()) {
                // fall back to root approach
                val path = getShaderCachePath(packageName)
                Shell.executeScriptSilent("find $path -type f | xargs rm")
            } else {
                throw IllegalStateException(dropError)
            }
        }
    }

    /**
     * Drop caches via setprop added in API 31
     *
     * Feature for dropping caches without root added in 31: https://r.android.com/1584525
     * Passing 3 will cause caches to be dropped, and prop will go back to 0 when it's done
     */
    @RequiresApi(31)
    private fun dropKernelPageCacheSetProp() {
        val result = Shell.executeScriptCaptureStdoutStderr("setprop perf.drop_caches 3")
        check(result.stdout.isEmpty() && result.stderr.isEmpty()) {
            "Failed to trigger drop cache via setprop: $result"
        }
        // Polling duration is very conservative, on Pixel 4L finishes in ~150ms
        repeat(50) {
            Thread.sleep(50)
            when (val getPropResult = Shell.getprop("perf.drop_caches")) {
                "0" -> return // completed!
                "3" -> {} // not completed, continue
                else -> throw IllegalStateException(
                    "Unable to drop caches: Failed to read drop cache via getprop: $getPropResult"
                )
            }
        }
        throw IllegalStateException(
            "Unable to drop caches: Did not observe perf.drop_caches reset automatically"
        )
    }

    /**
     * Drop Kernel's in-memory cache of disk pages.
     *
     * Enables measuring disk-based startup cost, without simply accessing cache of disk data
     * held in memory, such as during [cold startup](androidx.benchmark.macro.StartupMode.COLD).
     *
     * @Throws IllegalStateException if dropping the cache fails on a API 31+ or rooted device,
     * where it is expected to work.
     */
    public fun dropKernelPageCache() {
        if (Build.VERSION.SDK_INT >= 31) {
            dropKernelPageCacheSetProp()
        } else {
            val result = Shell.executeScriptCaptureStdoutStderr(
                "echo 3 > /proc/sys/vm/drop_caches && echo Success || echo Failure"
            )
            // Older user builds don't allow drop caches, should investigate workaround
            if (result.stdout.trim() != "Success") {
                if (DeviceInfo.isRooted && !Shell.isSessionRooted()) {
                    throw IllegalStateException("Failed to drop caches - run `adb root`")
                }
                Log.w(TAG, "Failed to drop kernel page cache, result: '$result'")
            }
        }
    }

    internal companion object {
        fun getShaderCachePath(packageName: String): String {
            val context = InstrumentationRegistry.getInstrumentation().context

            // Shader paths sourced from ActivityThread.java
            return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
                context.shaderDir
            } else {
                // getCodeCacheDir was added in L, but not used by platform for shaders until M
                // as M is minApi of this library, that's all we support here
                context.codeCacheDir
            }.absolutePath.replace(context.packageName, packageName)
        }

        @RequiresApi(Build.VERSION_CODES.N)
        internal object Api24Helper {
            val Context.shaderDir: File
                get() =
                    // shaders started using device protected storage context once it was added in N
                    createDeviceProtectedStorageContext().codeCacheDir
        }
    }
}