Merge "Add API to selectively redirect VM logs to apps"
diff --git a/demo/java/com/android/microdroid/demo/MainActivity.java b/demo/java/com/android/microdroid/demo/MainActivity.java
index c3fa55f..aab4148 100644
--- a/demo/java/com/android/microdroid/demo/MainActivity.java
+++ b/demo/java/com/android/microdroid/demo/MainActivity.java
@@ -246,6 +246,7 @@
                 builder.setProtectedVm(true);
                 if (debug) {
                     builder.setDebugLevel(VirtualMachineConfig.DEBUG_LEVEL_FULL);
+                    builder.setVmOutputCaptured(true);
                 }
                 VirtualMachineConfig config = builder.build();
                 VirtualMachineManager vmm =
@@ -261,10 +262,12 @@
                 mVirtualMachine.setCallback(Executors.newSingleThreadExecutor(), callback);
                 mStatus.postValue(mVirtualMachine.getStatus());
 
-                InputStream console = mVirtualMachine.getConsoleOutput();
-                InputStream log = mVirtualMachine.getLogOutput();
-                mExecutorService.execute(new Reader("console", mConsoleOutput, console));
-                mExecutorService.execute(new Reader("log", mLogOutput, log));
+                if (debug) {
+                    InputStream console = mVirtualMachine.getConsoleOutput();
+                    InputStream log = mVirtualMachine.getLogOutput();
+                    mExecutorService.execute(new Reader("console", mConsoleOutput, console));
+                    mExecutorService.execute(new Reader("log", mLogOutput, log));
+                }
             } catch (VirtualMachineException e) {
                 throw new RuntimeException(e);
             }
diff --git a/javalib/api/system-current.txt b/javalib/api/system-current.txt
index 71bdf13..1977321 100644
--- a/javalib/api/system-current.txt
+++ b/javalib/api/system-current.txt
@@ -65,6 +65,7 @@
     method public boolean isCompatibleWith(@NonNull android.system.virtualmachine.VirtualMachineConfig);
     method public boolean isEncryptedStorageEnabled();
     method public boolean isProtectedVm();
+    method public boolean isVmOutputCaptured();
     field public static final int DEBUG_LEVEL_FULL = 1; // 0x1
     field public static final int DEBUG_LEVEL_NONE = 0; // 0x0
   }
@@ -79,6 +80,7 @@
     method @NonNull public android.system.virtualmachine.VirtualMachineConfig.Builder setNumCpus(@IntRange(from=1) int);
     method @NonNull public android.system.virtualmachine.VirtualMachineConfig.Builder setPayloadBinaryName(@NonNull String);
     method @NonNull public android.system.virtualmachine.VirtualMachineConfig.Builder setProtectedVm(boolean);
