InstrumentationCoverageReporter.java

/*
 * Copyright (C) 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.test.internal.runner.coverage;

import android.app.Instrumentation;
import android.util.Log;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import androidx.test.platform.io.PlatformTestStorage;
import androidx.test.services.storage.TestStorage;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.io.PrintStream;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.nio.channels.Channels;
import java.nio.channels.FileChannel;
import java.nio.channels.WritableByteChannel;

/** A class that generates the JaCoCo execution data in Android Instrumentation tests. */
public class InstrumentationCoverageReporter {
  private static final String TAG = InstrumentationCoverageReporter.class.getSimpleName();

  private static final String EMMA_RUNTIME_CLASS = "com.vladium.emma.rt.RT";
  private static final String DEFAULT_COVERAGE_FILE_NAME = "coverage.ec";

  private final Instrumentation instrumentation;
  private final PlatformTestStorage testStorage;

  /**
   * Constructor.
   *
   * @param instrumentation the instrumentation instance. Must not be {@code null}.
   * @param testStorage the {@code PlatformTestStorage} to dump the coverage execution data onto the
   *     device.
   */
  public InstrumentationCoverageReporter(
      Instrumentation instrumentation, PlatformTestStorage testStorage) {
    this.instrumentation = instrumentation;
    this.testStorage = testStorage;
  }

  /**
   * Generates the JaCoCo execution data report in the specified file path. A default file path will
   * be used if no file path was provided, depending on whether the test storage service is
   * available:
   *
   * <ul>
   *   <li>If the test storage service is available, the coverage file will be generated as
   *       coverage.ec under the test storage managed internal directory.
   *   <li>Otherwise, the coverage file will be generated under the test app's file folder, i.e.
   *       /data/data/<app-package>/files/coverage.ec.
   * </ul>
   *
   * <p>Note, when the test storage service is not available, the caller of this method is
   * responsible to make sure the given file path is writable.
   *
   * @param coverageFilePath the file path to generate the coverage data report. This is a relative
   *     path when the test storage service is available, otherwise an absolute path on the device.
   *     Can be {@code null}.
   * @param instrumentationResultWriter the writer that can be used to write to the Instrumentation
   *     summary result.
   * @return the actual file path the coverage report was written to, when the provided path is
   *     {@code null}.
   */
  public String generateCoverageReport(
      @Nullable String coverageFilePath, PrintStream instrumentationResultWriter) {
    // Unfortunately, the JaCoCo (Emma-compatible) API only supports dumping the execution data to a
    // `File`, rather than accepting an `OutputStream`. Worth looking JaCoCo's newer API [1] which
    // supports obtaining the execution data directly, and writing using the `PlatformTestStorage`
    // without inspecting its implementation/instance.
    // [1]
    // https://www.jacoco.org/jacoco/trunk/doc/api/org/jacoco/agent/rt/IAgent.html#getExecutionData(boolean).
    if (testStorage instanceof TestStorage) {
      coverageFilePath = dumpCoverageToTestStorage(coverageFilePath, instrumentationResultWriter);
    } else {
      coverageFilePath = dumpCoverageToFile(coverageFilePath, instrumentationResultWriter);
    }
    Log.d(TAG, "Coverage file was generated to " + coverageFilePath);
    // Also outputs a more user friendly message.
    instrumentationResultWriter.format("\nGenerated code coverage data to %s", coverageFilePath);
    return coverageFilePath;
  }

  /**
   * Directly write the coverage execution data to file when the test storage service is not
   * installed on the device.
   */
  private String dumpCoverageToFile(
      String coverageFilePath, PrintStream instrumentationResultWriter) {
    if (coverageFilePath == null) {
      Log.d(TAG, "No coverage file path was specified. Dumps to the default file path.");
      coverageFilePath =
          instrumentation.getTargetContext().getFilesDir().getAbsolutePath()
              + File.separator
              + DEFAULT_COVERAGE_FILE_NAME;
    }

    if (!generateCoverageInternal(coverageFilePath, instrumentationResultWriter)) {
      Log.w(
          TAG,
          "Failed to generate the coverage data file. Please refer to the instrumentation result"
              + " for more info.");
    }
    return coverageFilePath;
  }

