Add TestApis for using console input

VirtualMachineConfig.setVmConsoleInputSupported: enable console input to
the Vm.

VirtualMachineConfig.isVmConsoleInputSupported: tests whether console
input to the VM is supported or not.

VirtualMachine.getConsoleInput: gets a output stream to the console
input to the VM.

Bug: 263360203
Test: atest MicrodroidTestApp:com.android.microdroid.test.MicrodroidTests#testConsoleInputSupported
Change-Id: I09b1c9c4b216fc887f747fd6cb9712c7b66b8754
diff --git a/javalib/api/test-current.txt b/javalib/api/test-current.txt
index 8b7ec11..1298000 100644
--- a/javalib/api/test-current.txt
+++ b/javalib/api/test-current.txt
@@ -2,15 +2,18 @@
 package android.system.virtualmachine {
 
   public class VirtualMachine implements java.lang.AutoCloseable {
+    method @NonNull @WorkerThread public java.io.OutputStream getConsoleInput() throws android.system.virtualmachine.VirtualMachineException;
     method @NonNull public java.io.File getRootDir();
   }
 
   public final class VirtualMachineConfig {
     method @Nullable public String getPayloadConfigPath();
+    method public boolean isVmConsoleInputSupported();
   }
 
   public static final class VirtualMachineConfig.Builder {
     method @NonNull @RequiresPermission(android.system.virtualmachine.VirtualMachine.USE_CUSTOM_VIRTUAL_MACHINE_PERMISSION) public android.system.virtualmachine.VirtualMachineConfig.Builder setPayloadConfigPath(@NonNull String);
+    method @NonNull public android.system.virtualmachine.VirtualMachineConfig.Builder setVmConsoleInputSupported(boolean);
   }
 
 }
diff --git a/javalib/src/android/system/virtualmachine/VirtualMachine.java b/javalib/src/android/system/virtualmachine/VirtualMachine.java
index 19f57fb..675a046 100644
--- a/javalib/src/android/system/virtualmachine/VirtualMachine.java
+++ b/javalib/src/android/system/virtualmachine/VirtualMachine.java
@@ -76,11 +76,13 @@
 
 import java.io.File;
 import java.io.FileInputStream;
+import java.io.FileOutputStream;
 import java.io.FileNotFoundException;
 import java.io.FileOutputStream;
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.InputStreamReader;
+import java.io.OutputStream;
 import java.lang.annotation.Retention;
 import java.lang.annotation.RetentionPolicy;
 import java.nio.channels.FileChannel;
@@ -294,6 +296,8 @@
 
     private final boolean mVmOutputCaptured;
 
+    private final boolean mVmConsoleInputSupported;
+
     /** The configuration that is currently associated with this VM. */
     @GuardedBy("mLock")
     @NonNull
@@ -306,11 +310,19 @@
 
     @GuardedBy("mLock")
     @Nullable
-    private ParcelFileDescriptor mConsoleReader;
+    private ParcelFileDescriptor mConsoleOutReader;
 
     @GuardedBy("mLock")
     @Nullable
-    private ParcelFileDescriptor mConsoleWriter;
+    private ParcelFileDescriptor mConsoleOutWriter;
+
+    @GuardedBy("mLock")
+    @Nullable
+    private ParcelFileDescriptor mConsoleInReader;
+
+    @GuardedBy("mLock")
+    @Nullable
+    private ParcelFileDescriptor mConsoleInWriter;
 
     @GuardedBy("mLock")
     @Nullable
@@ -372,6 +384,7 @@
                         : null;
 
         mVmOutputCaptured = config.isVmOutputCaptured();
+        mVmConsoleInputSupported = config.isVmConsoleInputSupported();
     }
 
     /**
@@ -787,7 +800,11 @@
 
             try {
                 if (mVmOutputCaptured) {
-                    createVmPipes();
+                    createVmOutputPipes();
+                }
+
+                if (mVmConsoleInputSupported) {
+                    createVmInputPipes();
                 }
 
                 VirtualMachineAppConfig appConfig =
@@ -804,8 +821,9 @@
                         android.system.virtualizationservice.VirtualMachineConfig.appConfig(
                                 appConfig);
 
-                // TODO(b/263360203): use consoleInFd also in Java (via private APIs)
-                mVirtualMachine = service.createVm(vmConfigParcel, mConsoleWriter, /* consoleInFd */ null, mLogWriter);
+                mVirtualMachine =
+                        service.createVm(
+                                vmConfigParcel, mConsoleOutWriter, mConsoleInReader, mLogWriter);
                 mVirtualMachine.registerCallback(new CallbackTranslator(service));
                 mContext.registerComponentCallbacks(mMemoryManagementCallbacks);
                 mVirtualMachine.start();
