Merge changes I39e5d8f9,I01e89a6b into main

* changes:
  Parse resize2fs output correctly
  Make error cause text view scrollable
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/BaseActivity.java b/android/TerminalApp/java/com/android/virtualization/terminal/BaseActivity.java
deleted file mode 100644
index aeae5dd..0000000
--- a/android/TerminalApp/java/com/android/virtualization/terminal/BaseActivity.java
+++ /dev/null
@@ -1,64 +0,0 @@
-/*
- * Copyright 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.terminal;
-
-import android.Manifest;
-import android.app.NotificationChannel;
-import android.app.NotificationManager;
-import android.content.pm.PackageManager;
-import android.os.Bundle;
-
-import androidx.appcompat.app.AppCompatActivity;
-
-public abstract class BaseActivity extends AppCompatActivity {
-    private static final int POST_NOTIFICATIONS_PERMISSION_REQUEST_CODE = 101;
-
-    @Override
-    protected void onCreate(Bundle savedInstanceState) {
-        super.onCreate(savedInstanceState);
-        NotificationManager notificationManager = getSystemService(NotificationManager.class);
-        if (notificationManager.getNotificationChannel(this.getPackageName()) == null) {
-            NotificationChannel channel =
-                    new NotificationChannel(
-                            this.getPackageName(),
-                            getString(R.string.app_name),
-                            NotificationManager.IMPORTANCE_HIGH);
-            notificationManager.createNotificationChannel(channel);
-        }
-
-        if (!(this instanceof ErrorActivity)) {
-            Thread currentThread = Thread.currentThread();
-            if (!(currentThread.getUncaughtExceptionHandler()
-                    instanceof TerminalExceptionHandler)) {
-                currentThread.setUncaughtExceptionHandler(
-                        new TerminalExceptionHandler(getApplicationContext()));
-            }
-        }
-    }
-
-    @Override
-    public void onResume() {
-        super.onResume();
-
-        if (getApplicationContext().checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS)
-                != PackageManager.PERMISSION_GRANTED) {
-            requestPermissions(
-                    new String[] {Manifest.permission.POST_NOTIFICATIONS},
-                    POST_NOTIFICATIONS_PERMISSION_REQUEST_CODE);
-        }
-    }
-}
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/BaseActivity.kt b/android/TerminalApp/java/com/android/virtualization/terminal/BaseActivity.kt
new file mode 100644
index 0000000..e7ac8d9
--- /dev/null
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/BaseActivity.kt
@@ -0,0 +1,66 @@
+/*
+ * Copyright 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.terminal
+
+import android.Manifest
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.content.pm.PackageManager
+import android.os.Bundle
+import androidx.appcompat.app.AppCompatActivity
+
+abstract class BaseActivity : AppCompatActivity() {
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        val notificationManager =
+            getSystemService<NotificationManager>(NotificationManager::class.java)
+        if (notificationManager.getNotificationChannel(this.packageName) == null) {
+            val channel =
+                NotificationChannel(
+                    this.packageName,
+                    getString(R.string.app_name),
+                    NotificationManager.IMPORTANCE_HIGH,
+                )
+            notificationManager.createNotificationChannel(channel)
+        }
+
+        if (this !is ErrorActivity) {
+            val currentThread = Thread.currentThread()
+            if (currentThread.uncaughtExceptionHandler !is TerminalExceptionHandler) {
+                currentThread.uncaughtExceptionHandler =
+                    TerminalExceptionHandler(applicationContext)
+            }
+        }
+    }
+
+    public override fun onResume() {
+        super.onResume()
+
+        if (
+            applicationContext.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) !=
+                PackageManager.PERMISSION_GRANTED
+        ) {
+            requestPermissions(
+                arrayOf<String>(Manifest.permission.POST_NOTIFICATIONS),
+                POST_NOTIFICATIONS_PERMISSION_REQUEST_CODE,
+            )
+        }
+    }
+
+    companion object {
+        private const val POST_NOTIFICATIONS_PERMISSION_REQUEST_CODE = 101
+    }
+}
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/DebianServiceImpl.java b/android/TerminalApp/java/com/android/virtualization/terminal/DebianServiceImpl.java
index 147a7e5..b4dffee 100644
--- a/android/TerminalApp/java/com/android/virtualization/terminal/DebianServiceImpl.java
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/DebianServiceImpl.java
@@ -34,7 +34,6 @@
 import io.grpc.stub.StreamObserver;
 
 import java.util.Set;
-import java.util.stream.Collectors;
 
 final class DebianServiceImpl extends DebianServiceGrpc.DebianServiceImplBase {
     private final Context mContext;
@@ -56,11 +55,8 @@
     public void reportVmActivePorts(
             ReportVmActivePortsRequest request,
             StreamObserver<ReportVmActivePortsResponse> responseObserver) {
-        Log.d(TAG, "reportVmActivePorts: " + request.toString());
-        mPortsStateManager.updateActivePorts(
-                request.getPortsList().stream()
-                        .map(activePort -> activePort.getPort())
-                        .collect(Collectors.toSet()));
+        mPortsStateManager.updateActivePorts(request.getPortsList());
+        Log.d(TAG, "reportVmActivePorts: " + mPortsStateManager.getActivePorts());
         ReportVmActivePortsResponse reply =
                 ReportVmActivePortsResponse.newBuilder().setSuccess(true).build();
         responseObserver.onNext(reply);
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/InstallerActivity.java b/android/TerminalApp/java/com/android/virtualization/terminal/InstallerActivity.java
deleted file mode 100644
index 1c62572..0000000
--- a/android/TerminalApp/java/com/android/virtualization/terminal/InstallerActivity.java
+++ /dev/null
@@ -1,314 +0,0 @@
-/*
- * Copyright 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.terminal;
-
-import static com.android.virtualization.terminal.MainActivity.TAG;
-
-import android.annotation.MainThread;
-import android.content.ComponentName;
-import android.content.Context;
-import android.content.Intent;
-import android.content.ServiceConnection;
-import android.os.Build;
-import android.os.Bundle;
-import android.os.ConditionVariable;
-import android.os.FileUtils;
-import android.os.IBinder;
-import android.os.RemoteException;
-import android.text.format.Formatter;
-import android.util.Log;
-import android.view.KeyEvent;
-import android.view.View;
-import android.widget.CheckBox;
-import android.widget.TextView;
-
-import com.android.internal.annotations.VisibleForTesting;
-
-import com.google.android.material.progressindicator.LinearProgressIndicator;
-import com.google.android.material.snackbar.Snackbar;
-
-import java.io.IOException;
-import java.lang.ref.WeakReference;
-
-public class InstallerActivity extends BaseActivity {
-    private static final long ESTIMATED_IMG_SIZE_BYTES = FileUtils.parseSize("550MB");
-
-    private CheckBox mWaitForWifiCheckbox;
-    private TextView mInstallButton;
-
-    private IInstallerService mService;
-    private ServiceConnection mInstallerServiceConnection;
-    private InstallProgressListener mInstallProgressListener;
-    private boolean mInstallRequested;
-    private ConditionVariable mInstallCompleted = new ConditionVariable();
-
-    @Override
-    public void onCreate(Bundle savedInstanceState) {
-        super.onCreate(savedInstanceState);
-        setResult(RESULT_CANCELED);
-
-        mInstallProgressListener = new InstallProgressListener(this);
-
-        setContentView(R.layout.activity_installer);
-        updateSizeEstimation(ESTIMATED_IMG_SIZE_BYTES);
-        measureImageSizeAndUpdateDescription();
-
-        mWaitForWifiCheckbox = (CheckBox) findViewById(R.id.installer_wait_for_wifi_checkbox);
-        mInstallButton = (TextView) findViewById(R.id.installer_install_button);
-
-        mInstallButton.setOnClickListener(
-                (event) -> {
-                    requestInstall();
-                });
-
-        Intent intent = new Intent(this, InstallerService.class);
-        mInstallerServiceConnection = new InstallerServiceConnection(this);
-        if (!bindService(intent, mInstallerServiceConnection, Context.BIND_AUTO_CREATE)) {
-            handleInternalError(new Exception("Failed to connect to installer service"));
-        }
-    }
-
-    private void updateSizeEstimation(long est) {
-        String desc =
-                getString(
-                        R.string.installer_desc_text_format,
-                        Formatter.formatShortFileSize(this, est));
-        runOnUiThread(
-                () -> {
-                    TextView view = (TextView) findViewById(R.id.installer_desc);
-                    view.setText(desc);
-                });
-    }
-
-    private void measureImageSizeAndUpdateDescription() {
-        new Thread(
-                        () -> {
-                            long est;
-                            try {
-                                est = ImageArchive.getDefault().getSize();
-                            } catch (IOException e) {
-                                Log.w(TAG, "Failed to measure image size.", e);
-                                return;
-                            }
-                            updateSizeEstimation(est);
-                        })
-                .start();
-    }
-
-    @Override
-    public void onResume() {
-        super.onResume();
-
-        if (Build.isDebuggable() && ImageArchive.fromSdCard().exists()) {
-            showSnackbar("Auto installing", Snackbar.LENGTH_LONG);
-            requestInstall();
-        }
-    }
-
-    @Override
-    public void onDestroy() {
-        if (mInstallerServiceConnection != null) {
-            unbindService(mInstallerServiceConnection);
-            mInstallerServiceConnection = null;
-        }
-
-        super.onDestroy();
-    }
-
-    @Override
-    public boolean onKeyUp(int keyCode, KeyEvent event) {
-        if (keyCode == KeyEvent.KEYCODE_BUTTON_START) {
-            requestInstall();
-            return true;
-        }
-        return super.onKeyUp(keyCode, event);
-    }
-
-    @VisibleForTesting
-    public boolean waitForInstallCompleted(long timeoutMillis) {
-        return mInstallCompleted.block(timeoutMillis);
-    }
-
-    private void showSnackbar(String message, int length) {
-        Snackbar snackbar = Snackbar.make(findViewById(android.R.id.content), message, length);
-        snackbar.setAnchorView(mWaitForWifiCheckbox);
-        snackbar.show();
-    }
-
-    public void handleInternalError(Exception e) {
-        if (Build.isDebuggable()) {
-            showSnackbar(
-                    e.getMessage() + ". File a bugreport to go/ferrochrome-bug",
-                    Snackbar.LENGTH_INDEFINITE);
-        }
-        Log.e(TAG, "Internal error", e);
-        finishWithResult(RESULT_CANCELED);
-    }
-
-    private void finishWithResult(int resultCode) {
-        if (resultCode == RESULT_OK) {
-            mInstallCompleted.open();
-        }
-        setResult(resultCode);
-        finish();
-    }
-
-    private void setInstallEnabled(boolean enable) {
-        mInstallButton.setEnabled(enable);
-        mWaitForWifiCheckbox.setEnabled(enable);
-        LinearProgressIndicator progressBar = findViewById(R.id.installer_progress);
-        if (enable) {
-            progressBar.setVisibility(View.INVISIBLE);
-        } else {
-            progressBar.setVisibility(View.VISIBLE);
-        }
-
-        int resId =
-                enable
-                        ? R.string.installer_install_button_enabled_text
-                        : R.string.installer_install_button_disabled_text;
-        mInstallButton.setText(getString(resId));
-    }
-
-    @MainThread
-    private void requestInstall() {
-        setInstallEnabled(/* enable= */ false);
-
-        if (mService != null) {
-            try {
-                mService.requestInstall(mWaitForWifiCheckbox.isChecked());
-            } catch (RemoteException e) {
-                handleInternalError(e);
-            }
-        } else {
-            Log.d(TAG, "requestInstall() is called, but not yet connected");
-            mInstallRequested = true;
-        }
-    }
-
-    @MainThread
-    public void handleInstallerServiceConnected() {
-        try {
-            mService.setProgressListener(mInstallProgressListener);
-            if (mService.isInstalled()) {
-                // Finishing this activity will trigger MainActivity::onResume(),
-                // and VM will be started from there.
-                finishWithResult(RESULT_OK);
-                return;
-            }
-
-            if (mInstallRequested) {
-                requestInstall();
-            } else if (mService.isInstalling()) {
-                setInstallEnabled(false);
-            }
-        } catch (RemoteException e) {
-            handleInternalError(e);
-        }
-    }
-
-    @MainThread
-    public void handleInstallerServiceDisconnected() {
-        handleInternalError(new Exception("InstallerService is destroyed while in use"));
-    }
-
-    @MainThread
-    private void handleInstallError(String displayText) {
-        showSnackbar(displayText, Snackbar.LENGTH_LONG);
-        setInstallEnabled(true);
-    }
-
-    private static class InstallProgressListener extends IInstallProgressListener.Stub {
-        private final WeakReference<InstallerActivity> mActivity;
-
-        InstallProgressListener(InstallerActivity activity) {
-            mActivity = new WeakReference<>(activity);
-        }
-
-        @Override
-        public void onCompleted() {
-            InstallerActivity activity = mActivity.get();
-            if (activity == null) {
-                // Ignore incoming connection or disconnection after activity is destroyed.
-                return;
-            }
-
-            // MainActivity will be resume and handle rest of progress.
-            activity.finishWithResult(RESULT_OK);
-        }
-
-        @Override
-        public void onError(String displayText) {
-            InstallerActivity context = mActivity.get();
-            if (context == null) {
-                // Ignore incoming connection or disconnection after activity is destroyed.
-                return;
-            }
-
-            context.runOnUiThread(
-                    () -> {
-                        InstallerActivity activity = mActivity.get();
-                        if (activity == null) {
-                            // Ignore incoming connection or disconnection after activity is
-                            // destroyed.
-                            return;
-                        }
-
-                        activity.handleInstallError(displayText);
-                    });
-        }
-    }
-
-    @MainThread
-    public static final class InstallerServiceConnection implements ServiceConnection {
-        private final WeakReference<InstallerActivity> mActivity;
-
-        InstallerServiceConnection(InstallerActivity activity) {
-            mActivity = new WeakReference<>(activity);
-        }
-
-        @Override
-        public void onServiceConnected(ComponentName name, IBinder service) {
-            InstallerActivity activity = mActivity.get();
-            if (activity == null || activity.mInstallerServiceConnection == null) {
-                // Ignore incoming connection or disconnection after activity is destroyed.
-                return;
-            }
-            if (service == null) {
-                activity.handleInternalError(new Exception("service shouldn't be null"));
-            }
-
-            activity.mService = IInstallerService.Stub.asInterface(service);
-            activity.handleInstallerServiceConnected();
-        }
-
-        @Override
-        public void onServiceDisconnected(ComponentName name) {
-            InstallerActivity activity = mActivity.get();
-            if (activity == null || activity.mInstallerServiceConnection == null) {
-                // Ignore incoming connection or disconnection after activity is destroyed.
-                return;
-            }
-
-            if (activity.mInstallerServiceConnection != null) {
-                activity.unbindService(activity.mInstallerServiceConnection);
-                activity.mInstallerServiceConnection = null;
-            }
-            activity.handleInstallerServiceDisconnected();
-        }
-    }
-}
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/InstallerActivity.kt b/android/TerminalApp/java/com/android/virtualization/terminal/InstallerActivity.kt
new file mode 100644
index 0000000..99c64f7
--- /dev/null
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/InstallerActivity.kt
@@ -0,0 +1,288 @@
+/*
+ * Copyright 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.terminal
+
+import android.annotation.MainThread
+import android.content.ComponentName
+import android.content.Intent
+import android.content.ServiceConnection
+import android.os.Build
+import android.os.Bundle
+import android.os.ConditionVariable
+import android.os.FileUtils
+import android.os.IBinder
+import android.os.RemoteException
+import android.text.format.Formatter
+import android.util.Log
+import android.view.KeyEvent
+import android.view.View
+import android.widget.CheckBox
+import android.widget.TextView
+import com.android.internal.annotations.VisibleForTesting
+import com.android.virtualization.terminal.ImageArchive.Companion.fromSdCard
+import com.android.virtualization.terminal.ImageArchive.Companion.getDefault
+import com.android.virtualization.terminal.InstallerActivity.InstallProgressListener
+import com.android.virtualization.terminal.InstallerActivity.InstallerServiceConnection
+import com.android.virtualization.terminal.MainActivity.TAG
+import com.google.android.material.progressindicator.LinearProgressIndicator
+import com.google.android.material.snackbar.Snackbar
+import java.io.IOException
+import java.lang.Exception
+import java.lang.ref.WeakReference
+
+class InstallerActivity : BaseActivity() {
+    private lateinit var waitForWifiCheckbox: CheckBox
+    private lateinit var installButton: TextView
+
+    private var service: IInstallerService? = null
+    private var installerServiceConnection: ServiceConnection? = null
+    private lateinit var installProgressListener: InstallProgressListener
+    private var installRequested = false
+    private val installCompleted = ConditionVariable()
+
+    public override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        setResult(RESULT_CANCELED)
+
+        installProgressListener = InstallProgressListener(this)
+
+        setContentView(R.layout.activity_installer)
+        updateSizeEstimation(ESTIMATED_IMG_SIZE_BYTES)
+        measureImageSizeAndUpdateDescription()
+
+        waitForWifiCheckbox = findViewById<CheckBox>(R.id.installer_wait_for_wifi_checkbox)
+        installButton = findViewById<TextView>(R.id.installer_install_button)
+
+        installButton.setOnClickListener(View.OnClickListener { requestInstall() })
+
+        val intent = Intent(this, InstallerService::class.java)
+        installerServiceConnection = InstallerServiceConnection(this)
+        if (!bindService(intent, installerServiceConnection!!, BIND_AUTO_CREATE)) {
+            handleInternalError(Exception("Failed to connect to installer service"))
+        }
+    }
+
+    private fun updateSizeEstimation(est: Long) {
+        val desc =
+            getString(R.string.installer_desc_text_format, Formatter.formatShortFileSize(this, est))
+        runOnUiThread {
+            val view = findViewById<TextView>(R.id.installer_desc)
+            view.text = desc
+        }
+    }
+
+    private fun measureImageSizeAndUpdateDescription() {
+        Thread {
+                val est: Long =
+                    try {
+                        getDefault().getSize()
+                    } catch (e: IOException) {
+                        Log.w(TAG, "Failed to measure image size.", e)
+                        return@Thread
+                    }
+                updateSizeEstimation(est)
+            }
+            .start()
+    }
+
+    override fun onResume() {
+        super.onResume()
+
+        if (Build.isDebuggable() && fromSdCard().exists()) {
+            showSnackBar("Auto installing", Snackbar.LENGTH_LONG)
+            requestInstall()
+        }
+    }
+
+    public override fun onDestroy() {
+        if (installerServiceConnection != null) {
+            unbindService(installerServiceConnection!!)
+            installerServiceConnection = null
+        }
+
+        super.onDestroy()
+    }
+
+    override fun onKeyUp(keyCode: Int, event: KeyEvent?): Boolean {
+        if (keyCode == KeyEvent.KEYCODE_BUTTON_START) {
+            requestInstall()
+            return true
+        }
+        return super.onKeyUp(keyCode, event)
+    }
+
+    @VisibleForTesting
+    fun waitForInstallCompleted(timeoutMillis: Long): Boolean {
+        return installCompleted.block(timeoutMillis)
+    }
+
+    private fun showSnackBar(message: String, length: Int) {
+        val snackBar = Snackbar.make(findViewById<View>(android.R.id.content), message, length)
+        snackBar.anchorView = waitForWifiCheckbox
+        snackBar.show()
+    }
+
+    fun handleInternalError(e: Exception) {
+        if (Build.isDebuggable()) {
+            showSnackBar(
+                e.message + ". File a bugreport to go/ferrochrome-bug",
+                Snackbar.LENGTH_INDEFINITE,
+            )
+        }
+        Log.e(TAG, "Internal error", e)
+        finishWithResult(RESULT_CANCELED)
+    }
+
+    private fun finishWithResult(resultCode: Int) {
+        if (resultCode == RESULT_OK) {
+            installCompleted.open()
+        }
+        setResult(resultCode)
+        finish()
+    }
+
+    private fun setInstallEnabled(enabled: Boolean) {
+        installButton.setEnabled(enabled)
+        waitForWifiCheckbox.setEnabled(enabled)
+        val progressBar = findViewById<LinearProgressIndicator>(R.id.installer_progress)
+        progressBar.visibility = if (enabled) View.INVISIBLE else View.VISIBLE
+
+        val resId =
+            if (enabled) R.string.installer_install_button_enabled_text
+            else R.string.installer_install_button_disabled_text
+        installButton.text = getString(resId)
+    }
+
+    @MainThread
+    private fun requestInstall() {
+        setInstallEnabled(/* enabled= */ false)
+
+        if (service != null) {
+            try {
+                service!!.requestInstall(waitForWifiCheckbox.isChecked)
+            } catch (e: RemoteException) {
+                handleInternalError(e)
+            }
+        } else {
+            Log.d(TAG, "requestInstall() is called, but not yet connected")
+            installRequested = true
+        }
+    }
+
+    @MainThread
+    fun handleInstallerServiceConnected() {
+        try {
+            service!!.setProgressListener(installProgressListener)
+            if (service!!.isInstalled()) {
+                // Finishing this activity will trigger MainActivity::onResume(),
+                // and VM will be started from there.
+                finishWithResult(RESULT_OK)
+                return
+            }
+
+            if (installRequested) {
+                requestInstall()
+            } else if (service!!.isInstalling()) {
+                setInstallEnabled(false)
+            }
+        } catch (e: RemoteException) {
+            handleInternalError(e)
+        }
+    }
+
+    @MainThread
+    fun handleInstallerServiceDisconnected() {
+        handleInternalError(Exception("InstallerService is destroyed while in use"))
+    }
+
+    @MainThread
+    private fun handleInstallError(displayText: String) {
+        showSnackBar(displayText, Snackbar.LENGTH_LONG)
+        setInstallEnabled(true)
+    }
+
+    private class InstallProgressListener(activity: InstallerActivity) :
+        IInstallProgressListener.Stub() {
+        private val activity: WeakReference<InstallerActivity> =
+            WeakReference<InstallerActivity>(activity)
+
+        override fun onCompleted() {
+            val activity = activity.get()
+            if (activity == null) {
+                // Ignore incoming connection or disconnection after activity is destroyed.
+                return
+            }
+
+            // MainActivity will be resume and handle rest of progress.
+            activity.finishWithResult(RESULT_OK)
+        }
+
+        override fun onError(displayText: String) {
+            val context = activity.get()
+            if (context == null) {
+                // Ignore incoming connection or disconnection after activity is destroyed.
+                return
+            }
+
+            context.runOnUiThread {
+                val activity = activity.get()
+                if (activity == null) {
+                    // Ignore incoming connection or disconnection after activity is
+                    // destroyed.
+                    return@runOnUiThread
+                }
+                activity.handleInstallError(displayText)
+            }
+        }
+    }
+
+    @MainThread
+    class InstallerServiceConnection internal constructor(activity: InstallerActivity) :
+        ServiceConnection {
+        private val activity: WeakReference<InstallerActivity> =
+            WeakReference<InstallerActivity>(activity)
+
+        override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
+            val activity = activity.get()
+            if (activity == null || activity.installerServiceConnection == null) {
+                // Ignore incoming connection or disconnection after activity is destroyed.
+                return
+            }
+            if (service == null) {
+                activity.handleInternalError(Exception("service shouldn't be null"))
+            }
+
+            activity.service = IInstallerService.Stub.asInterface(service)
+            activity.handleInstallerServiceConnected()
+        }
+
+        override fun onServiceDisconnected(name: ComponentName?) {
+            val activity = activity.get()
+            if (activity == null || activity.installerServiceConnection == null) {
+                // Ignore incoming connection or disconnection after activity is destroyed.
+                return
+            }
+
+            activity.unbindService(activity.installerServiceConnection!!)
+            activity.installerServiceConnection = null
+            activity.handleInstallerServiceDisconnected()
+        }
+    }
+
+    companion object {
+        private val ESTIMATED_IMG_SIZE_BYTES = FileUtils.parseSize("550MB")
+    }
+}
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/InstallerService.java b/android/TerminalApp/java/com/android/virtualization/terminal/InstallerService.java
deleted file mode 100644
index 66ab414..0000000
--- a/android/TerminalApp/java/com/android/virtualization/terminal/InstallerService.java
+++ /dev/null
@@ -1,355 +0,0 @@
-/*
- * Copyright 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.terminal;
-
-import static com.android.virtualization.terminal.MainActivity.TAG;
-
-import android.app.Notification;
-import android.app.PendingIntent;
-import android.app.Service;
-import android.content.Intent;
-import android.content.pm.ServiceInfo;
-import android.net.ConnectivityManager;
-import android.net.Network;
-import android.net.NetworkCapabilities;
-import android.os.Build;
-import android.os.IBinder;
-import android.util.Log;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-
-import com.android.internal.annotations.GuardedBy;
-
-import java.io.IOException;
-import java.io.InputStream;
-import java.lang.ref.WeakReference;
-import java.net.SocketException;
-import java.net.UnknownHostException;
-import java.nio.file.Path;
-import java.util.concurrent.ExecutorService;
-import java.util.concurrent.Executors;
-
-public class InstallerService extends Service {
-    private static final int NOTIFICATION_ID = 1313; // any unique number among notifications
-
-    private final Object mLock = new Object();
-
-    private Notification mNotification;
-
-    @GuardedBy("mLock")
-    private boolean mIsInstalling;
-
-    @GuardedBy("mLock")
-    private boolean mHasWifi;
-
-    @GuardedBy("mLock")
-    private IInstallProgressListener mListener;
-
-    private ExecutorService mExecutorService;
-    private ConnectivityManager mConnectivityManager;
-    private MyNetworkCallback mNetworkCallback;
-
-    @Override
-    public void onCreate() {
-        super.onCreate();
-
-        Intent intent = new Intent(this, MainActivity.class);
-        PendingIntent pendingIntent =
-                PendingIntent.getActivity(
-                        this, /* requestCode= */ 0, intent, PendingIntent.FLAG_IMMUTABLE);
-        mNotification =
-                new Notification.Builder(this, this.getPackageName())
-                        .setSilent(true)
-                        .setSmallIcon(R.drawable.ic_launcher_foreground)
-                        .setContentTitle(getString(R.string.installer_notif_title_text))
-                        .setContentText(getString(R.string.installer_notif_desc_text))
-                        .setOngoing(true)
-                        .setContentIntent(pendingIntent)
-                        .build();
-
-        mExecutorService =
-                Executors.newSingleThreadExecutor(
-                        new TerminalThreadFactory(getApplicationContext()));
-
-        mConnectivityManager = getSystemService(ConnectivityManager.class);
-        Network defaultNetwork = mConnectivityManager.getBoundNetworkForProcess();
-        if (defaultNetwork != null) {
-            NetworkCapabilities capability =
-                    mConnectivityManager.getNetworkCapabilities(defaultNetwork);
-            if (capability != null) {
-                mHasWifi = capability.hasTransport(NetworkCapabilities.TRANSPORT_WIFI);
-            }
-        }
-        mNetworkCallback = new MyNetworkCallback();
-        mConnectivityManager.registerDefaultNetworkCallback(mNetworkCallback);
-    }
-
-    @Nullable
-    @Override
-    public IBinder onBind(Intent intent) {
-        return new InstallerServiceImpl(this);
-    }
-
-    @Override
-    public int onStartCommand(Intent intent, int flags, int startId) {
-        super.onStartCommand(intent, flags, startId);
-
-        Log.d(TAG, "Starting service ...");
-
-        return START_STICKY;
-    }
-
-    @Override
-    public void onDestroy() {
-        super.onDestroy();
-
-        Log.d(TAG, "Service is destroyed");
-        if (mExecutorService != null) {
-            mExecutorService.shutdown();
-        }
-        mConnectivityManager.unregisterNetworkCallback(mNetworkCallback);
-    }
-
-    private void requestInstall(boolean isWifiOnly) {
-        synchronized (mLock) {
-            if (mIsInstalling) {
-                Log.i(TAG, "already installing..");
-                return;
-            } else {
-                Log.i(TAG, "installing..");
-                mIsInstalling = true;
-            }
-        }
-
-        // Make service to be long running, even after unbind() when InstallerActivity is destroyed
-        // The service will still be destroyed if task is remove.
-        startService(new Intent(this, InstallerService.class));
-        startForeground(
-                NOTIFICATION_ID, mNotification, ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE);
-
-        mExecutorService.execute(
-                () -> {
-                    boolean success = downloadFromSdcard() || downloadFromUrl(isWifiOnly);
-                    stopForeground(STOP_FOREGROUND_REMOVE);
-
-                    synchronized (mLock) {
-                        mIsInstalling = false;
-                    }
-                    if (success) {
-                        notifyCompleted();
-                    }
-                });
-    }
-
-    private boolean downloadFromSdcard() {
-        ImageArchive archive = ImageArchive.fromSdCard();
-
-        // Installing from sdcard is preferred, but only supported only in debuggable build.
-        if (Build.isDebuggable() && archive.exists()) {
-            Log.i(TAG, "trying to install /sdcard/linux/images.tar.gz");
-
-            Path dest = InstalledImage.getDefault(this).getInstallDir();
-            try {
-                archive.installTo(dest, null);
-                Log.i(TAG, "image is installed from /sdcard/linux/images.tar.gz");
-                return true;
-            } catch (IOException e) {
-                Log.i(TAG, "Failed to install /sdcard/linux/images.tar.gz", e);
-            }
-        } else {
-            Log.i(TAG, "Non-debuggable build doesn't support installation from /sdcard/linux");
-        }
-        return false;
-    }
-
-    private boolean checkForWifiOnly(boolean isWifiOnly) {
-        if (!isWifiOnly) {
-            return true;
-        }
-        synchronized (mLock) {
-            return mHasWifi;
-        }
-    }
-
-    // TODO(b/374015561): Support pause/resume download
-    private boolean downloadFromUrl(boolean isWifiOnly) {
-        if (!checkForWifiOnly(isWifiOnly)) {
-            Log.e(TAG, "Install isn't started because Wifi isn't available");
-            notifyError(getString(R.string.installer_error_no_wifi));
-            return false;
-        }
-
-        Path dest = InstalledImage.getDefault(this).getInstallDir();
-        try {
-            ImageArchive.fromInternet()
-                    .installTo(
-                            dest,
-                            is -> {
-                                WifiCheckInputStream filter = new WifiCheckInputStream(is);
-                                filter.setWifiOnly(isWifiOnly);
-                                return filter;
-                            });
-        } catch (WifiCheckInputStream.NoWifiException e) {
-            Log.e(TAG, "Install failed because of Wi-Fi is gone");
-            notifyError(getString(R.string.installer_error_no_wifi));
-            return false;
-        } catch (UnknownHostException | SocketException e) {
-            // Log.e() doesn't print stack trace for UnknownHostException
-            Log.e(TAG, "Install failed: " + e.getMessage(), e);
-            notifyError(getString(R.string.installer_error_network));
-            return false;
-        } catch (IOException e) {
-            Log.e(TAG, "Installation failed", e);
-            notifyError(getString(R.string.installer_error_unknown));
-            return false;
-        }
-        return true;
-    }
-
-    private void notifyError(String displayText) {
-        IInstallProgressListener listener;
-        synchronized (mLock) {
-            listener = mListener;
-        }
-
-        try {
-            listener.onError(displayText);
-        } catch (Exception e) {
-            // ignore. Activity may not exist.
-        }
-    }
-
-    private void notifyCompleted() {
-        IInstallProgressListener listener;
-        synchronized (mLock) {
-            listener = mListener;
-        }
-
-        try {
-            listener.onCompleted();
-        } catch (Exception e) {
-            // ignore. Activity may not exist.
-        }
-    }
-
-    private static final class InstallerServiceImpl extends IInstallerService.Stub {
-        // Holds weak reference to avoid Context leak
-        private final WeakReference<InstallerService> mService;
-
-        public InstallerServiceImpl(InstallerService service) {
-            mService = new WeakReference<>(service);
-        }
-
-        private InstallerService ensureServiceConnected() throws RuntimeException {
-            InstallerService service = mService.get();
-            if (service == null) {
-                throw new RuntimeException(
-                        "Internal error: Installer service is being accessed after destroyed");
-            }
-            return service;
-        }
-
-        @Override
-        public void requestInstall(boolean isWifiOnly) {
-            InstallerService service = ensureServiceConnected();
-            synchronized (service.mLock) {
-                service.requestInstall(isWifiOnly);
-            }
-        }
-
-        @Override
-        public void setProgressListener(IInstallProgressListener listener) {
-            InstallerService service = ensureServiceConnected();
-            synchronized (service.mLock) {
-                service.mListener = listener;
-            }
-        }
-
-        @Override
-        public boolean isInstalling() {
-            InstallerService service = ensureServiceConnected();
-            synchronized (service.mLock) {
-                return service.mIsInstalling;
-            }
-        }
-
-        @Override
-        public boolean isInstalled() {
-            InstallerService service = ensureServiceConnected();
-            synchronized (service.mLock) {
-                return !service.mIsInstalling && InstalledImage.getDefault(service).isInstalled();
-            }
-        }
-    }
-
-    private final class WifiCheckInputStream extends InputStream {
-        private static final int READ_BYTES = 1024;
-
-        private final InputStream mInputStream;
-        private boolean mIsWifiOnly;
-
-        public WifiCheckInputStream(InputStream is) {
-            super();
-            mInputStream = is;
-        }
-
-        public void setWifiOnly(boolean isWifiOnly) {
-            mIsWifiOnly = isWifiOnly;
-        }
-
-        @Override
-        public int read(byte[] buf, int offset, int numToRead) throws IOException {
-            int totalRead = 0;
-            while (numToRead > 0) {
-                if (!checkForWifiOnly(mIsWifiOnly)) {
-                    throw new NoWifiException();
-                }
-                int read =
-                        mInputStream.read(buf, offset + totalRead, Math.min(READ_BYTES, numToRead));
-                if (read <= 0) {
-                    break;
-                }
-                totalRead += read;
-                numToRead -= read;
-            }
-            return totalRead;
-        }
-
-        @Override
-        public int read() throws IOException {
-            if (!checkForWifiOnly(mIsWifiOnly)) {
-                throw new NoWifiException();
-            }
-            return mInputStream.read();
-        }
-
-        private static final class NoWifiException extends SocketException {
-            // empty
-        }
-    }
-
-    private final class MyNetworkCallback extends ConnectivityManager.NetworkCallback {
-        @Override
-        public void onCapabilitiesChanged(
-                @NonNull Network network, @NonNull NetworkCapabilities capability) {
-            synchronized (mLock) {
-                mHasWifi = capability.hasTransport(NetworkCapabilities.TRANSPORT_WIFI);
-            }
-        }
-    }
-}
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/InstallerService.kt b/android/TerminalApp/java/com/android/virtualization/terminal/InstallerService.kt
new file mode 100644
index 0000000..9bd6a13
--- /dev/null
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/InstallerService.kt
@@ -0,0 +1,333 @@
+/*
+ * Copyright 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.terminal
+
+import android.app.Notification
+import android.app.PendingIntent
+import android.app.Service
+import android.content.Intent
+import android.content.pm.ServiceInfo
+import android.net.ConnectivityManager
+import android.net.Network
+import android.net.NetworkCapabilities
+import android.os.Build
+import android.os.IBinder
+import android.util.Log
+import com.android.internal.annotations.GuardedBy
+import com.android.virtualization.terminal.ImageArchive.Companion.fromInternet
+import com.android.virtualization.terminal.ImageArchive.Companion.fromSdCard
+import com.android.virtualization.terminal.InstalledImage.Companion.getDefault
+import com.android.virtualization.terminal.InstallerService.InstallerServiceImpl
+import com.android.virtualization.terminal.InstallerService.WifiCheckInputStream.NoWifiException
+import com.android.virtualization.terminal.MainActivity.TAG
+import java.io.IOException
+import java.io.InputStream
+import java.lang.Exception
+import java.lang.RuntimeException
+import java.lang.ref.WeakReference
+import java.net.SocketException
+import java.net.UnknownHostException
+import java.util.concurrent.ExecutorService
+import java.util.concurrent.Executors
+import kotlin.math.min
+
+class InstallerService : Service() {
+    private val lock = Any()
+
+    private lateinit var notification: Notification
+
+    @GuardedBy("lock") private var isInstalling = false
+
+    @GuardedBy("lock") private var hasWifi = false
+
+    @GuardedBy("lock") private var listener: IInstallProgressListener? = null
+
+    private lateinit var executorService: ExecutorService
+    private lateinit var connectivityManager: ConnectivityManager
+    private lateinit var networkCallback: MyNetworkCallback
+
+    override fun onCreate() {
+        super.onCreate()
+
+        val intent = Intent(this, MainActivity::class.java)
+        val pendingIntent =
+            PendingIntent.getActivity(
+                this,
+                /* requestCode= */ 0,
+                intent,
+                PendingIntent.FLAG_IMMUTABLE,
+            )
+        notification =
+            Notification.Builder(this, this.packageName)
+                .setSilent(true)
+                .setSmallIcon(R.drawable.ic_launcher_foreground)
+                .setContentTitle(getString(R.string.installer_notif_title_text))
+                .setContentText(getString(R.string.installer_notif_desc_text))
+                .setOngoing(true)
+                .setContentIntent(pendingIntent)
+                .build()
+
+        executorService =
+            Executors.newSingleThreadExecutor(TerminalThreadFactory(applicationContext))
+
+        connectivityManager = getSystemService<ConnectivityManager>(ConnectivityManager::class.java)
+        val defaultNetwork = connectivityManager.boundNetworkForProcess
+        if (defaultNetwork != null) {
+            val capability = connectivityManager.getNetworkCapabilities(defaultNetwork)
+            if (capability != null) {
+                hasWifi = capability.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
+            }
+        }
+        networkCallback = MyNetworkCallback()
+        connectivityManager.registerDefaultNetworkCallback(networkCallback)
+    }
+
+    override fun onBind(intent: Intent?): IBinder? {
+        return InstallerServiceImpl(this)
+    }
+
+    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+        super.onStartCommand(intent, flags, startId)
+
+        Log.d(TAG, "Starting service ...")
+
+        return START_STICKY
+    }
+
+    override fun onDestroy() {
+        super.onDestroy()
+
+        Log.d(TAG, "Service is destroyed")
+        executorService.shutdown()
+        connectivityManager.unregisterNetworkCallback(networkCallback)
+    }
+
+    private fun requestInstall(isWifiOnly: Boolean) {
+        synchronized(lock) {
+            if (isInstalling) {
+                Log.i(TAG, "already installing..")
+                return
+            } else {
+                Log.i(TAG, "installing..")
+                isInstalling = true
+            }
+        }
+
+        // Make service to be long running, even after unbind() when InstallerActivity is destroyed
+        // The service will still be destroyed if task is remove.
+        startService(Intent(this, InstallerService::class.java))
+        startForeground(
+            NOTIFICATION_ID,
+            notification,
+            ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE,
+        )
+
+        executorService.execute(
+            Runnable {
+                val success = downloadFromSdcard() || downloadFromUrl(isWifiOnly)
+                stopForeground(STOP_FOREGROUND_REMOVE)
+
+                synchronized(lock) { isInstalling = false }
+                if (success) {
+                    notifyCompleted()
+                }
+            }
+        )
+    }
+
+    private fun downloadFromSdcard(): Boolean {
+        val archive = fromSdCard()
+
+        // Installing from sdcard is preferred, but only supported only in debuggable build.
+        if (Build.isDebuggable() && archive.exists()) {
+            Log.i(TAG, "trying to install /sdcard/linux/images.tar.gz")
+
+            val dest = getDefault(this).installDir
+            try {
+                archive.installTo(dest, null)
+                Log.i(TAG, "image is installed from /sdcard/linux/images.tar.gz")
+                return true
+            } catch (e: IOException) {
+                Log.i(TAG, "Failed to install /sdcard/linux/images.tar.gz", e)
+            }
+        } else {
+            Log.i(TAG, "Non-debuggable build doesn't support installation from /sdcard/linux")
+        }
+        return false
+    }
+
+    private fun checkForWifiOnly(isWifiOnly: Boolean): Boolean {
+        if (!isWifiOnly) {
+            return true
+        }
+        synchronized(lock) {
+            return hasWifi
+        }
+    }
+
+    // TODO(b/374015561): Support pause/resume download
+    private fun downloadFromUrl(isWifiOnly: Boolean): Boolean {
+        if (!checkForWifiOnly(isWifiOnly)) {
+            Log.e(TAG, "Install isn't started because Wifi isn't available")
+            notifyError(getString(R.string.installer_error_no_wifi))
+            return false
+        }
+
+        val dest = getDefault(this).installDir
+        try {
+            fromInternet().installTo(dest) {
+                val filter = WifiCheckInputStream(it)
+                filter.setWifiOnly(isWifiOnly)
+                filter
+            }
+        } catch (e: NoWifiException) {
+            Log.e(TAG, "Install failed because of Wi-Fi is gone")
+            notifyError(getString(R.string.installer_error_no_wifi))
+            return false
+        } catch (e: UnknownHostException) {
+            // Log.e() doesn't print stack trace for UnknownHostException
+            Log.e(TAG, "Install failed: " + e.message, e)
+            notifyError(getString(R.string.installer_error_network))
+            return false
+        } catch (e: SocketException) {
+            Log.e(TAG, "Install failed: " + e.message, e)
+            notifyError(getString(R.string.installer_error_network))
+            return false
+        } catch (e: IOException) {
+            Log.e(TAG, "Installation failed", e)
+            notifyError(getString(R.string.installer_error_unknown))
+            return false
+        }
+        return true
+    }
+
+    private fun notifyError(displayText: String?) {
+        var listener: IInstallProgressListener
+        synchronized(lock) { listener = this@InstallerService.listener!! }
+
+        try {
+            listener.onError(displayText)
+        } catch (e: Exception) {
+            // ignore. Activity may not exist.
+        }
+    }
+
+    private fun notifyCompleted() {
+        var listener: IInstallProgressListener
+        synchronized(lock) { listener = this@InstallerService.listener!! }
+
+        try {
+            listener.onCompleted()
+        } catch (e: Exception) {
+            // ignore. Activity may not exist.
+        }
+    }
+
+    private class InstallerServiceImpl(service: InstallerService?) : IInstallerService.Stub() {
+        // Holds weak reference to avoid Context leak
+        private val mService: WeakReference<InstallerService> =
+            WeakReference<InstallerService>(service)
+
+        @Throws(RuntimeException::class)
+        fun ensureServiceConnected(): InstallerService {
+            val service: InstallerService? = mService.get()
+            if (service == null) {
+                throw RuntimeException(
+                    "Internal error: Installer service is being accessed after destroyed"
+                )
+            }
+            return service
+        }
+
+        override fun requestInstall(isWifiOnly: Boolean) {
+            val service = ensureServiceConnected()
+            synchronized(service.lock) { service.requestInstall(isWifiOnly) }
+        }
+
+        override fun setProgressListener(listener: IInstallProgressListener) {
+            val service = ensureServiceConnected()
+            synchronized(service.lock) { service.listener = listener }
+        }
+
+        override fun isInstalling(): Boolean {
+            val service = ensureServiceConnected()
+            synchronized(service.lock) {
+                return service.isInstalling
+            }
+        }
+
+        override fun isInstalled(): Boolean {
+            val service = ensureServiceConnected()
+            synchronized(service.lock) {
+                return !service.isInstalling && getDefault(service).isInstalled()
+            }
+        }
+    }
+
+    private inner class WifiCheckInputStream(private val inputStream: InputStream) : InputStream() {
+        private var isWifiOnly = false
+
+        fun setWifiOnly(isWifiOnly: Boolean) {
+            this@WifiCheckInputStream.isWifiOnly = isWifiOnly
+        }
+
+        @Throws(IOException::class)
+        override fun read(buf: ByteArray?, offset: Int, numToRead: Int): Int {
+            var remaining = numToRead
+            var totalRead = 0
+            while (remaining > 0) {
+                if (!checkForWifiOnly(isWifiOnly)) {
+                    throw NoWifiException()
+                }
+                val read =
+                    this@WifiCheckInputStream.inputStream.read(
+                        buf,
+                        offset + totalRead,
+                        min(READ_BYTES, remaining),
+                    )
+                if (read <= 0) {
+                    break
+                }
+                totalRead += read
+                remaining -= read
+            }
+            return totalRead
+        }
+
+        @Throws(IOException::class)
+        override fun read(): Int {
+            if (!checkForWifiOnly(isWifiOnly)) {
+                throw NoWifiException()
+            }
+            return this@WifiCheckInputStream.inputStream.read()
+        }
+
+        inner class NoWifiException : SocketException()
+    }
+
+    private inner class MyNetworkCallback : ConnectivityManager.NetworkCallback() {
+        override fun onCapabilitiesChanged(network: Network, capability: NetworkCapabilities) {
+            synchronized(lock) {
+                hasWifi = capability.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)
+            }
+        }
+    }
+
+    companion object {
+        private const val NOTIFICATION_ID = 1313 // any unique number among notifications
+        private const val READ_BYTES = 1024
+    }
+}
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/PortNotifier.kt b/android/TerminalApp/java/com/android/virtualization/terminal/PortNotifier.kt
index 7a07dfe..30729c4 100644
--- a/android/TerminalApp/java/com/android/virtualization/terminal/PortNotifier.kt
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/PortNotifier.kt
@@ -76,7 +76,11 @@
 
         val title = getString(R.string.settings_port_forwarding_notification_title)
         val content =