+    method @NonNull public android.system.virtualmachine.VirtualMachineConfig.Builder setVmOutputCaptured(boolean);
   }
 
   public final class VirtualMachineDescriptor implements android.os.Parcelable {
diff --git a/javalib/src/android/system/virtualmachine/VirtualMachine.java b/javalib/src/android/system/virtualmachine/VirtualMachine.java
index b57cb5e..1f0c8ea 100644
--- a/javalib/src/android/system/virtualmachine/VirtualMachine.java
+++ b/javalib/src/android/system/virtualmachine/VirtualMachine.java
@@ -293,6 +293,7 @@
     /** Lock protecting callbacks. */
     private final Object mCallbackLock = new Object();
 
+    private final boolean mVmOutputCaptured;
 
     /** The configuration that is currently associated with this VM. */
     @GuardedBy("mLock")
@@ -371,6 +372,7 @@
                         ? new File(thisVmDir, ENCRYPTED_STORE_FILE)
                         : null;
 
+        mVmOutputCaptured = config.isVmOutputCaptured();
     }
 
     /**
@@ -772,7 +774,9 @@
             IVirtualizationService service = mVirtualizationService.connect();
 
             try {
-                createVmPipes();
+                if (mVmOutputCaptured) {
+                    createVmPipes();
+                }
 
                 VirtualMachineAppConfig appConfig = getConfig().toVsConfig();
                 appConfig.name = mName;
@@ -890,17 +894,26 @@
     }
 
     /**
-     * Returns the stream object representing the console output from the virtual machine.
+     * Returns the stream object representing the console output from the virtual machine. The
+     * console output is only available if the {@link VirtualMachineConfig} specifies that it should
+     * be {@linkplain VirtualMachineConfig#isVmOutputCaptured captured}.
+     *
+     * <p>If you turn on output capture, you must consume data from {@code getConsoleOutput} -
+     * because otherwise the code in the VM may get blocked when the pipe buffer fills up.
      *
      * <p>NOTE: This method may block and should not be called on the main thread.
      *
-     * @throws VirtualMachineException if the stream could not be created.
+     * @throws VirtualMachineException if the stream could not be created, or capturing is turned
+     *     off.
      * @hide
      */
     @SystemApi
     @WorkerThread
     @NonNull
     public InputStream getConsoleOutput() throws VirtualMachineException {
+        if (!mVmOutputCaptured) {
+            throw new VirtualMachineException("Capturing vm outputs is turned off");
+        }
         synchronized (mLock) {
             createVmPipes();
             return new FileInputStream(mConsoleReader.getFileDescriptor());
@@ -908,17 +921,26 @@
     }
 
     /**
-     * Returns the stream object representing the log output from the virtual machine.
+     * Returns the stream object representing the log output from the virtual machine. The log
+     * output is only available if the VirtualMachineConfig specifies that it should be {@linkplain
+     * VirtualMachineConfig#isVmOutputCaptured captured}.
+     *
+     * <p>If you turn on output capture, you must consume data from {@code getLogOutput} - because
+     * otherwise the code in the VM may get blocked when the pipe buffer fills up.
      *
      * <p>NOTE: This method may block and should not be called on the main thread.
      *
-     * @throws VirtualMachineException if the stream could not be created.
+     * @throws VirtualMachineException if the stream could not be created, or capturing is turned
+     *     off.
      * @hide
      */
     @SystemApi
     @WorkerThread
     @NonNull
     public InputStream getLogOutput() throws VirtualMachineException {
+        if (!mVmOutputCaptured) {
+            throw new VirtualMachineException("Capturing vm outputs is turned off");
+        }
         synchronized (mLock) {
             createVmPipes();
             return new FileInputStream(mLogReader.getFileDescriptor());
diff --git a/javalib/src/android/system/virtualmachine/VirtualMachineConfig.java b/javalib/src/android/system/virtualmachine/VirtualMachineConfig.java
index d9fc70c..f5c3cd2 100644
--- a/javalib/src/android/system/virtualmachine/VirtualMachineConfig.java
+++ b/javalib/src/android/system/virtualmachine/VirtualMachineConfig.java
@@ -58,7 +58,7 @@
     private static final String[] EMPTY_STRING_ARRAY = {};
 
     // These define the schema of the config file persisted on disk.
-    private static final int VERSION = 2;
+    private static final int VERSION = 3;
     private static final String KEY_VERSION = "version";
     private static final String KEY_APKPATH = "apkPath";
     private static final String KEY_PAYLOADCONFIGPATH = "payloadConfigPath";
@@ -68,6 +68,7 @@
     private static final String KEY_MEMORY_MIB = "memoryMib";
     private static final String KEY_NUM_CPUS = "numCpus";
     private static final String KEY_ENCRYPTED_STORAGE_KIB = "encryptedStorageKib";
+    private static final String KEY_VM_OUTPUT_CAPTURED = "vmOutputCaptured";
 
     /** @hide */
     @Retention(RetentionPolicy.SOURCE)
@@ -124,6 +125,9 @@
     /** The size of storage in KiB. 0 indicates that encryptedStorage is not required */
     private final long mEncryptedStorageKib;
 
+    /** Whether the app can read console and log output. */
+    private final boolean mVmOutputCaptured;
+
     private VirtualMachineConfig(
             @NonNull String apkPath,
             @Nullable String payloadConfigPath,
@@ -132,7 +136,8 @@
             boolean protectedVm,
             int memoryMib,
             int numCpus,
-            long encryptedStorageKib) {
+            long encryptedStorageKib,
+            boolean vmOutputCaptured) {
         // This is only called from Builder.build(); the builder handles parameter validation.
         mApkPath = apkPath;
         mPayloadConfigPath = payloadConfigPath;
@@ -142,6 +147,7 @@
         mMemoryMib = memoryMib;
         mNumCpus = numCpus;
         mEncryptedStorageKib = encryptedStorageKib;
+        mVmOutputCaptured = vmOutputCaptured;
     }
 
     /** Loads a config from a file. */
@@ -210,6 +216,7 @@
         if (encryptedStorageKib != 0) {
             builder.setEncryptedStorageKib(encryptedStorageKib);
         }
+        builder.setVmOutputCaptured(b.getBoolean(KEY_VM_OUTPUT_CAPTURED));
 
         return builder.build();
     }
@@ -239,6 +246,7 @@
         if (mEncryptedStorageKib > 0) {
             b.putLong(KEY_ENCRYPTED_STORAGE_KIB, mEncryptedStorageKib);
         }
+        b.putBoolean(KEY_VM_OUTPUT_CAPTURED, mVmOutputCaptured);
         b.writeToStream(output);
     }
 
@@ -346,6 +354,18 @@
     }
 
     /**
+     * Returns whether the app can read the VM console or log output. If not, the VM output is
+     * automatically forwarded to the host logcat.
+     *
+     * @see #setVmOutputCaptured
+     * @hide
+     */
+    @SystemApi
+    public boolean isVmOutputCaptured() {
+        return mVmOutputCaptured;
+    }
+
+    /**
      * Tests if this config is compatible with other config. Being compatible means that the configs
      * can be interchangeably used for the same virtual machine; they do not change the VM identity
      * or secrets. Such changes include varying the number of CPUs or the size of the RAM. Changes
@@ -360,6 +380,7 @@
         return this.mDebugLevel == other.mDebugLevel
                 && this.mProtectedVm == other.mProtectedVm
                 && this.mEncryptedStorageKib == other.mEncryptedStorageKib
+                && this.mVmOutputCaptured == other.mVmOutputCaptured
                 && Objects.equals(this.mPayloadConfigPath, other.mPayloadConfigPath)
                 && Objects.equals(this.mPayloadBinaryName, other.mPayloadBinaryName)
                 && this.mApkPath.equals(other.mApkPath);
@@ -417,6 +438,7 @@
         private int mMemoryMib;
         private int mNumCpus = 1;
         private long mEncryptedStorageKib;
+        private boolean mVmOutputCaptured = false;
 
         /**
          * Creates a builder for the given context.
@@ -469,6 +491,10 @@
                 throw new IllegalStateException("setProtectedVm must be called explicitly");
             }
 
+            if (mVmOutputCaptured && mDebugLevel != DEBUG_LEVEL_FULL) {
+                throw new IllegalStateException("debug level must be FULL to capture output");
+            }
+
             return new VirtualMachineConfig(
                     apkPath,
                     mPayloadConfigPath,
@@ -477,7 +503,8 @@
                     mProtectedVm,
                     mMemoryMib,
                     mNumCpus,
-                    mEncryptedStorageKib);
+                    mEncryptedStorageKib,
+                    mVmOutputCaptured);
         }
 
         /**
@@ -654,5 +681,31 @@
             mEncryptedStorageKib = encryptedStorageKib;
             return this;
         }
+
+        /**
+         * Sets whether to allow the app to read the VM outputs (console / log). Default is {@code
+         * false}.
+         *
+         * <p>By default, console and log outputs of a {@linkplain #setDebugLevel debuggable} VM are
+         * automatically forwarded to the host logcat. Setting this as {@code true} will allow the
+         * app to directly read {@linkplain VirtualMachine#getConsoleOutput console output} and
+         * {@linkplain VirtualMachine#getLogOutput log output}, instead of forwarding them to the
+         * host logcat.
+         *
+         * <p>If you turn on output capture, you must consume data from {@link
+         * VirtualMachine#getConsoleOutput} and {@link VirtualMachine#getLogOutput} - because
+         * otherwise the code in the VM may get blocked when the pipe buffer fills up.
+         *
+         * <p>The {@linkplain #setDebugLevel debug level} must be {@link #DEBUG_LEVEL_FULL} to be
+         * set as true.
+         *
+         * @hide
+         */
+        @SystemApi
+        @NonNull
+        public Builder setVmOutputCaptured(boolean captured) {
+            mVmOutputCaptured = captured;
+            return this;
+        }
     }
 }
