Refactor clipboard handling into a separate class

Bug: N/A
Test: N/A
Change-Id: I2e18d34ae6810e318e6607e4df343df5c65aff32
diff --git a/android/VmLauncherApp/java/com/android/virtualization/vmlauncher/ClipboardHandler.java b/android/VmLauncherApp/java/com/android/virtualization/vmlauncher/ClipboardHandler.java
new file mode 100644
index 0000000..828d923
--- /dev/null
+++ b/android/VmLauncherApp/java/com/android/virtualization/vmlauncher/ClipboardHandler.java
@@ -0,0 +1,84 @@
+/*
+ * Copyright (C) 2024 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 com.android.virtualization.vmlauncher;
+
+import android.content.ClipData;
+import android.content.ClipboardManager;
+import android.content.Context;
+import android.util.Log;
+
+import java.nio.charset.StandardCharsets;
+
+/** Provide methods to synchronize clipboard across Android and VM. */
+class ClipboardHandler {
+    private static final String TAG = MainActivity.TAG;
+    private final ClipboardManager mClipboardManager;
+    private final VmAgent mVmAgent;
+
+    ClipboardHandler(Context context, VmAgent vmAgent) {
+        mClipboardManager = context.getSystemService(ClipboardManager.class);
+        mVmAgent = vmAgent;
+    }
+
+    private VmAgent.Connection getConnection() {
+        return mVmAgent.connect();
+    }
+
+    /** Read a text clip from Android's clipboard and send it to VM. */
+    void writeClipboardToVm() {
+        if (!mClipboardManager.hasPrimaryClip()) {
+            return;
+        }
+
+        ClipData clip = mClipboardManager.getPrimaryClip();
+        String text = clip.getItemAt(0).getText().toString();
+        // TODO: remove this trailing null character. The size is already encoded in the header.
+        text = text + '\0';
+        // TODO: use UTF-8 encoding
+        byte[] data = text.getBytes();
+
+        try {
+            getConnection().sendData(VmAgent.WRITE_CLIPBOARD_TYPE_TEXT_PLAIN, data);
+        } catch (RuntimeException e) {
+            Log.e(TAG, "Failed to write clipboard data to VM", e);
+        }
+    }
+
+    /** Read a text clip from VM and paste it to Android's clipboard. */
+    void readClipboardFromVm() {
+        VmAgent.Data data;
+        try {
+            data = getConnection().sendAndReceive(VmAgent.READ_CLIPBOARD_FROM_VM, null);
+        } catch (RuntimeException e) {
+            Log.e(TAG, "Failed to read clipboard data from VM", e);
+            return;
+        }
+
+        switch (data.type) {
+            case VmAgent.WRITE_CLIPBOARD_TYPE_EMPTY:
+                Log.d(TAG, "clipboard data from VM is empty");
+                break;
+            case VmAgent.WRITE_CLIPBOARD_TYPE_TEXT_PLAIN:
+                String text = new String(data.data, StandardCharsets.UTF_8);
+                ClipData clip = ClipData.newPlainText(null, text);
+                mClipboardManager.setPrimaryClip(clip);
+                break;
+            default:
+                Log.e(TAG, "Unknown clipboard response type: " + data.type);
+        }
+    }
+}
diff --git a/android/VmLauncherApp/java/com/android/virtualization/vmlauncher/MainActivity.java b/android/VmLauncherApp/java/com/android/virtualization/vmlauncher/MainActivity.java
index 7c7d7ef..54543b0 100644
--- a/android/VmLauncherApp/java/com/android/virtualization/vmlauncher/MainActivity.java
+++ b/android/VmLauncherApp/java/com/android/virtualization/vmlauncher/MainActivity.java
@@ -20,11 +20,8 @@
 
 import android.Manifest.permission;
 import android.app.Activity;
-import android.content.ClipData;
-import android.content.ClipboardManager;
 import android.content.Intent;
 import android.os.Bundle;
-import android.os.ParcelFileDescriptor;
 import android.system.virtualmachine.VirtualMachine;
 import android.system.virtualmachine.VirtualMachineConfig;
 import android.system.virtualmachine.VirtualMachineException;