@@ -844,12 +862,12 @@
     }
 
     @GuardedBy("mLock")
-    private void createVmPipes() throws VirtualMachineException {
+    private void createVmOutputPipes() throws VirtualMachineException {
         try {
-            if (mConsoleReader == null || mConsoleWriter == null) {
+            if (mConsoleOutReader == null || mConsoleOutWriter == null) {
                 ParcelFileDescriptor[] pipe = ParcelFileDescriptor.createPipe();
-                mConsoleReader = pipe[0];
-                mConsoleWriter = pipe[1];
+                mConsoleOutReader = pipe[0];
+                mConsoleOutWriter = pipe[1];
             }
 
             if (mLogReader == null || mLogWriter == null) {
@@ -858,7 +876,20 @@
                 mLogWriter = pipe[1];
             }
         } catch (IOException e) {
-            throw new VirtualMachineException("Failed to create stream for VM", e);
+            throw new VirtualMachineException("Failed to create output stream for VM", e);
+        }
+    }
+
+    @GuardedBy("mLock")
+    private void createVmInputPipes() throws VirtualMachineException {
+        try {
+            if (mConsoleInReader == null || mConsoleInWriter == null) {
+                ParcelFileDescriptor[] pipe = ParcelFileDescriptor.createPipe();
+                mConsoleInReader = pipe[0];
+                mConsoleInWriter = pipe[1];
+            }
+        } catch (IOException e) {
+            throw new VirtualMachineException("Failed to create input stream for VM", e);
         }
     }
 
@@ -884,12 +915,37 @@
             throw new VirtualMachineException("Capturing vm outputs is turned off");
         }
         synchronized (mLock) {
-            createVmPipes();
-            return new FileInputStream(mConsoleReader.getFileDescriptor());
+            createVmOutputPipes();
+            return new FileInputStream(mConsoleOutReader.getFileDescriptor());
         }
     }
 
     /**
+     * Returns the stream object representing the console input to the virtual machine. The console
+     * input is only available if the {@link VirtualMachineConfig} specifies that it should be
+     * {@linkplain VirtualMachineConfig#isVmConsoleInputSupported supported}.
+     *
+     * <p>NOTE: This method may block and should not be called on the main thread.
+     *
+     * @throws VirtualMachineException if the stream could not be created, or console input is not
+     *     supported.
+     * @hide
+     */
+    @TestApi
+    @WorkerThread
+    @NonNull
+    public OutputStream getConsoleInput() throws VirtualMachineException {
+        if (!mVmConsoleInputSupported) {
+            throw new VirtualMachineException("VM console input is not supported");
+        }
+        synchronized (mLock) {
+            createVmInputPipes();
+            return new FileOutputStream(mConsoleInWriter.getFileDescriptor());
+        }
+    }
+
+
+    /**
      * 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}.
@@ -911,7 +967,7 @@
             throw new VirtualMachineException("Capturing vm outputs is turned off");
         }
         synchronized (mLock) {
-            createVmPipes();
+            createVmOutputPipes();
             return new FileInputStream(mLogReader.getFileDescriptor());
         }
     }
diff --git a/javalib/src/android/system/virtualmachine/VirtualMachineConfig.java b/javalib/src/android/system/virtualmachine/VirtualMachineConfig.java
index 93e65db..2ce2b62 100644
--- a/javalib/src/android/system/virtualmachine/VirtualMachineConfig.java
+++ b/javalib/src/android/system/virtualmachine/VirtualMachineConfig.java
@@ -76,6 +76,7 @@
     private static final String KEY_CPU_TOPOLOGY = "cpuTopology";
     private static final String KEY_ENCRYPTED_STORAGE_BYTES = "encryptedStorageBytes";
     private static final String KEY_VM_OUTPUT_CAPTURED = "vmOutputCaptured";
+    private static final String KEY_VM_CONSOLE_INPUT_SUPPORTED = "vmConsoleInputSupported";
 
     /** @hide */
     @Retention(RetentionPolicy.SOURCE)
@@ -164,6 +165,9 @@
     /** Whether the app can read console and log output. */
     private final boolean mVmOutputCaptured;
 