diff --git a/tests/benchmark/src/java/com/android/microdroid/benchmark/MicrodroidBenchmarks.java b/tests/benchmark/src/java/com/android/microdroid/benchmark/MicrodroidBenchmarks.java
index 9e4b228..40114fd 100644
--- a/tests/benchmark/src/java/com/android/microdroid/benchmark/MicrodroidBenchmarks.java
+++ b/tests/benchmark/src/java/com/android/microdroid/benchmark/MicrodroidBenchmarks.java
@@ -211,6 +211,7 @@
                     newVmConfigBuilder()
                             .setPayloadBinaryName("MicrodroidIdleNativeLib.so")
                             .setDebugLevel(DEBUG_LEVEL_FULL)
+                            .setVmOutputCaptured(true)
                             .setMemoryMib(256)
                             .build();
             forceCreateNewVirtualMachine("test_vm_boot_time_debug", normalConfig);
diff --git a/tests/helper/src/java/com/android/microdroid/test/device/MicrodroidDeviceTestBase.java b/tests/helper/src/java/com/android/microdroid/test/device/MicrodroidDeviceTestBase.java
index d762310..e8a36ce 100644
--- a/tests/helper/src/java/com/android/microdroid/test/device/MicrodroidDeviceTestBase.java
+++ b/tests/helper/src/java/com/android/microdroid/test/device/MicrodroidDeviceTestBase.java
@@ -212,9 +212,11 @@
                 throws VirtualMachineException, InterruptedException {
             vm.setCallback(mExecutorService, this);
             vm.run();
-            logVmOutputAndMonitorBootEvents(
-                    logTag, vm.getConsoleOutput(), "Console", mConsoleOutput);
-            logVmOutput(logTag, vm.getLogOutput(), "Log", mLogOutput);
+            if (vm.getConfig().isVmOutputCaptured()) {
+                logVmOutputAndMonitorBootEvents(
+                        logTag, vm.getConsoleOutput(), "Console", mConsoleOutput);
+                logVmOutput(logTag, vm.getLogOutput(), "Log", mLogOutput);
+            }
             mExecutorService.awaitTermination(300, TimeUnit.SECONDS);
         }
 