@@ -35,14 +32,6 @@
 import android.view.WindowInsets;
 import android.view.WindowInsetsController;
 
-import java.io.FileInputStream;
-import java.io.FileOutputStream;
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.OutputStream;
-import java.nio.ByteBuffer;
-import java.nio.ByteOrder;
-import java.nio.charset.StandardCharsets;
 import java.nio.file.Path;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
@@ -59,9 +48,10 @@
 
     private ExecutorService mExecutorService;
     private VirtualMachine mVirtualMachine;
-    private ClipboardManager mClipboardManager;
     private InputForwarder mInputForwarder;
     private DisplayProvider mDisplayProvider;
+    private VmAgent mVmAgent;
+    private ClipboardHandler mClipboardHandler;
 
     @Override
     protected void onCreate(Bundle savedInstanceState) {
@@ -105,6 +95,9 @@
 
         Path logPath = getFileStreamPath(mVirtualMachine.getName() + ".log").toPath();
         Logger.setup(mVirtualMachine, logPath, mExecutorService);
+
+        mVmAgent = new VmAgent(mVirtualMachine);
+        mClipboardHandler = new ClipboardHandler(this, mVmAgent);
     }
 
     private void makeFullscreen() {
@@ -156,127 +149,25 @@
         Log.d(TAG, "destroyed");
     }
 