-            context.getString(R.string.settings_port_forwarding_notification_content, port)
+            context.getString(
+                R.string.settings_port_forwarding_notification_content,
+                port,
+                portsStateManager.getActivePortInfo(port)?.comm,
+            )
         val acceptText = getString(R.string.settings_port_forwarding_notification_accept)
         val denyText = getString(R.string.settings_port_forwarding_notification_deny)
         val icon = Icon.createWithResource(context, R.drawable.ic_launcher_foreground)
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/PortsStateManager.kt b/android/TerminalApp/java/com/android/virtualization/terminal/PortsStateManager.kt
index 317ca8e..c5335ac 100644
--- a/android/TerminalApp/java/com/android/virtualization/terminal/PortsStateManager.kt
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/PortsStateManager.kt
@@ -18,6 +18,7 @@
 import android.content.Context
 import android.content.SharedPreferences
 import com.android.internal.annotations.GuardedBy
+import com.android.virtualization.terminal.proto.ActivePort
 import java.util.HashSet
 
 /**
@@ -27,7 +28,7 @@
 class PortsStateManager private constructor(private val sharedPref: SharedPreferences) {
     private val lock = Any()
 
-    @GuardedBy("lock") private val activePorts: MutableSet<Int> = hashSetOf()
+    @GuardedBy("lock") private val activePorts: MutableMap<Int, ActivePort> = hashMapOf()
 
     @GuardedBy("lock")
     private val enabledPorts: MutableSet<Int> =
@@ -44,7 +45,13 @@
 
     fun getActivePorts(): Set<Int> {
         synchronized(lock) {
-            return HashSet<Int>(activePorts)
+            return HashSet<Int>(activePorts.keys)
+        }
+    }
+
+    fun getActivePortInfo(port: Int): ActivePort? {
+        synchronized(lock) {
+            return activePorts[port]
         }
     }
 
@@ -54,11 +61,11 @@
         }
     }
 
-    fun updateActivePorts(ports: Set<Int>) {
+    fun updateActivePorts(ports: List<ActivePort>) {
         val oldPorts = getActivePorts()
         synchronized(lock) {
             activePorts.clear()
-            activePorts.addAll(ports)
+            activePorts.putAll(ports.associateBy { it.port })
         }
         notifyPortsStateUpdated(oldPorts, getActivePorts())
     }
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/SettingsPortForwardingActiveAdapter.kt b/android/TerminalApp/java/com/android/virtualization/terminal/SettingsPortForwardingActiveAdapter.kt
index 1bfa390..7076084 100644
--- a/android/TerminalApp/java/com/android/virtualization/terminal/SettingsPortForwardingActiveAdapter.kt
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/SettingsPortForwardingActiveAdapter.kt
@@ -15,6 +15,7 @@
  */
 package com.android.virtualization.terminal
 