diff --git a/tests/testapk/src/java/com/android/microdroid/test/MicrodroidTests.java b/tests/testapk/src/java/com/android/microdroid/test/MicrodroidTests.java
index d8e74f7..5c3be5f 100644
--- a/tests/testapk/src/java/com/android/microdroid/test/MicrodroidTests.java
+++ b/tests/testapk/src/java/com/android/microdroid/test/MicrodroidTests.java
@@ -153,6 +153,7 @@
                         .setPayloadBinaryName("MicrodroidTestNativeLib.so")
                         .setMemoryMib(minMemoryRequired())
                         .setDebugLevel(DEBUG_LEVEL_NONE)
+                        .setVmOutputCaptured(false)
                         .build();
         VirtualMachine vm = forceCreateNewVirtualMachine("test_vm", config);
 
@@ -332,18 +333,21 @@
         assertThat(minimal.isProtectedVm()).isEqualTo(isProtectedVm());
         assertThat(minimal.isEncryptedStorageEnabled()).isFalse();
         assertThat(minimal.getEncryptedStorageKib()).isEqualTo(0);
+        assertThat(minimal.isVmOutputCaptured()).isEqualTo(false);
 
         // Maximal has everything that can be set to some non-default value. (And has different
         // values than minimal for the required fields.)
         int maxCpus = Runtime.getRuntime().availableProcessors();
         VirtualMachineConfig.Builder maximalBuilder =