  /**
   * Dumps the coverage execution data to file and then moves it to the test storage internal
   * folder.
   */
  private String dumpCoverageToTestStorage(
      String coverageFilePath, PrintStream instrumentationResultWriter) {
    if (coverageFilePath == null) {
      Log.d(
          TAG,
          "No coverage file path was specified. Dumps to the default coverage file using test"
              + " storage.");
      coverageFilePath = DEFAULT_COVERAGE_FILE_NAME;
    }

    String tempCoverageFilePath =
        instrumentation.getTargetContext().getFilesDir().getAbsolutePath()
            + File.separator
            + DEFAULT_COVERAGE_FILE_NAME;
    if (!generateCoverageInternal(tempCoverageFilePath, instrumentationResultWriter)) {
      Log.w(
          TAG,
          "Failed to generate the coverage data file. Please refer to the instrumentation result"
              + " for more info.");
    }

    try {
      Log.d(
          TAG,
          "Test service is available. Moving the coverage data file to be managed by the storage"
              + " service.");
      moveFileToTestStorage(tempCoverageFilePath, coverageFilePath);
      return coverageFilePath;
    } catch (IOException e) {
      reportEmmaError(instrumentationResultWriter, e);
    }
    return null;
  }

  private void moveFileToTestStorage(String srcFilePath, String destFilePath) throws IOException {
    File srcFile = new File(srcFilePath);
    if (srcFile.exists()) {
      Log.d(
          TAG,
          String.format(
              "Moving coverage file [%s] to the internal test storage [%s].",
              srcFilePath, destFilePath));
      try (OutputStream outputStream = testStorage.openInternalOutputFile(destFilePath);
          FileChannel srcChannel = new FileInputStream(srcFilePath).getChannel();
          WritableByteChannel destChannel = Channels.newChannel(outputStream)) {
        srcChannel.transferTo(0 /* position */, srcChannel.size() /* count */, destChannel);
      }
      if (!srcFile.delete()) {
        Log.e(
            TAG,
            String.format(
                "Failed to delete original coverage file [%s]", srcFile.getAbsolutePath()));
      }
    }
  }

  /**
   * Uses the JaCoCo agent to dump the execution data file to the given file path.
   *
   * @return true if the coverage data was successfully generated, false otherwise.
   */
  // Has to be `public` so that Mockito could properly stub it in testing.
  @VisibleForTesting
  public boolean generateCoverageInternal(
      String coverageFilePath, PrintStream instrumentationResultWriter) {
    java.io.File coverageFile = new java.io.File(coverageFilePath);

    try {
      // In case the target and instrumentation contexts are different, prioritize coverage from the
      // target context. If the target context classloader implements a delegate-first strategy
      // for org.jacoco.agent.rt and com.vladium.emma.rt, it will still be possible to get coverage
      // from either, or both, contexts.
      Class<?> emmaRTClass;
      try {
        emmaRTClass =
            Class.forName(
                EMMA_RUNTIME_CLASS, true, instrumentation.getTargetContext().getClassLoader());
      } catch (ClassNotFoundException e) {
        emmaRTClass =
            Class.forName(EMMA_RUNTIME_CLASS, true, instrumentation.getContext().getClassLoader());
        String msg = "Generating coverage for alternate test context.";
        Log.w(TAG, msg);
        instrumentationResultWriter.format("\nWarning: %s", msg);
      }

      // Uses reflection to call emma dump coverage method, to avoid always statically compiling
      // against the JaCoCo jar. The test infrastructure should make sure the JaCoCo library is
      // available when collecting the coverage data.
      Method dumpCoverageMethod =
          emmaRTClass.getMethod(
              "dumpCoverageData", coverageFile.getClass(), boolean.class, boolean.class);
      dumpCoverageMethod.invoke(null, coverageFile, false, false);
      return true;
    } catch (ClassNotFoundException e) {
      reportEmmaError(instrumentationResultWriter, "Is Emma/JaCoCo jar on classpath?", e);
    } catch (SecurityException
        | NoSuchMethodException
        | IllegalArgumentException
        | IllegalAccessException
        | InvocationTargetException e) {
      reportEmmaError(instrumentationResultWriter, e);
    }
    return false;
  }

  private void reportEmmaError(PrintStream writer, Exception e) {
    reportEmmaError(writer, "", e);
  }

  private void reportEmmaError(PrintStream writer, String hint, Exception e) {
    String msg = "Failed to generate Emma/JaCoCo coverage. " + hint;
    Log.e(TAG, msg, e);
    writer.format("\nError: %s", msg);
  }
}