Retrieve terminal info in a more robust way

Previously, it was possible for onServiceUpdated is called "after" the
service is destroyed due to an early exit of the application. When that
happened, we got RejectedExecutionException because the executor service
given to the NsdManager is shutdown as part of the service destroyal.

This CL fixes that by having a dedicated executor service for NsdManager
and shut it down upon onServiceInfoCallbackUnregistered, which means
there will be no more callbacks.

This CL also does some pending refactorings;

* Retrival of the terminal service info (ip addr & port) is deduped
  across MainActivity and VmLauncherService

* Uses CompletableFuture to abstract the retrival of the terminal
  service info.

Bug: 401041913
Test: try to kill the app before the terminal is connected
Change-Id: Ie268601317f040c2da9d33d31b2b9d1f9e475b61
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/MainActivity.kt b/android/TerminalApp/java/com/android/virtualization/terminal/MainActivity.kt
index f6eeff9..662fef5 100644
--- a/android/TerminalApp/java/com/android/virtualization/terminal/MainActivity.kt
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/MainActivity.kt
@@ -24,8 +24,6 @@
 import android.graphics.drawable.Icon
 import android.graphics.fonts.FontStyle
 import android.net.Uri
-import android.net.nsd.NsdManager
-import android.net.nsd.NsdServiceInfo
 import android.os.Build
 import android.os.Bundle
 import android.os.ConditionVariable
@@ -62,6 +60,7 @@
 import java.io.IOException
 import java.net.MalformedURLException
 import java.net.URL
+import java.util.concurrent.CompletableFuture
 import java.util.concurrent.ExecutorService
 import java.util.concurrent.Executors
 
@@ -78,12 +77,11 @@
     private lateinit var image: InstalledImage
     private lateinit var accessibilityManager: AccessibilityManager
     private lateinit var manageExternalStorageActivityResultLauncher: ActivityResultLauncher<Intent>
-    private var ipAddress: String? = null
-    private var port: Int? = null
     private lateinit var terminalViewModel: TerminalViewModel
     private lateinit var viewPager: ViewPager2
     private lateinit var tabLayout: TabLayout
     private lateinit var terminalTabAdapter: TerminalTabAdapter
+    private val terminalInfo = CompletableFuture<TerminalInfo>()
 
     override fun onCreate(savedInstanceState: Bundle?) {
         super.onCreate(savedInstanceState)
@@ -243,40 +241,12 @@
     }
 
     fun connectToTerminalService(terminalView: TerminalView) {
-        if (ipAddress != null && port != null) {
-            val url = getTerminalServiceUrl(ipAddress, port!!)
-            terminalView.loadUrl(url.toString())
-            return
-        }
-        // TODO: refactor this block as a method
-        val nsdManager = getSystemService<NsdManager>(NsdManager::class.java)
-        val info = NsdServiceInfo()
-        info.serviceType = "_http._tcp"
-        info.serviceName = "ttyd"
-        nsdManager.registerServiceInfoCallback(
-            info,
-            executorService,
-            object : NsdManager.ServiceInfoCallback {
-                var loaded: Boolean = false
-
-                override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) {}
-
-                override fun onServiceInfoCallbackUnregistered() {}
-
-                override fun onServiceLost() {}
-
-                override fun onServiceUpdated(info: NsdServiceInfo) {
-                    Log.i(TAG, "Service found: $info")
-                    if (!loaded) {
-                        ipAddress = info.hostAddresses[0].hostAddress
-                        port = info.port
-                        val url = getTerminalServiceUrl(ipAddress, port!!)
-                        loaded = true
-                        nsdManager.unregisterServiceInfoCallback(this)
-                        runOnUiThread(Runnable { terminalView.loadUrl(url.toString()) })
-                    }
-                }
+        terminalInfo.thenAcceptAsync(
+            { info ->
+                val url = getTerminalServiceUrl(info.ipAddress, info.port)
+                runOnUiThread({ terminalView.loadUrl(url.toString()) })
             },
+            executorService,
         )
     }
 
@@ -292,6 +262,10 @@
         Log.i(TAG, "onVmStart()")
     }
 