-                newVmConfigBuilder()
+                new VirtualMachineConfig.Builder(getContext())
+                        .setProtectedVm(mProtectedVm)
                         .setPayloadConfigPath("config/path")
                         .setApkPath("/apk/path")
                         .setNumCpus(maxCpus)
                         .setDebugLevel(DEBUG_LEVEL_FULL)
                         .setMemoryMib(42)
-                        .setEncryptedStorageKib(1024);
+                        .setEncryptedStorageKib(1024)
+                        .setVmOutputCaptured(true);
         VirtualMachineConfig maximal = maximalBuilder.build();
 
         assertThat(maximal.getApkPath()).isEqualTo("/apk/path");
@@ -355,6 +359,7 @@
         assertThat(maximal.isProtectedVm()).isEqualTo(isProtectedVm());
         assertThat(maximal.isEncryptedStorageEnabled()).isTrue();
         assertThat(maximal.getEncryptedStorageKib()).isEqualTo(1024);
+        assertThat(maximal.isVmOutputCaptured()).isEqualTo(true);
 
         assertThat(minimal.isCompatibleWith(maximal)).isFalse();
         assertThat(minimal.isCompatibleWith(minimal)).isTrue();
@@ -392,6 +397,14 @@
                 new VirtualMachineConfig.Builder(getContext()).setPayloadBinaryName("binary.so");
         e = assertThrows(IllegalStateException.class, () -> protectedNotSet.build());
         assertThat(e).hasMessageThat().contains("setProtectedVm must be called");