-    private static final int DATA_SHARING_SERVICE_PORT = 3580;
-    private static final byte READ_CLIPBOARD_FROM_VM = 0;
-    private static final byte WRITE_CLIPBOARD_TYPE_EMPTY = 1;
-    private static final byte WRITE_CLIPBOARD_TYPE_TEXT_PLAIN = 2;
-    private static final byte OPEN_URL = 3;
-
-    private ClipboardManager getClipboardManager() {
-        if (mClipboardManager == null) {
-            mClipboardManager = getSystemService(ClipboardManager.class);
-        }
-        return mClipboardManager;
-    }
-
-    // Construct header for the clipboard data.
-    // Byte 0: Data type
-    // Byte 1-3: Padding alignment & Reserved for other use cases in the future
-    // Byte 4-7: Data size of the payload
-    private byte[] constructClipboardHeader(byte type, int dataSize) {
-        ByteBuffer header = ByteBuffer.allocate(8);
-        header.clear();
-        header.order(ByteOrder.LITTLE_ENDIAN);
-        header.put(0, type);
-        header.putInt(4, dataSize);
-        return header.array();
-    }
-
-    private ParcelFileDescriptor connectDataSharingService() throws VirtualMachineException {
-        // TODO(349702313): Consider when clipboard sharing server is started to run in VM.
-        return mVirtualMachine.connectVsock(DATA_SHARING_SERVICE_PORT);
-    }
-
-    private void writeClipboardToVm() {
-        Log.d(TAG, "running writeClipboardToVm");
-        try (ParcelFileDescriptor pfd = connectDataSharingService()) {
-            ClipboardManager clipboardManager = getClipboardManager();
-            if (!clipboardManager.hasPrimaryClip()) {
-                Log.d(TAG, "host device has no clipboard data");
-                return;
-            }
-            ClipData clip = clipboardManager.getPrimaryClip();
-            String text = clip.getItemAt(0).getText().toString();
-
-            byte[] header =
-                    constructClipboardHeader(
-                            WRITE_CLIPBOARD_TYPE_TEXT_PLAIN, text.getBytes().length + 1);
-            try (OutputStream stream = new FileOutputStream(pfd.getFileDescriptor())) {
-                stream.write(header);
-                stream.write(text.getBytes());
-                stream.write('\0');
-                Log.d(TAG, "successfully wrote clipboard data to the VM");
-            } catch (IOException e) {
-                Log.e(TAG, "failed to write clipboard data to the VM", e);
-            }
-        } catch (Exception e) {
-            Log.e(TAG, "error on writeClipboardToVm", e);
-        }
-    }
-
-    private byte[] readExactly(InputStream stream, int len) throws IOException {
-        byte[] buf = stream.readNBytes(len);
-        if (buf.length != len) {
-            throw new IOException("Cannot read enough bytes");
-        }
-        return buf;
-    }
-
-    private void readClipboardFromVm() {
-        Log.d(TAG, "running readClipboardFromVm");
-        try (ParcelFileDescriptor pfd = connectDataSharingService()) {
-            byte[] request = constructClipboardHeader(READ_CLIPBOARD_FROM_VM, 0);
-            try (OutputStream output = new FileOutputStream(pfd.getFileDescriptor())) {
-                output.write(request);
-                Log.d(TAG, "successfully send request to the VM for reading clipboard");
-            } catch (IOException e) {
-                Log.e(TAG, "failed to send request to the VM for reading clipboard");
-                throw e;
-            }
-
-            try (InputStream input = new FileInputStream(pfd.getFileDescriptor())) {
-                ByteBuffer header = ByteBuffer.wrap(readExactly(input, 8));
-                header.order(ByteOrder.LITTLE_ENDIAN);
-                switch (header.get(0)) {
-                    case WRITE_CLIPBOARD_TYPE_EMPTY:
-                        Log.d(TAG, "clipboard data in VM is empty");
-                        break;
-                    case WRITE_CLIPBOARD_TYPE_TEXT_PLAIN:
-                        int dataSize = header.getInt(4);
-                        String text_data =
-                                new String(readExactly(input, dataSize), StandardCharsets.UTF_8);
-                        getClipboardManager()
-                                .setPrimaryClip(ClipData.newPlainText(null, text_data));
-                        Log.d(TAG, "successfully received clipboard data from VM");
-                        break;
-                    default:
-                        Log.e(TAG, "unknown clipboard response type");
-                        break;
-                }
-            } catch (IOException e) {
-                Log.e(TAG, "failed to receive clipboard content from VM");
-                throw e;
-            }
-        } catch (Exception e) {
-            Log.e(TAG, "error on readClipboardFromVm", e);
-        }
-    }
-
     @Override
     public void onWindowFocusChanged(boolean hasFocus) {
         super.onWindowFocusChanged(hasFocus);
+
+        // TODO: explain why we have to do this on every focus change
         if (hasFocus) {
-            SurfaceView surfaceView = findViewById(R.id.surface_view);
-            Log.d(TAG, "requestPointerCapture()");
-            surfaceView.requestPointerCapture();
+            SurfaceView mainView = findViewById(R.id.surface_view);
+            mainView.requestPointerCapture();
         }
-        if (mVirtualMachine != null) {
-            if (hasFocus) {
-                mExecutorService.execute(() -> writeClipboardToVm());
-            } else {
-                mExecutorService.execute(() -> readClipboardFromVm());
-            }
-        }
+
+        // TODO: remove executor here. Let clipboard handler handle this.
+        mExecutorService.execute(
+                () -> {
+                    if (hasFocus) {
+                        mClipboardHandler.writeClipboardToVm();
+                    } else {
+                        mClipboardHandler.readClipboardFromVm();
+                    }
+                });
     }
 
     @Override
@@ -291,16 +182,7 @@
         if (text != null) {
             mExecutorService.execute(
                     () -> {
-                        byte[] data = text.getBytes();
-                        try (ParcelFileDescriptor pfd = connectDataSharingService();
-                                OutputStream stream =
-                                        new FileOutputStream(pfd.getFileDescriptor())) {
-                            stream.write(constructClipboardHeader(OPEN_URL, data.length));
-                            stream.write(data);
-                            Log.d(TAG, "Successfully sent URL to the VM");
-                        } catch (IOException | VirtualMachineException e) {
-                            Log.e(TAG, "Failed to send URL to the VM", e);
-                        }
+                        mVmAgent.connect().sendData(VmAgent.OPEN_URL, text.getBytes());
                     });
         }
     }
@@ -312,5 +194,4 @@
                     new String[] {permission.RECORD_AUDIO}, RECORD_AUDIO_PERMISSION_REQUEST_CODE);
         }
     }
-
 }