+    override fun onTerminalAvailable(info: TerminalInfo) {
+        terminalInfo.complete(info)
+    }
+
     override fun onVmStop() {
         Log.i(TAG, "onVmStop()")
         finish()
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/VmLauncherService.kt b/android/TerminalApp/java/com/android/virtualization/terminal/VmLauncherService.kt
index 5f3fd49..345e8dd 100644
--- a/android/TerminalApp/java/com/android/virtualization/terminal/VmLauncherService.kt
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/VmLauncherService.kt
@@ -60,6 +60,7 @@
 import java.net.InetSocketAddress
 import java.net.SocketAddress
 import java.nio.file.Files
+import java.util.concurrent.CompletableFuture
 import java.util.concurrent.ExecutorService
 import java.util.concurrent.Executors
 
@@ -117,6 +118,8 @@
     interface VmLauncherServiceCallback {
         fun onVmStart()
 
+        fun onTerminalAvailable(info: TerminalInfo)
+
         fun onVmStop()
 
         fun onVmError()
@@ -230,35 +233,56 @@
 
         portNotifier = PortNotifier(this)
 
-        // TODO: dedup this part
+        getTerminalServiceInfo()
+            .thenAcceptAsync(
+                { info ->
+                    val ipAddress = info.hostAddresses[0].hostAddress
+                    val port = info.port
+                    val bundle = Bundle()
+                    bundle.putString(KEY_TERMINAL_IPADDRESS, ipAddress)
+                    bundle.putInt(KEY_TERMINAL_PORT, port)
+                    resultReceiver!!.send(RESULT_TERMINAL_AVAIL, bundle)
+                    startDebianServer(ipAddress)
+                },
+                executorService,
+            )
+
+        return START_NOT_STICKY
+    }
+
+    private fun getTerminalServiceInfo(): CompletableFuture<NsdServiceInfo> {
+        val executor = Executors.newSingleThreadExecutor(TerminalThreadFactory(applicationContext))
         val nsdManager = getSystemService<NsdManager?>(NsdManager::class.java)
-        val info = NsdServiceInfo()
-        info.serviceType = "_http._tcp"
-        info.serviceName = "ttyd"
+        val queryInfo = NsdServiceInfo()
+        queryInfo.serviceType = "_http._tcp"
+        queryInfo.serviceName = "ttyd"
+        var resolvedInfo = CompletableFuture<NsdServiceInfo>()
+
         nsdManager.registerServiceInfoCallback(
-            info,
-            executorService!!,
+            queryInfo,
+            executor,
             object : NsdManager.ServiceInfoCallback {
-                var started: Boolean = false
+                var found: Boolean = false
 
                 override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) {}
 
-                override fun onServiceInfoCallbackUnregistered() {}
+                override fun onServiceInfoCallbackUnregistered() {
+                    executor.shutdown()
+                }
 
                 override fun onServiceLost() {}
 
                 override fun onServiceUpdated(info: NsdServiceInfo) {
                     Log.i(TAG, "Service found: $info")
-                    if (!started) {
-                        started = true
+                    if (!found) {
+                        found = true
                         nsdManager.unregisterServiceInfoCallback(this)
-                        startDebianServer(info.hostAddresses[0].hostAddress)
+                        resolvedInfo.complete(info)
                     }
                 }
             },
         )
-
-        return START_NOT_STICKY
+        return resolvedInfo
     }
 
     private fun createNotificationForTerminalClose(): Notification {
@@ -438,6 +462,10 @@
         private const val RESULT_START = 0
         private const val RESULT_STOP = 1
         private const val RESULT_ERROR = 2
+        private const val RESULT_TERMINAL_AVAIL = 3
+
+        private const val KEY_TERMINAL_IPADDRESS = "address"
+        private const val KEY_TERMINAL_PORT = "port"
 
         private const val INITIAL_MEM_BALLOON_PERCENT = 10
         private const val MAX_MEM_BALLOON_PERCENT = 50
@@ -463,6 +491,11 @@
                         }
                         when (resultCode) {
                             RESULT_START -> callback.onVmStart()
+                            RESULT_TERMINAL_AVAIL -> {
+                                val ipAddress = resultData!!.getString(KEY_TERMINAL_IPADDRESS)
+                                val port = resultData!!.getInt(KEY_TERMINAL_PORT)
+                                callback.onTerminalAvailable(TerminalInfo(ipAddress!!, port))
+                            }
                             RESULT_STOP -> callback.onVmStop()
                             RESULT_ERROR -> callback.onVmError()
                         }
@@ -489,6 +522,8 @@
     }
 }
 
+data class TerminalInfo(val ipAddress: String, val port: Int)
+
 data class DisplayInfo(val width: Int, val height: Int, val dpi: Int, val refreshRate: Int) :
     Parcelable {
     constructor(