+
+        VirtualMachineConfig.Builder captureOutputOnNonDebuggable =
+                newVmConfigBuilder()
+                        .setPayloadBinaryName("binary.so")
+                        .setDebugLevel(VirtualMachineConfig.DEBUG_LEVEL_NONE)
+                        .setVmOutputCaptured(true);
+        e = assertThrows(IllegalStateException.class, () -> captureOutputOnNonDebuggable.build());
+        assertThat(e).hasMessageThat().contains("debug level must be FULL to capture output");
     }
 
     private VirtualMachineConfig.Builder newBaselineBuilder() {
@@ -431,6 +444,11 @@
         assertConfigCompatible(baseline, newBaselineBuilder().setApkPath("/different")).isFalse();
         assertConfigCompatible(baseline, newBaselineBuilder().setEncryptedStorageKib(100))
                 .isFalse();
+
+        VirtualMachineConfig.Builder debuggableBuilder =
+                newBaselineBuilder().setDebugLevel(DEBUG_LEVEL_FULL);
+        VirtualMachineConfig debuggable = debuggableBuilder.build();
+        assertConfigCompatible(debuggable, debuggableBuilder.setVmOutputCaptured(true)).isFalse();
     }
 
     private BooleanSubject assertConfigCompatible(
@@ -662,6 +680,7 @@
                             .setPayloadBinaryName("MicrodroidTestNativeLib.so")
                             .setMemoryMib(memMib)
                             .setDebugLevel(DEBUG_LEVEL_NONE)
+                            .setVmOutputCaptured(false)
                             .build();
             VirtualMachine vm = forceCreateNewVirtualMachine("low_mem", lowMemConfig);
             final CompletableFuture<Boolean> onPayloadReadyExecuted = new CompletableFuture<>();
@@ -704,7 +723,8 @@
         VirtualMachineConfig.Builder builder =
                 newVmConfigBuilder()
                         .setPayloadBinaryName("MicrodroidTestNativeLib.so")
-                        .setDebugLevel(fromLevel);
+                        .setDebugLevel(fromLevel)
+                        .setVmOutputCaptured(false);
         VirtualMachineConfig normalConfig = builder.build();
         forceCreateNewVirtualMachine("test_vm", normalConfig);
         assertThat(tryBootVm(TAG, "test_vm").payloadStarted).isTrue();
@@ -1031,9 +1051,12 @@
 
     @Test
     public void bootFailsWhenBinaryIsMissingEntryFunction() throws Exception {
-        VirtualMachineConfig.Builder builder =
-                newVmConfigBuilder().setPayloadBinaryName("MicrodroidEmptyNativeLib.so");
-        VirtualMachineConfig normalConfig = builder.setDebugLevel(DEBUG_LEVEL_FULL).build();
+        VirtualMachineConfig normalConfig =
+                newVmConfigBuilder()
+                        .setPayloadBinaryName("MicrodroidEmptyNativeLib.so")
+                        .setDebugLevel(DEBUG_LEVEL_FULL)
+                        .setVmOutputCaptured(true)
+                        .build();
         VirtualMachine vm = forceCreateNewVirtualMachine("test_vm_missing_entry", normalConfig);
 
         assertThatPayloadFailsDueTo(vm, "Failed to find entrypoint");
@@ -1041,9 +1064,12 @@
 
     @Test
     public void bootFailsWhenBinaryTriesToLinkAgainstPrivateLibs() throws Exception {
-        VirtualMachineConfig.Builder builder =
-                newVmConfigBuilder().setPayloadBinaryName("MicrodroidPrivateLinkingNativeLib.so");
-        VirtualMachineConfig normalConfig = builder.setDebugLevel(DEBUG_LEVEL_FULL).build();
+        VirtualMachineConfig normalConfig =
+                newVmConfigBuilder()
+                        .setPayloadBinaryName("MicrodroidPrivateLinkingNativeLib.so")
+                        .setDebugLevel(DEBUG_LEVEL_FULL)
+                        .setVmOutputCaptured(true)
+                        .build();
         VirtualMachine vm = forceCreateNewVirtualMachine("test_vm_private_linking", normalConfig);
 
         assertThatPayloadFailsDueTo(vm, "Failed to dlopen");
@@ -1233,6 +1259,29 @@
         assertThat(testResults.mFileContent).isEqualTo("Hello, I am a file!");
     }
 
+    @Test
+    public void outputsShouldBeExplicitlyForwarded() throws Exception {
+        assumeSupportedKernel();
+
+        final VirtualMachineConfig vmConfig =
+                new VirtualMachineConfig.Builder(ApplicationProvider.getApplicationContext())
+                        .setProtectedVm(mProtectedVm)
+                        .setPayloadBinaryName("MicrodroidTestNativeLib.so")
+                        .setDebugLevel(DEBUG_LEVEL_FULL)
+                        .build();
+        final VirtualMachine vm = forceCreateNewVirtualMachine("test_vm_forward_log", vmConfig);
+        vm.run();
+
+        try {
+            assertThrowsVmExceptionContaining(
+                    () -> vm.getConsoleOutput(), "Capturing vm outputs is turned off");
+            assertThrowsVmExceptionContaining(
+                    () -> vm.getLogOutput(), "Capturing vm outputs is turned off");
+        } finally {
+            vm.stop();
+        }
+    }
+
     private void assertFileContentsAreEqualInTwoVms(String fileName, String vmName1, String vmName2)
             throws IOException {
         File file1 = getVmFile(vmName1, fileName);