diff --git a/android/VmLauncherApp/java/com/android/virtualization/vmlauncher/VmAgent.java b/android/VmLauncherApp/java/com/android/virtualization/vmlauncher/VmAgent.java
new file mode 100644
index 0000000..78da6c0
--- /dev/null
+++ b/android/VmLauncherApp/java/com/android/virtualization/vmlauncher/VmAgent.java
@@ -0,0 +1,129 @@
+/*
+ * Copyright (C) 2024 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 com.android.virtualization.vmlauncher;
+
+import android.os.ParcelFileDescriptor;
+import android.system.virtualmachine.VirtualMachine;
+import android.system.virtualmachine.VirtualMachineException;
+
+import libcore.io.Streams;
+
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+
+/**
+ * Agent running in the VM. This class provides connection to the agent and ways to communicate with
+ * it.
+ */
+class VmAgent {
+    private static final String TAG = MainActivity.TAG;
+    private static final int DATA_SHARING_SERVICE_PORT = 3580;
+    private static final int HEADER_SIZE = 8; // size of the header
+    private static final int SIZE_OFFSET = 4; // offset of the size field in the header
+
+    static final byte READ_CLIPBOARD_FROM_VM = 0;
+    static final byte WRITE_CLIPBOARD_TYPE_EMPTY = 1;
+    static final byte WRITE_CLIPBOARD_TYPE_TEXT_PLAIN = 2;
+    static final byte OPEN_URL = 3;
+
+    private final VirtualMachine mVirtualMachine;
+
+    VmAgent(VirtualMachine vm) {
+        mVirtualMachine = vm;
+    }
+
+    /** Connect to the agent and returns the communication channel established. This can block. */
+    Connection connect() {
+        try {
+            // TODO: wait until the VM is up and the agent is running
+            return new Connection(mVirtualMachine.connectVsock(DATA_SHARING_SERVICE_PORT));
+        } catch (VirtualMachineException e) {
+            throw new RuntimeException("Failed to connect to the VM agent", e);
+        }
+    }
+
+    static class Data {
+        final int type;
+        final byte[] data;
+
+        Data(int type, byte[] data) {
+            this.type = type;
+            this.data = data;
+        }
+    }
+
+    /** Represents a connection to the agent */
+    class Connection {
+        private final ParcelFileDescriptor mConn;
+
+        private Connection(ParcelFileDescriptor conn) {
+            mConn = conn;
+        }
+
+        /** Send data of a given type. This can block. */
+        void sendData(byte type, byte[] data) {
+            // Byte 0: Data type
+            // Byte 1-3: Padding alignment & Reserved for other use cases in the future
+            // Byte 4-7: Data size of the payload
+            ByteBuffer header = ByteBuffer.allocate(HEADER_SIZE);
+            header.clear();
+            header.order(ByteOrder.LITTLE_ENDIAN);
+            header.put(0, type);
+            int dataSize = data == null ? 0 : data.length;
+            header.putInt(SIZE_OFFSET, dataSize);
+
+            try (OutputStream out = new FileOutputStream(mConn.getFileDescriptor())) {
+                out.write(header.array());
+                if (data != null) {
+                    out.write(data);
+                }
+            } catch (IOException e) {
+                throw new RuntimeException("Failed to send message of type: " + type, e);
+            }
+        }
+
+        /** Read data from agent. This can block. */
+        Data readData() {
+            ByteBuffer header = ByteBuffer.allocate(HEADER_SIZE);
+            header.clear();
+            header.order(ByteOrder.LITTLE_ENDIAN);
+            byte[] data;
+
+            try (InputStream in = new FileInputStream(mConn.getFileDescriptor())) {
+                Streams.readFully(in, header.array());
+                byte type = header.get(0);
+                int dataSize = header.getInt(SIZE_OFFSET);
+                data = new byte[dataSize];
+                Streams.readFully(in, data);
+                return new Data(type, data);
+            } catch (IOException e) {
+                throw new RuntimeException("Failed to read data", e);
+            }
+        }
+
+        /** Convenient method for sending data and then reading response for it. This can block. */
+        Data sendAndReceive(byte type, byte[] data) {
+            sendData(type, data);
+            return readData();
+        }
+    }
+}