+    /** Whether the app can write console input to the VM */
+    private final boolean mVmConsoleInputSupported;
+
     private VirtualMachineConfig(
             @Nullable String packageName,
             @Nullable String apkPath,
@@ -174,7 +178,8 @@
             long memoryBytes,
             @CpuTopology int cpuTopology,
             long encryptedStorageBytes,
-            boolean vmOutputCaptured) {
+            boolean vmOutputCaptured,
+            boolean vmConsoleInputSupported) {
         // This is only called from Builder.build(); the builder handles parameter validation.
         mPackageName = packageName;
         mApkPath = apkPath;
@@ -186,6 +191,7 @@
         mCpuTopology = cpuTopology;
         mEncryptedStorageBytes = encryptedStorageBytes;
         mVmOutputCaptured = vmOutputCaptured;
+        mVmConsoleInputSupported = vmConsoleInputSupported;
     }
 
     /** Loads a config from a file. */
@@ -260,6 +266,7 @@
             builder.setEncryptedStorageBytes(encryptedStorageBytes);
         }
         builder.setVmOutputCaptured(b.getBoolean(KEY_VM_OUTPUT_CAPTURED));
+        builder.setVmConsoleInputSupported(b.getBoolean(KEY_VM_CONSOLE_INPUT_SUPPORTED));
 
         return builder.build();
     }
@@ -295,6 +302,7 @@
             b.putLong(KEY_ENCRYPTED_STORAGE_BYTES, mEncryptedStorageBytes);
         }
         b.putBoolean(KEY_VM_OUTPUT_CAPTURED, mVmOutputCaptured);
+        b.putBoolean(KEY_VM_CONSOLE_INPUT_SUPPORTED, mVmConsoleInputSupported);
         b.writeToStream(output);
     }
 
@@ -413,6 +421,17 @@
     }
 
     /**
+     * Returns whether the app can write to the VM console.
+     *
+     * @see Builder#setVmConsoleInputSupported
+     * @hide
+     */
+    @TestApi
+    public boolean isVmConsoleInputSupported() {
+        return mVmConsoleInputSupported;
+    }
+
+    /**
      * 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
@@ -431,6 +450,7 @@
                 && this.mProtectedVm == other.mProtectedVm
                 && this.mEncryptedStorageBytes == other.mEncryptedStorageBytes
                 && this.mVmOutputCaptured == other.mVmOutputCaptured
+                && this.mVmConsoleInputSupported == other.mVmConsoleInputSupported
                 && Objects.equals(this.mPayloadConfigPath, other.mPayloadConfigPath)
                 && Objects.equals(this.mPayloadBinaryName, other.mPayloadBinaryName)
                 && Objects.equals(this.mPackageName, other.mPackageName)
@@ -554,6 +574,7 @@
         @CpuTopology private int mCpuTopology = CPU_TOPOLOGY_ONE_CPU;
         private long mEncryptedStorageBytes;
         private boolean mVmOutputCaptured = false;
+        private boolean mVmConsoleInputSupported = false;
 
         /**
          * Creates a builder for the given context.
@@ -612,6 +633,10 @@
                 throw new IllegalStateException("debug level must be FULL to capture output");
             }
 
+            if (mVmConsoleInputSupported && mDebugLevel != DEBUG_LEVEL_FULL) {
+                throw new IllegalStateException("debug level must be FULL to use console input");
+            }
+
             return new VirtualMachineConfig(
                     packageName,
                     apkPath,
@@ -622,7 +647,8 @@
                     mMemoryBytes,
                     mCpuTopology,
                     mEncryptedStorageBytes,
-                    mVmOutputCaptured);
+                    mVmOutputCaptured,
+                    mVmConsoleInputSupported);
         }
 
         /**
@@ -822,5 +848,23 @@
             mVmOutputCaptured = captured;
             return this;
         }
+
+        /**
+         * Sets whether to allow the app to write to the VM console. Default is {@code false}.
+         *
+         * <p>Setting this as {@code true} will allow the app to directly write into {@linkplain
+         * VirtualMachine#getConsoleInput console input}.
+         *
+         * <p>The {@linkplain #setDebugLevel debug level} must be {@link #DEBUG_LEVEL_FULL} to be
+         * set as true.
+         *
+         * @hide
+         */
+        @TestApi
+        @NonNull
+        public Builder setVmConsoleInputSupported(boolean supported) {
+            mVmConsoleInputSupported = supported;
+            return this;
+        }
     }
 }