+import android.content.Context
 import android.view.LayoutInflater
 import android.view.View
 import android.view.ViewGroup
@@ -22,12 +23,14 @@
 import androidx.recyclerview.widget.RecyclerView
 import com.google.android.material.materialswitch.MaterialSwitch
 
-class SettingsPortForwardingActiveAdapter(private val mPortsStateManager: PortsStateManager) :
-    SettingsPortForwardingBaseAdapter<SettingsPortForwardingActiveAdapter.ViewHolder>() {
+class SettingsPortForwardingActiveAdapter(
+    private val portsStateManager: PortsStateManager,
+    private val context: Context,
+) : SettingsPortForwardingBaseAdapter<SettingsPortForwardingActiveAdapter.ViewHolder>() {
 
     override fun getItems(): ArrayList<SettingsPortForwardingItem> {
-        val enabledPorts = mPortsStateManager.getEnabledPorts()
-        return mPortsStateManager
+        val enabledPorts = portsStateManager.getEnabledPorts()
+        return portsStateManager
             .getActivePorts()
             .map { SettingsPortForwardingItem(it, enabledPorts.contains(it)) }
             .toCollection(ArrayList())
@@ -47,13 +50,18 @@
     }
 
     override fun onBindViewHolder(viewHolder: ViewHolder, position: Int) {
-        val port = mItems[position].port
-        viewHolder.port.text = port.toString()
+        val port = items[position].port
+        viewHolder.port.text =
+            context.getString(
+                R.string.settings_port_forwarding_active_ports_content,
+                port,
+                portsStateManager.getActivePortInfo(port)?.comm,
+            )
         viewHolder.enabledSwitch.contentDescription = viewHolder.port.text
         viewHolder.enabledSwitch.setOnCheckedChangeListener(null)
-        viewHolder.enabledSwitch.isChecked = mItems[position].enabled
+        viewHolder.enabledSwitch.isChecked = items[position].enabled
         viewHolder.enabledSwitch.setOnCheckedChangeListener { _, isChecked ->
-            mPortsStateManager.updateEnabledPort(port, isChecked)
+            portsStateManager.updateEnabledPort(port, isChecked)
         }
     }
 }
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/SettingsPortForwardingActivity.kt b/android/TerminalApp/java/com/android/virtualization/terminal/SettingsPortForwardingActivity.kt
index b4ca77b..db3926d 100644
--- a/android/TerminalApp/java/com/android/virtualization/terminal/SettingsPortForwardingActivity.kt
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/SettingsPortForwardingActivity.kt
@@ -31,30 +31,30 @@
 private const val PORT_RANGE_MAX: Int = 65535
 
 class SettingsPortForwardingActivity : AppCompatActivity() {
-    private lateinit var mPortsStateManager: PortsStateManager
-    private lateinit var mPortsStateListener: Listener
-    private lateinit var mActivePortsAdapter: SettingsPortForwardingActiveAdapter
-    private lateinit var mInactivePortsAdapter: SettingsPortForwardingInactiveAdapter
+    private lateinit var portsStateManager: PortsStateManager
+    private lateinit var portsStateListener: Listener
+    private lateinit var activePortsAdapter: SettingsPortForwardingActiveAdapter
+    private lateinit var inactivePortsAdapter: SettingsPortForwardingInactiveAdapter
 
     override fun onCreate(savedInstanceState: Bundle?) {
         super.onCreate(savedInstanceState)
         setContentView(R.layout.settings_port_forwarding)
 
-        mPortsStateManager = PortsStateManager.getInstance(this)
+        portsStateManager = PortsStateManager.getInstance(this)
 
-        mActivePortsAdapter = SettingsPortForwardingActiveAdapter(mPortsStateManager)
+        activePortsAdapter = SettingsPortForwardingActiveAdapter(portsStateManager, this)
         val activeRecyclerView: RecyclerView =
             findViewById(R.id.settings_port_forwarding_active_recycler_view)
         activeRecyclerView.layoutManager = LinearLayoutManager(this)
-        activeRecyclerView.adapter = mActivePortsAdapter
+        activeRecyclerView.adapter = activePortsAdapter
 
-        mInactivePortsAdapter = SettingsPortForwardingInactiveAdapter(mPortsStateManager, this)
+        inactivePortsAdapter = SettingsPortForwardingInactiveAdapter(portsStateManager, this)
         val inactiveRecyclerView: RecyclerView =
             findViewById(R.id.settings_port_forwarding_inactive_recycler_view)
         inactiveRecyclerView.layoutManager = LinearLayoutManager(this)
-        inactiveRecyclerView.adapter = mInactivePortsAdapter
+        inactiveRecyclerView.adapter = inactivePortsAdapter
 
-        mPortsStateListener = Listener()
+        portsStateListener = Listener()
 
         val addButton = findViewById<ImageButton>(R.id.settings_port_forwarding_inactive_add_button)
         addButton.setOnClickListener {
@@ -71,7 +71,7 @@
                                 R.id.settings_port_forwarding_inactive_add_dialog_text
                             )!!
                         val port = editText.text.toString().toInt()
-                        mPortsStateManager.updateEnabledPort(port, true)
+                        portsStateManager.updateEnabledPort(port, true)
                     }
                     .setNegativeButton(R.string.settings_port_forwarding_dialog_cancel, null)
                     .create()
@@ -121,8 +121,8 @@
                             )
                             positiveButton.setEnabled(false)
                         } else if (
-                            mPortsStateManager.getActivePorts().contains(port) ||
-                                mPortsStateManager.getEnabledPorts().contains(port)
+                            portsStateManager.getActivePorts().contains(port) ||
+                                portsStateManager.getEnabledPorts().contains(port)
                         ) {
                             editText.setError(
                                 getString(
@@ -141,19 +141,19 @@
 
     private fun refreshAdapters() {
         runOnUiThread {
-            mActivePortsAdapter.refreshItems()
-            mInactivePortsAdapter.refreshItems()
+            activePortsAdapter.refreshItems()
+            inactivePortsAdapter.refreshItems()
         }
     }
 
     override fun onResume() {
         super.onResume()
-        mPortsStateManager.registerListener(mPortsStateListener)
+        portsStateManager.registerListener(portsStateListener)
         refreshAdapters()
     }
 
     override fun onPause() {
-        mPortsStateManager.unregisterListener(mPortsStateListener)
+        portsStateManager.unregisterListener(portsStateListener)
         super.onPause()
     }
 
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/SettingsPortForwardingBaseAdapter.kt b/android/TerminalApp/java/com/android/virtualization/terminal/SettingsPortForwardingBaseAdapter.kt
index 4595372..5b8d022 100644
--- a/android/TerminalApp/java/com/android/virtualization/terminal/SettingsPortForwardingBaseAdapter.kt
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/SettingsPortForwardingBaseAdapter.kt
@@ -21,10 +21,10 @@
 
 abstract class SettingsPortForwardingBaseAdapter<T : RecyclerView.ViewHolder>() :
     RecyclerView.Adapter<T>() {
-    var mItems: SortedList<SettingsPortForwardingItem>
+    var items: SortedList<SettingsPortForwardingItem>
 
     init {
-        mItems =
+        items =
             SortedList(
                 SettingsPortForwardingItem::class.java,
                 object : SortedListAdapterCallback<SettingsPortForwardingItem>(this) {
@@ -52,11 +52,11 @@
             )
     }
 
-    override fun getItemCount() = mItems.size()
+    override fun getItemCount() = items.size()
 
     abstract fun getItems(): ArrayList<SettingsPortForwardingItem>
 
     fun refreshItems() {
-        mItems.replaceAll(getItems())
+        items.replaceAll(getItems())
     }
 }
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/SettingsPortForwardingInactiveAdapter.kt b/android/TerminalApp/java/com/android/virtualization/terminal/SettingsPortForwardingInactiveAdapter.kt
index d572129..e1fe468 100644
--- a/android/TerminalApp/java/com/android/virtualization/terminal/SettingsPortForwardingInactiveAdapter.kt
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/SettingsPortForwardingInactiveAdapter.kt
@@ -24,14 +24,14 @@
 import androidx.recyclerview.widget.RecyclerView
 
 class SettingsPortForwardingInactiveAdapter(
-    private val mPortsStateManager: PortsStateManager,
-    private val mContext: Context,
+    private val portsStateManager: PortsStateManager,
+    private val context: Context,
 ) : SettingsPortForwardingBaseAdapter<SettingsPortForwardingInactiveAdapter.ViewHolder>() {
 
     override fun getItems(): ArrayList<SettingsPortForwardingItem> {
-        return mPortsStateManager
+        return portsStateManager
             .getEnabledPorts()
-            .subtract(mPortsStateManager.getActivePorts())
+            .subtract(portsStateManager.getActivePorts())
             .map { SettingsPortForwardingItem(it, true) }
             .toCollection(ArrayList())
     }
@@ -50,15 +50,15 @@
     }
 
     override fun onBindViewHolder(viewHolder: ViewHolder, position: Int) {
-        val port = mItems[position].port
+        val port = items[position].port
         viewHolder.port.text = port.toString()
         viewHolder.closeButton.contentDescription =
-            mContext.getString(
+            context.getString(
                 R.string.settings_port_forwarding_other_enabled_port_close_button,
                 port,
             )
         viewHolder.closeButton.setOnClickListener { _ ->
-            mPortsStateManager.updateEnabledPort(port, false)
+            portsStateManager.updateEnabledPort(port, false)
         }
     }
 }
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/TerminalView.kt b/android/TerminalApp/java/com/android/virtualization/terminal/TerminalView.kt
index 18a39fa..0f990c5 100644
--- a/android/TerminalApp/java/com/android/virtualization/terminal/TerminalView.kt
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/TerminalView.kt
@@ -94,7 +94,7 @@
     // AccessibilityDelegate to the parent view where the events are sent to. And to guarantee that
     // the parent view exists, wait until the WebView is attached to the window by when the parent
     // must exist.
-    private val mA11yEventFilter: AccessibilityDelegate =
+    private val a11yEventFilter: AccessibilityDelegate =
         object : AccessibilityDelegate() {
             override fun onRequestSendAccessibilityEvent(
                 host: ViewGroup,
@@ -122,11 +122,11 @@
         super.onAttachedToWindow()
         if (a11yManager.isEnabled) {
             val parent = getParent() as View
-            parent.setAccessibilityDelegate(mA11yEventFilter)
+            parent.setAccessibilityDelegate(a11yEventFilter)
         }
     }
 
-    private val mA11yNodeProvider: AccessibilityNodeProvider =
+    private val a11yNodeProvider: AccessibilityNodeProvider =
         object : AccessibilityNodeProvider() {
             /** Returns the original NodeProvider that WebView implements. */
             private fun getParent(): AccessibilityNodeProvider? {
@@ -262,7 +262,7 @@
     override fun getAccessibilityNodeProvider(): AccessibilityNodeProvider? {
         val p = super.getAccessibilityNodeProvider()
         if (p != null && a11yManager.isEnabled) {
-            return mA11yNodeProvider
+            return a11yNodeProvider
         }
         return p
     }
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/VmLauncherService.kt b/android/TerminalApp/java/com/android/virtualization/terminal/VmLauncherService.kt
index 2796b86..5271e8b 100644
--- a/android/TerminalApp/java/com/android/virtualization/terminal/VmLauncherService.kt
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/VmLauncherService.kt
@@ -60,12 +60,12 @@
 
 class VmLauncherService : Service() {
     // TODO: using lateinit for some fields to avoid null
-    private var mExecutorService: ExecutorService? = null
-    private var mVirtualMachine: VirtualMachine? = null
-    private var mResultReceiver: ResultReceiver? = null
-    private var mServer: Server? = null
-    private var mDebianService: DebianServiceImpl? = null
-    private var mPortNotifier: PortNotifier? = null
+    private var executorService: ExecutorService? = null
+    private var virtualMachine: VirtualMachine? = null
+    private var resultReceiver: ResultReceiver? = null
+    private var server: Server? = null
+    private var debianService: DebianServiceImpl? = null
+    private var portNotifier: PortNotifier? = null
 
     interface VmLauncherServiceCallback {
         fun onVmStart()
@@ -81,7 +81,7 @@
 
     override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
         if (intent.action == ACTION_STOP_VM_LAUNCHER_SERVICE) {
-            if (mDebianService != null && mDebianService!!.shutdownDebian()) {
+            if (debianService != null && debianService!!.shutdownDebian()) {
                 // During shutdown, change the notification content to indicate that it's closing
                 val notification = createNotificationForTerminalClose()
                 getSystemService<NotificationManager?>(NotificationManager::class.java)
@@ -92,11 +92,11 @@
             }
             return START_NOT_STICKY
         }
-        if (mVirtualMachine != null) {
+        if (virtualMachine != null) {
             Log.d(TAG, "VM instance is already started")
             return START_NOT_STICKY
         }
-        mExecutorService = Executors.newCachedThreadPool(TerminalThreadFactory(applicationContext))
+        executorService = Executors.newCachedThreadPool(TerminalThreadFactory(applicationContext))
 
         val image = InstalledImage.getDefault(this)
         val json = ConfigJson.from(this, image.configPath)
@@ -117,28 +117,28 @@
         Trace.endSection()
         Trace.beginAsyncSection("debianBoot", 0)
 
-        mVirtualMachine = runner.vm
-        mResultReceiver =
+        virtualMachine = runner.vm
+        resultReceiver =
             intent.getParcelableExtra<ResultReceiver?>(
                 Intent.EXTRA_RESULT_RECEIVER,
                 ResultReceiver::class.java,
             )
 
         runner.exitStatus.thenAcceptAsync { success: Boolean ->
-            mResultReceiver?.send(if (success) RESULT_STOP else RESULT_ERROR, null)
+            resultReceiver?.send(if (success) RESULT_STOP else RESULT_ERROR, null)
             stopSelf()
         }
-        val logPath = getFileStreamPath(mVirtualMachine!!.name + ".log").toPath()
-        Logger.setup(mVirtualMachine!!, logPath, mExecutorService!!)
+        val logPath = getFileStreamPath(virtualMachine!!.name + ".log").toPath()
+        Logger.setup(virtualMachine!!, logPath, executorService!!)
 
         val notification =
             intent.getParcelableExtra<Notification?>(EXTRA_NOTIFICATION, Notification::class.java)
 
         startForeground(this.hashCode(), notification)
 
-        mResultReceiver!!.send(RESULT_START, null)
+        resultReceiver!!.send(RESULT_START, null)
 
-        mPortNotifier = PortNotifier(this)
+        portNotifier = PortNotifier(this)
 
         // TODO: dedup this part
         val nsdManager = getSystemService<NsdManager?>(NsdManager::class.java)
@@ -147,7 +147,7 @@
         info.serviceName = "ttyd"
         nsdManager.registerServiceInfoCallback(
             info,
-            mExecutorService!!,
+            executorService!!,
             object : NsdManager.ServiceInfoCallback {
                 override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) {}
 
@@ -245,11 +245,11 @@
         try {
             // TODO(b/372666638): gRPC for java doesn't support vsock for now.
             val port = 0
-            mDebianService = DebianServiceImpl(this)
-            mServer =
+            debianService = DebianServiceImpl(this)
+            server =
                 OkHttpServerBuilder.forPort(port, InsecureServerCredentials.create())
                     .intercept(interceptor)
-                    .addService(mDebianService)
+                    .addService(debianService)
                     .build()
                     .start()
         } catch (e: IOException) {
@@ -257,13 +257,13 @@
             return
         }
 
-        mExecutorService!!.execute(
+        executorService!!.execute(
             Runnable {
                 // TODO(b/373533555): we can use mDNS for that.
                 val debianServicePortFile = File(filesDir, "debian_service_port")
                 try {
                     FileOutputStream(debianServicePortFile).use { writer ->
-                        writer.write(mServer!!.port.toString().toByteArray())
+                        writer.write(server!!.port.toString().toByteArray())
                     }
                 } catch (e: IOException) {
                     Log.d(TAG, "cannot write grpc port number", e)
@@ -273,28 +273,28 @@
     }
 
     override fun onDestroy() {
-        mPortNotifier?.stop()
+        portNotifier?.stop()
         getSystemService<NotificationManager?>(NotificationManager::class.java).cancelAll()
         stopDebianServer()
-        if (mVirtualMachine != null) {
-            if (mVirtualMachine!!.getStatus() == VirtualMachine.STATUS_RUNNING) {
+        if (virtualMachine != null) {
+            if (virtualMachine!!.getStatus() == VirtualMachine.STATUS_RUNNING) {
                 try {
-                    mVirtualMachine!!.stop()
+                    virtualMachine!!.stop()
                     stopForeground(STOP_FOREGROUND_REMOVE)
                 } catch (e: VirtualMachineException) {
                     Log.e(TAG, "failed to stop a VM instance", e)
                 }
             }
-            mExecutorService?.shutdownNow()
-            mExecutorService = null
-            mVirtualMachine = null
+            executorService?.shutdownNow()
+            executorService = null
+            virtualMachine = null
         }
         super.onDestroy()
     }
 
     private fun stopDebianServer() {
-        mDebianService?.killForwarderHost()
-        mServer?.shutdown()
+        debianService?.killForwarderHost()
+        server?.shutdown()
     }
 
     companion object {
diff --git a/android/TerminalApp/res/values/strings.xml b/android/TerminalApp/res/values/strings.xml
index 40c354e..d3440d3 100644
--- a/android/TerminalApp/res/values/strings.xml
+++ b/android/TerminalApp/res/values/strings.xml
@@ -87,6 +87,8 @@
     <string name="settings_port_forwarding_sub_title">Allow/deny listening ports</string>
     <!-- Title for active ports setting in port forwarding [CHAR LIMIT=none] -->
     <string name="settings_port_forwarding_active_ports_title">Listening ports</string>
+    <!-- Text content for active ports setting in port forwarding [CHAR LIMIT=none] -->
+    <string name="settings_port_forwarding_active_ports_content"><xliff:g id="port_number" example="8000">%1$d</xliff:g> (<xliff:g id="process_name" example="undefined">%2$s</xliff:g>)</string>
     <!-- Title for other enabled ports setting in port forwarding [CHAR LIMIT=none] -->
     <string name="settings_port_forwarding_other_enabled_ports_title">Saved allowed ports</string>
     <!-- Description of add button for other enabled ports. Used for talkback. [CHAR LIMIT=16] -->
@@ -112,7 +114,7 @@
     <!-- Notification title for a new active port [CHAR LIMIT=none] -->
     <string name="settings_port_forwarding_notification_title">Terminal is requesting to open a new port</string>
     <!-- Notification content for a new active port [CHAR LIMIT=none] -->
-    <string name="settings_port_forwarding_notification_content">Port requested: <xliff:g id="port_number" example="8080">%d</xliff:g></string>
+    <string name="settings_port_forwarding_notification_content">Port requested: <xliff:g id="port_number" example="8000">%1$d</xliff:g> (<xliff:g id="process_name" example="undefined">%2$s</xliff:g>)</string>
     <!-- Notification action accept [CHAR LIMIT=16] -->
     <string name="settings_port_forwarding_notification_accept">Accept</string>
     <!-- Notification action deny [CHAR LIMIT=16] -->
diff --git a/guest/forwarder_guest_launcher/src/main.rs b/guest/forwarder_guest_launcher/src/main.rs
index bed8965..963a531 100644
--- a/guest/forwarder_guest_launcher/src/main.rs
+++ b/guest/forwarder_guest_launcher/src/main.rs
@@ -22,7 +22,7 @@
 use futures::stream::StreamExt;
 use log::{debug, error};
 use serde::Deserialize;
-use std::collections::HashSet;
+use std::collections::HashMap;
 use std::process::Stdio;
 use tokio::io::BufReader;
 use tokio::process::Command;
@@ -46,6 +46,8 @@
     ip: i8,
     lport: i32,
     rport: i32,
+    #[serde(alias = "C-COMM")]
+    c_comm: String,
     newstate: String,
 }
 
@@ -88,12 +90,12 @@
 }
 
 async fn send_active_ports_report(
-    listening_ports: HashSet<i32>,
+    listening_ports: HashMap<i32, ActivePort>,
     client: &mut DebianServiceClient<Channel>,
 ) -> Result<(), Box<dyn std::error::Error>> {
     let res = client
         .report_vm_active_ports(Request::new(ReportVmActivePortsRequest {
-            ports: listening_ports.into_iter().map(|port| ActivePort { port }).collect(),
+            ports: listening_ports.values().cloned().collect(),
         }))
         .await?
         .into_inner();
@@ -126,12 +128,16 @@
     // TODO(b/340126051): Consider using NETLINK_SOCK_DIAG for the optimization.
     let listeners = listeners::get_all()?;
     // TODO(b/340126051): Support distinguished port forwarding for ipv6 as well.
-    let mut listening_ports: HashSet<_> = listeners
+    let mut listening_ports: HashMap<_, _> = listeners
         .iter()
-        .map(|x| x.socket)
-        .filter(|x| x.is_ipv4())
-        .map(|x| x.port().into())
-        .filter(|x| is_forwardable_port(*x))
+        .filter(|x| x.socket.is_ipv4())
+        .map(|x| {
+            (
+                x.socket.port().into(),
+                ActivePort { port: x.socket.port().into(), comm: x.process.name.to_string() },
+            )
+        })
+        .filter(|(x, _)| is_forwardable_port(*x))
         .collect();
     send_active_ports_report(listening_ports.clone(), &mut client).await?;
 
@@ -149,7 +155,7 @@
         }
         match row.newstate.as_str() {
             TCPSTATES_STATE_LISTEN => {
-                listening_ports.insert(row.lport);
+                listening_ports.insert(row.lport, ActivePort { port: row.lport, comm: row.c_comm });
             }
             TCPSTATES_STATE_CLOSE => {
                 listening_ports.remove(&row.lport);
diff --git a/libs/debian_service/proto/DebianService.proto b/libs/debian_service/proto/DebianService.proto
index 7ab0af7..43955fa 100644
--- a/libs/debian_service/proto/DebianService.proto
+++ b/libs/debian_service/proto/DebianService.proto
@@ -31,9 +31,9 @@
   int32 cid = 1;
 }
 
-// TODO(b/382998551): Add more information about the port.
 message ActivePort {
   int32 port = 1;
+  string comm = 2;
 }
 
 message ReportVmActivePortsRequest {