TracingReceiver.kt

/*
 * Copyright 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 androidx.tracing.perfetto

import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.os.Build
import android.util.JsonWriter
import androidx.annotation.RestrictTo
import androidx.annotation.RestrictTo.Scope.LIBRARY
import androidx.tracing.perfetto.Tracing.EnableTracingResponse
import androidx.tracing.perfetto.PerfettoHandshake.EnableTracingResponse
import androidx.tracing.perfetto.PerfettoHandshake.RequestKeys.ACTION_ENABLE_TRACING
import androidx.tracing.perfetto.PerfettoHandshake.RequestKeys.KEY_PATH
import androidx.tracing.perfetto.PerfettoHandshake.ResponseExitCodes.RESULT_CODE_ERROR_OTHER
import androidx.tracing.perfetto.PerfettoHandshake.ResponseKeys
import java.io.File
import java.io.StringWriter
import java.util.concurrent.LinkedBlockingQueue
import java.util.concurrent.ThreadPoolExecutor
import java.util.concurrent.TimeUnit

/** Allows for enabling tracing in an app using a broadcast. @see [ACTION_ENABLE_TRACING] */
@RestrictTo(LIBRARY)
class TracingReceiver : BroadcastReceiver() {
    private val executor by lazy {
        ThreadPoolExecutor(
            /* corePoolSize = */ 0,
            /* maximumPoolSize = */ 1,
            /* keepAliveTime = */ 10, // gives time for tooling to side-load the .so file
            /* unit = */ TimeUnit.SECONDS,
            /* workQueue = */ LinkedBlockingQueue()
        )
    }

    // TODO: check value on app start
    override fun onReceive(context: Context?, intent: Intent?) {
        if (intent == null || intent.action != ACTION_ENABLE_TRACING) return

        // Path to the provided library binary file (optional). If not provided, local library files
        // will be used if present.
        val srcPath = intent.extras?.getString(KEY_PATH)

        val pendingResult = goAsync()

        executor.execute {
            try {
                val response = enableTracing(srcPath, context)
                pendingResult.setResult(response.exitCode, response.toJsonString(), null)
            } finally {
                pendingResult.finish()
            }
        }
    }

    private fun enableTracing(srcPath: String?, context: Context?): EnableTracingResponse =
        when {
            Build.VERSION.SDK_INT < Build.VERSION_CODES.R -> {
                // TODO(234351579): Support API < 30
                EnableTracingResponse(
                    RESULT_CODE_ERROR_OTHER,
                    "SDK version not supported. Current minimum SDK = ${Build.VERSION_CODES.R}"
                )
            }
            srcPath != null && context != null -> {
                try {
                    val dstFile = copyExternalLibraryFile(context, srcPath)
                    Tracing.enable(dstFile, context)
                } catch (e: Exception) {
                    EnableTracingResponse(RESULT_CODE_ERROR_OTHER, e)
                }
            }
            srcPath != null && context == null -> {
                EnableTracingResponse(
                    RESULT_CODE_ERROR_OTHER,
                    "Cannot copy source file: $srcPath without access to a Context instance."
                )
            }
            else -> {
                // Library path was not provided, trying to resolve using app's local library files.
                Tracing.enable()
            }
        }

    private fun copyExternalLibraryFile(
        context: Context,
        srcPath: String
    ): File {
        // Prepare a location to copy the library into with the following properties:
        // 1) app has exclusive write access in
        // 2) app can load binaries from
        val abi: String = File(context.applicationInfo.nativeLibraryDir).name // e.g. arm64
        val dstDir = context.cacheDir.resolve("lib/$abi")
        dstDir.mkdirs()

        // Copy the library file over
        //
        // TODO: load into memory and verify in-memory to prevent from copying a malicious
        // library into app's local files. Use SHA or Signature to verify the binaries.
        val srcFile = File(srcPath)
        val dstFile = dstDir.resolve(srcFile.name)
        srcFile.copyTo(dstFile, overwrite = true)

        return dstFile
    }

    private fun EnableTracingResponse.toJsonString(): String {
        val output = StringWriter()

        JsonWriter(output).use {
            it.beginObject()

            it.name(ResponseKeys.KEY_EXIT_CODE)
            it.value(exitCode)

            it.name(ResponseKeys.KEY_REQUIRED_VERSION)
            it.value(requiredVersion)

            message?.let { msg ->
                it.name(ResponseKeys.KEY_MESSAGE)
                it.value(msg)
            }

            it.endObject()
        }

        return output.toString()
    }
}