diff --git a/tests/aidl/com/android/microdroid/testservice/ITestService.aidl b/tests/aidl/com/android/microdroid/testservice/ITestService.aidl
index a6f1c80..8d467cd 100644
--- a/tests/aidl/com/android/microdroid/testservice/ITestService.aidl
+++ b/tests/aidl/com/android/microdroid/testservice/ITestService.aidl
@@ -70,6 +70,9 @@
     /** Requests the VM to asynchronously call appCallback.setVmCallback() */
     void requestCallback(IAppCallback appCallback);
 
+    /** Read a line from /dev/console */
+    String readLineFromConsole();
+
     /**
      * Request the service to exit, triggering the termination of the VM. This may cause any
      * requests in flight to fail.
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 f58ce81..d30f0d0 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
@@ -471,6 +471,7 @@
         public long[] mTimings;
         public int mFileMode;
         public int mMountFlags;
+        public String mConsoleInput;
 
         public void assertNoException() {
             if (mException != null) {
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 a64b62a..ffb2c11 100644
--- a/tests/testapk/src/java/com/android/microdroid/test/MicrodroidTests.java
+++ b/tests/testapk/src/java/com/android/microdroid/test/MicrodroidTests.java
@@ -1652,6 +1652,35 @@
     }
 
     @Test
+    public void testConsoleInputSupported() throws Exception {
+        assumeSupportedDevice();
+
+        VirtualMachineConfig config =
+                newVmConfigBuilder()
+                        .setPayloadBinaryName("MicrodroidTestNativeLib.so")
+                        .setDebugLevel(DEBUG_LEVEL_FULL)
+                        .setVmConsoleInputSupported(true)
+                        .setVmOutputCaptured(true)
+                        .build();
+        VirtualMachine vm = forceCreateNewVirtualMachine("test_vm_console_in", config);
+
+        final String TYPED = "this is a console input\n";
+        TestResults testResults =
+                runVmTestService(
+                        TAG,
+                        vm,
+                        (ts, tr) -> {
+                            OutputStreamWriter consoleIn =
+                                    new OutputStreamWriter(vm.getConsoleInput());
+                            consoleIn.write(TYPED);
+                            consoleIn.close();
+                            tr.mConsoleInput = ts.readLineFromConsole();
+                        });
+        testResults.assertNoException();
+        assertThat(testResults.mConsoleInput).isEqualTo(TYPED);
+    }
+
+    @Test
     public void testStartVmWithPayloadOfAnotherApp() throws Exception {
         assumeSupportedDevice();
 
diff --git a/tests/testapk/src/native/testbinary.cpp b/tests/testapk/src/native/testbinary.cpp
index 7e0fc5b..297b505 100644
--- a/tests/testapk/src/native/testbinary.cpp
+++ b/tests/testapk/src/native/testbinary.cpp
@@ -313,6 +313,26 @@
             return ScopedAStatus::ok();
         }
 
+        ScopedAStatus readLineFromConsole(std::string* out) {
+            FILE* f = fopen("/dev/console", "r");
+            if (f == nullptr) {
+                return ScopedAStatus::fromExceptionCodeWithMessage(EX_SERVICE_SPECIFIC,
+                                                                   "failed to open /dev/console");
+            }
+            char* line = nullptr;
+            size_t len = 0;
+            ssize_t nread = getline(&line, &len, f);
+
+            if (nread == -1) {
+                free(line);
+                return ScopedAStatus::fromExceptionCodeWithMessage(EX_SERVICE_SPECIFIC,
+                                                                   "failed to read /dev/console");
+            }
+            out->append(line, nread);
+            free(line);
+            return ScopedAStatus::ok();
+        }
+
         ScopedAStatus quit() override { exit(0); }
     };
     auto testService = ndk::SharedRefBase::make<TestService>();
diff --git a/tests/vmshareapp/src/java/com/android/microdroid/test/sharevm/VmShareServiceImpl.java b/tests/vmshareapp/src/java/com/android/microdroid/test/sharevm/VmShareServiceImpl.java
index 1f7ffb7..0ddf70b 100644
--- a/tests/vmshareapp/src/java/com/android/microdroid/test/sharevm/VmShareServiceImpl.java
+++ b/tests/vmshareapp/src/java/com/android/microdroid/test/sharevm/VmShareServiceImpl.java
@@ -245,6 +245,11 @@
         }
 
         @Override
+        public String readLineFromConsole() {
+            throw new UnsupportedOperationException("Not supported");
+        }
+
+        @Override
         public void quit() throws RemoteException {
             throw new UnsupportedOperationException("Not supported");
         }