1 /* <lambda>null2 * Copyright (C) 2024 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 package com.android.virtualization.terminal 17 18 import android.app.Notification 19 import android.app.NotificationManager 20 import android.app.PendingIntent 21 import android.app.Service 22 import android.content.Context 23 import android.content.Intent 24 import android.graphics.drawable.Icon 25 import android.net.nsd.NsdManager 26 import android.net.nsd.NsdServiceInfo 27 import android.os.Bundle 28 import android.os.Handler 29 import android.os.IBinder 30 import android.os.Looper 31 import android.os.Parcel 32 import android.os.Parcelable 33 import android.os.ResultReceiver 34 import android.os.StatFs 35 import android.os.SystemProperties 36 import android.system.virtualmachine.VirtualMachine 37 import android.system.virtualmachine.VirtualMachineCustomImageConfig 38 import android.system.virtualmachine.VirtualMachineCustomImageConfig.AudioConfig 39 import android.system.virtualmachine.VirtualMachineException 40 import android.util.Log 41 import android.widget.Toast 42 import androidx.annotation.WorkerThread 43 import com.android.system.virtualmachine.flags.Flags 44 import com.android.virtualization.terminal.InstalledImage.Companion.roundUp 45 import com.android.virtualization.terminal.MainActivity.Companion.PREFIX 46 import com.android.virtualization.terminal.MainActivity.Companion.TAG 47 import io.grpc.Grpc 48 import io.grpc.InsecureServerCredentials 49 import io.grpc.Metadata 50 import io.grpc.Server 51 import io.grpc.ServerCall 52 import io.grpc.ServerCallHandler 53 import io.grpc.ServerInterceptor 54 import io.grpc.Status 55 import io.grpc.okhttp.OkHttpServerBuilder 56 import java.io.File 57 import java.io.FileOutputStream 58 import java.io.IOException 59 import java.net.InetSocketAddress 60 import java.net.SocketAddress 61 import java.nio.file.Files 62 import java.util.concurrent.CompletableFuture 63 import java.util.concurrent.ExecutorService 64 import java.util.concurrent.Executors 65 import java.util.concurrent.TimeUnit 66 67 class VmLauncherService : Service() { 68 // Thread pool 69 private lateinit var bgThreads: ExecutorService 70 // Single thread 71 private lateinit var mainWorkerThread: ExecutorService 72 private lateinit var image: InstalledImage 73 74 // TODO: using lateinit for some fields to avoid null 75 private var virtualMachine: VirtualMachine? = null 76 private var server: Server? = null 77 private var debianService: DebianServiceImpl? = null 78 private var portNotifier: PortNotifier? = null 79 private var runner: Runner? = null 80 81 interface VmLauncherServiceCallback { 82 fun onVmStart() 83 84 fun onTerminalAvailable(info: TerminalInfo) 85 86 fun onVmStop() 87 88 fun onVmError() 89 } 90 91 override fun onBind(intent: Intent?): IBinder? { 92 return null 93 } 94 95 override fun onCreate() { 96 super.onCreate() 97 val threadFactory = TerminalThreadFactory(applicationContext) 98 bgThreads = Executors.newCachedThreadPool(threadFactory) 99 mainWorkerThread = Executors.newSingleThreadExecutor(threadFactory) 100 image = InstalledImage.getDefault(this) 101 } 102 103 override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int { 104 val resultReceiver = 105 intent.getParcelableExtra<ResultReceiver>( 106 Intent.EXTRA_RESULT_RECEIVER, 107 ResultReceiver::class.java, 108 )!! 109 110 when (intent.action) { 111 ACTION_START_VM -> { 112 val notification = 113 intent.getParcelableExtra<Notification>( 114 EXTRA_NOTIFICATION, 115 Notification::class.java, 116 )!! 117 118 val displayInfo = 119 intent.getParcelableExtra(EXTRA_DISPLAY_INFO, DisplayInfo::class.java)!! 120 121 // Note: this doesn't always do the resizing. If the current image size is the same 122 // as the requested size which is rounded up to the page alignment, resizing is not 123 // done. 124 val diskSize = intent.getLongExtra(EXTRA_DISK_SIZE, image.getApparentSize()) 125 126 mainWorkerThread.execute({ 127 doStart(notification, displayInfo, diskSize, resultReceiver) 128 }) 129 130 // Do this outside of the main worker thread, so that we don't cause 131 // ForegroundServiceDidNotStartInTimeException 132 startForeground(this.hashCode(), notification) 133 } 134 ACTION_SHUTDOWN_VM -> mainWorkerThread.execute({ doShutdown(resultReceiver) }) 135 else -> { 136 Log.e(TAG, "Unknown command " + intent.action) 137 stopSelf() 138 } 139 } 140 141 return START_NOT_STICKY 142 } 143 144 private fun calculateSparseDiskSize(): Long { 145 // With storage ballooning enabled, we create a sparse file with 95% of the total size. 146 val statFs = StatFs(filesDir.absolutePath) 147 val hostSize = statFs.totalBytes 148 return roundUp(hostSize * GUEST_SPARSE_DISK_SIZE_PERCENTAGE / 100) 149 } 150 151 private fun truncateDiskIfNecessary(image: InstalledImage) { 152 val curSize = image.getApparentSize() 153 val physicalSize = image.getPhysicalSize() 154 155 val expectedSize = calculateSparseDiskSize() 156 Log.d( 157 TAG, 158 "rootfs apparent size=$curSize, physical size=$physicalSize, expectedSize=$expectedSize", 159 ) 160 161 if (curSize != expectedSize) { 162 try { 163 image.truncate(expectedSize) 164 } catch (e: IOException) { 165 throw RuntimeException("Failed to truncate a disk", e) 166 } 167 } 168 } 169 170 // Convert the rootfs disk to a non-sparse file. 171 private fun convertToNonSparseDiskIfNecessary(image: InstalledImage) { 172 try { 173 val curApparentSize = image.getApparentSize() 174 val curPhysicalSize = image.getPhysicalSize() 175 Log.d(TAG, "Current disk size: apparent=$curApparentSize, physical=$curPhysicalSize") 176 177 // If storage ballooning was enabled via Flags.terminalStorageBalloon() before but it's 178 // now disabled, the disk is still a sparse file whose apparent size is too large. 179 // We need to shrink it to the minimum size. 180 // 181 // The disk file is considered sparse if its apparent disk size matches the expected 182 // sparse disk size. 183 // In addition, we consider it sparse if the physical size is clearly smaller than its 184 // apparent size. This additional condition is a fallback for cases 185 // where the logic of calculating the expected sparse disk size since the disk is 186 // created. 187 if ( 188 curApparentSize == calculateSparseDiskSize() || 189 curPhysicalSize < 190 curApparentSize * EXPECTED_PHYSICAL_SIZE_PERCENTAGE_FOR_NON_SPARSE / 100 191 ) { 192 Log.d(TAG, "A sparse disk is detected. Shrink it to the minimum size.") 193 val newSize = image.shrinkToMinimumSize() 194 Log.d(TAG, "Shrink the disk image: $curApparentSize -> $newSize") 195 } 196 } catch (e: IOException) { 197 throw RuntimeException("Failed to shrink rootfs disk", e) 198 return 199 } 200 } 201 202 @WorkerThread 203 private fun doStart( 204 notification: Notification, 205 displayInfo: DisplayInfo, 206 diskSize: Long, 207 resultReceiver: ResultReceiver, 208 ) { 209 val image = InstalledImage.getDefault(this) 210 val json = ConfigJson.from(this, image.configPath) 211 val configBuilder = json.toConfigBuilder(this) 212 val customImageConfigBuilder = json.toCustomImageConfigBuilder(this) 213 214 if (Flags.terminalStorageBalloon()) { 215 // When storage ballooning flag is enabled, convert rootfs disk into a sparse file. 216 truncateDiskIfNecessary(image) 217 } else { 218 // Convert rootfs disk into a sparse file if storage ballooning flag had been enabled 219 // and then disabled. 220 convertToNonSparseDiskIfNecessary(image) 221 222 // Note: this doesn't always do the resizing. If the current image size is the same as 223 // the requested size which is rounded up to the page alignment, resizing is not done. 224 image.resize(diskSize) 225 } 226 227 customImageConfigBuilder.setAudioConfig( 228 AudioConfig.Builder().setUseSpeaker(true).setUseMicrophone(true).build() 229 ) 230 if (overrideConfigIfNecessary(customImageConfigBuilder, displayInfo)) { 231 configBuilder.setCustomImageConfig(customImageConfigBuilder.build()) 232 } 233 val config = configBuilder.build() 234 235 runner = 236 try { 237 Runner.create(this, config) 238 } catch (e: VirtualMachineException) { 239 throw RuntimeException("cannot create runner", e) 240 } 241 242 val virtualMachine = runner!!.vm 243 val mbc = MemBalloonController(this, virtualMachine) 244 mbc.start() 245 246 runner!!.exitStatus.thenAcceptAsync { success: Boolean -> 247 mbc.stop() 248 resultReceiver.send(if (success) RESULT_STOP else RESULT_ERROR, null) 249 stopSelf() 250 } 251 val logDir = getFileStreamPath(virtualMachine.name + ".log").toPath() 252 Logger.setup(virtualMachine, logDir, bgThreads) 253 254 resultReceiver.send(RESULT_START, null) 255 256 portNotifier = PortNotifier(this) 257 258 getTerminalServiceInfo() 259 .thenAcceptAsync( 260 { info -> 261 val ipAddress = info.hostAddresses[0].hostAddress 262 val port = info.port 263 val bundle = Bundle() 264 bundle.putString(KEY_TERMINAL_IPADDRESS, ipAddress) 265 bundle.putInt(KEY_TERMINAL_PORT, port) 266 resultReceiver.send(RESULT_TERMINAL_AVAIL, bundle) 267 startDebianServer(ipAddress) 268 }, 269 bgThreads, 270 ) 271 .exceptionallyAsync( 272 { e -> 273 Log.e(TAG, "Failed to start VM", e) 274 resultReceiver.send(RESULT_ERROR, null) 275 stopSelf() 276 null 277 }, 278 bgThreads, 279 ) 280 } 281 282 private fun getTerminalServiceInfo(): CompletableFuture<NsdServiceInfo> { 283 val executor = Executors.newSingleThreadExecutor(TerminalThreadFactory(applicationContext)) 284 val nsdManager = getSystemService<NsdManager?>(NsdManager::class.java) 285 val queryInfo = NsdServiceInfo() 286 queryInfo.serviceType = "_http._tcp" 287 queryInfo.serviceName = "ttyd" 288 var resolvedInfo = CompletableFuture<NsdServiceInfo>() 289 290 nsdManager.registerServiceInfoCallback( 291 queryInfo, 292 executor, 293 object : NsdManager.ServiceInfoCallback { 294 var found: Boolean = false 295 296 override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) {} 297 298 override fun onServiceInfoCallbackUnregistered() { 299 executor.shutdown() 300 } 301 302 override fun onServiceLost() {} 303 304 override fun onServiceUpdated(info: NsdServiceInfo) { 305 Log.i(TAG, "Service found: $info") 306 if (!found) { 307 found = true 308 nsdManager.unregisterServiceInfoCallback(this) 309 resolvedInfo.complete(info) 310 } 311 } 312 }, 313 ) 314 315 resolvedInfo.orTimeout(VM_BOOT_TIMEOUT_SECONDS.toLong(), TimeUnit.SECONDS) 316 return resolvedInfo 317 } 318 319 private fun createNotificationForTerminalClose(): Notification { 320 val stopIntent = Intent() 321 stopIntent.setClass(this, VmLauncherService::class.java) 322 stopIntent.setAction(ACTION_SHUTDOWN_VM) 323 val stopPendingIntent = 324 PendingIntent.getService( 325 this, 326 0, 327 stopIntent, 328 PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, 329 ) 330 val icon = Icon.createWithResource(resources, R.drawable.ic_launcher_foreground) 331 val stopActionText: String? = 332 resources.getString(R.string.service_notification_force_quit_action) 333 val stopNotificationTitle: String? = 334 resources.getString(R.string.service_notification_close_title) 335 return Notification.Builder(this, Application.CHANNEL_SYSTEM_EVENTS_ID) 336 .setSmallIcon(R.drawable.ic_launcher_foreground) 337 .setContentTitle(stopNotificationTitle) 338 .setOngoing(true) 339 .setSilent(true) 340 .addAction(Notification.Action.Builder(icon, stopActionText, stopPendingIntent).build()) 341 .build() 342 } 343 344 private fun overrideConfigIfNecessary( 345 builder: VirtualMachineCustomImageConfig.Builder, 346 displayInfo: DisplayInfo?, 347 ): Boolean { 348 var changed = false 349 // TODO: check if ANGLE is enabled for the app. 350 if (Files.exists(ImageArchive.getSdcardPathForTesting().resolve("virglrenderer"))) { 351 builder.setGpuConfig( 352 VirtualMachineCustomImageConfig.GpuConfig.Builder() 353 .setBackend("virglrenderer") 354 .setRendererUseEgl(true) 355 .setRendererUseGles(true) 356 .setRendererUseGlx(false) 357 .setRendererUseSurfaceless(true) 358 .setRendererUseVulkan(false) 359 .setContextTypes(arrayOf<String>("virgl2")) 360 .build() 361 ) 362 Toast.makeText(this, R.string.virgl_enabled, Toast.LENGTH_SHORT).show() 363 changed = true 364 } else if (Files.exists(ImageArchive.getSdcardPathForTesting().resolve("gfxstream"))) { 365 // TODO: check if the configuration is right. current config comes from cuttlefish's one 366 builder.setGpuConfig( 367 VirtualMachineCustomImageConfig.GpuConfig.Builder() 368 .setBackend("gfxstream") 369 .setRendererUseEgl(false) 370 .setRendererUseGles(false) 371 .setRendererUseGlx(false) 372 .setRendererUseSurfaceless(true) 373 .setRendererUseVulkan(true) 374 .setContextTypes(arrayOf<String>("gfxstream-vulkan", "gfxstream-composer")) 375 .build() 376 ) 377 Toast.makeText(this, "gfxstream", Toast.LENGTH_SHORT).show() 378 changed = true 379 } 380 381 // Set the initial display size 382 // TODO(jeongik): set up the display size on demand 383 if (Flags.terminalGuiSupport() && displayInfo != null) { 384 builder 385 .setDisplayConfig( 386 VirtualMachineCustomImageConfig.DisplayConfig.Builder() 387 .setWidth(displayInfo.width) 388 .setHeight(displayInfo.height) 389 .setHorizontalDpi(displayInfo.dpi) 390 .setVerticalDpi(displayInfo.dpi) 391 .setRefreshRate(displayInfo.refreshRate) 392 .build() 393 ) 394 .useKeyboard(true) 395 .useMouse(true) 396 .useTouch(true) 397 changed = true 398 } 399 400 val image = InstalledImage.getDefault(this) 401 if (image.hasBackup()) { 402 val backup = image.backupFile 403 builder.addDisk(VirtualMachineCustomImageConfig.Disk.RWDisk(backup.toString())) 404 changed = true 405 } 406 return changed 407 } 408 409 private fun startDebianServer(ipAddress: String?) { 410 val interceptor: ServerInterceptor = 411 object : ServerInterceptor { 412 override fun <ReqT, RespT> interceptCall( 413 call: ServerCall<ReqT?, RespT?>, 414 headers: Metadata?, 415 next: ServerCallHandler<ReqT?, RespT?>, 416 ): ServerCall.Listener<ReqT?>? { 417 val remoteAddr = 418 call.attributes.get<SocketAddress?>(Grpc.TRANSPORT_ATTR_REMOTE_ADDR) 419 as InetSocketAddress? 420 421 if (remoteAddr?.address?.hostAddress == ipAddress) { 422 // Allow the request only if it is from VM 423 return next.startCall(call, headers) 424 } 425 Log.d(TAG, "blocked grpc request from $remoteAddr") 426 call.close(Status.Code.PERMISSION_DENIED.toStatus(), Metadata()) 427 return object : ServerCall.Listener<ReqT?>() {} 428 } 429 } 430 try { 431 // TODO(b/372666638): gRPC for java doesn't support vsock for now. 432 val port = 0 433 debianService = DebianServiceImpl(this) 434 server = 435 OkHttpServerBuilder.forPort(port, InsecureServerCredentials.create()) 436 .intercept(interceptor) 437 .addService(debianService) 438 .build() 439 .start() 440 } catch (e: IOException) { 441 Log.d(TAG, "grpc server error", e) 442 return 443 } 444 445 bgThreads.execute( 446 Runnable { 447 // TODO(b/373533555): we can use mDNS for that. 448 val debianServicePortFile = File(filesDir, "debian_service_port") 449 try { 450 FileOutputStream(debianServicePortFile).use { writer -> 451 writer.write(server!!.port.toString().toByteArray()) 452 } 453 } catch (e: IOException) { 454 Log.d(TAG, "cannot write grpc port number", e) 455 } 456 } 457 ) 458 459 if (Flags.terminalStorageBalloon()) { 460 StorageBalloonWorker.start(this, debianService!!) 461 } 462 } 463 464 @WorkerThread 465 private fun doShutdown(resultReceiver: ResultReceiver?) { 466 runner?.exitStatus?.thenAcceptAsync { success: Boolean -> 467 resultReceiver?.send(if (success) RESULT_STOP else RESULT_ERROR, null) 468 } 469 if (debianService != null && debianService!!.shutdownDebian()) { 470 // During shutdown, change the notification content to indicate that it's closing 471 val notification = createNotificationForTerminalClose() 472 getSystemService<NotificationManager?>(NotificationManager::class.java) 473 .notify(this.hashCode(), notification) 474 475 runner?.also { r -> 476 // For the case that shutdown from the guest agent fails. 477 // When timeout is set, the original CompletableFuture's every `thenAcceptAsync` is 478 // canceled as well. So add empty `thenAcceptAsync` to avoid interference. 479 r.exitStatus 480 .thenAcceptAsync {} 481 .orTimeout(SHUTDOWN_TIMEOUT_SECONDS, TimeUnit.SECONDS) 482 .exceptionally { 483 Log.e( 484 TAG, 485 "Stop the service directly because the VM instance isn't stopped with " + 486 "graceful shutdown", 487 ) 488 r.vm.stop() 489 null 490 } 491 } 492 runner = null 493 } else { 494 // If there is no Debian service or it fails to shutdown, just stop the service. 495 runner?.vm?.stop() 496 } 497 } 498 499 private fun stopDebianServer() { 500 debianService?.killForwarderHost() 501 debianService?.closeStorageBalloonRequestQueue() 502 server?.shutdown() 503 } 504 505 override fun onDestroy() { 506 mainWorkerThread.execute({ 507 if (runner?.vm?.getStatus() == VirtualMachine.STATUS_RUNNING) { 508 doShutdown(null) 509 } 510 }) 511 portNotifier?.stop() 512 getSystemService<NotificationManager?>(NotificationManager::class.java).cancelAll() 513 stopDebianServer() 514 bgThreads.shutdownNow() 515 mainWorkerThread.shutdown() 516 stopForeground(STOP_FOREGROUND_REMOVE) 517 super.onDestroy() 518 } 519 520 companion object { 521 private const val ACTION_START_VM: String = PREFIX + "ACTION_START_VM" 522 private const val EXTRA_NOTIFICATION = PREFIX + "EXTRA_NOTIFICATION" 523 private const val EXTRA_DISPLAY_INFO = PREFIX + "EXTRA_DISPLAY_INFO" 524 private const val EXTRA_DISK_SIZE = PREFIX + "EXTRA_DISK_SIZE" 525 526 private const val ACTION_SHUTDOWN_VM: String = PREFIX + "ACTION_SHUTDOWN_VM" 527 528 private const val RESULT_START = 0 529 private const val RESULT_STOP = 1 530 private const val RESULT_ERROR = 2 531 private const val RESULT_TERMINAL_AVAIL = 3 532 533 private const val KEY_TERMINAL_IPADDRESS = "address" 534 private const val KEY_TERMINAL_PORT = "port" 535 536 private const val SHUTDOWN_TIMEOUT_SECONDS = 3L 537 538 private const val GUEST_SPARSE_DISK_SIZE_PERCENTAGE = 95 539 private const val EXPECTED_PHYSICAL_SIZE_PERCENTAGE_FOR_NON_SPARSE = 90 540 541 private val VM_BOOT_TIMEOUT_SECONDS: Int = 542 { 543 val deviceName = SystemProperties.get("ro.product.vendor.device", "") 544 val cuttlefish = deviceName.startsWith("vsoc_") 545 val goldfish = deviceName.startsWith("emu64") 546 547 if (cuttlefish || goldfish) { 548 3 * 60 549 } else { 550 30 551 } 552 }() 553 554 private fun prepareIntent(context: Context, callback: VmLauncherServiceCallback): Intent { 555 val intent = Intent(context.getApplicationContext(), VmLauncherService::class.java) 556 val resultReceiver = 557 object : ResultReceiver(Handler(Looper.myLooper()!!)) { 558 override fun onReceiveResult(resultCode: Int, resultData: Bundle?) { 559 when (resultCode) { 560 RESULT_START -> callback.onVmStart() 561 RESULT_TERMINAL_AVAIL -> { 562 val ipAddress = resultData!!.getString(KEY_TERMINAL_IPADDRESS) 563 val port = resultData!!.getInt(KEY_TERMINAL_PORT) 564 callback.onTerminalAvailable(TerminalInfo(ipAddress!!, port)) 565 } 566 RESULT_STOP -> callback.onVmStop() 567 RESULT_ERROR -> callback.onVmError() 568 else -> Log.e(TAG, "unknown result code: " + resultCode) 569 } 570 } 571 } 572 573 val parcel = Parcel.obtain() 574 resultReceiver.writeToParcel(parcel, 0) 575 parcel.setDataPosition(0) 576 intent.putExtra( 577 Intent.EXTRA_RESULT_RECEIVER, 578 ResultReceiver.CREATOR.createFromParcel(parcel).also { parcel.recycle() }, 579 ) 580 return intent 581 } 582 583 fun getIntentForStart( 584 context: Context, 585 callback: VmLauncherServiceCallback, 586 notification: Notification?, 587 displayInfo: DisplayInfo, 588 diskSize: Long?, 589 ): Intent { 590 val i = prepareIntent(context, callback) 591 i.setAction(ACTION_START_VM) 592 i.putExtra(EXTRA_NOTIFICATION, notification) 593 i.putExtra(EXTRA_DISPLAY_INFO, displayInfo) 594 if (diskSize != null) { 595 i.putExtra(EXTRA_DISK_SIZE, diskSize) 596 } 597 return i 598 } 599 600 fun getIntentForShutdown(context: Context, callback: VmLauncherServiceCallback): Intent { 601 val i = prepareIntent(context, callback) 602 i.setAction(ACTION_SHUTDOWN_VM) 603 return i 604 } 605 } 606 } 607 608 data class TerminalInfo(val ipAddress: String, val port: Int) 609 610 data class DisplayInfo(val width: Int, val height: Int, val dpi: Int, val refreshRate: Int) : 611 Parcelable { 612 constructor( 613 source: Parcel 614 ) : this(source.readInt(), source.readInt(), source.readInt(), source.readInt()) 615 describeContentsnull616 override fun describeContents(): Int = 0 617 618 override fun writeToParcel(dest: Parcel, flags: Int) { 619 dest.writeInt(width) 620 dest.writeInt(height) 621 dest.writeInt(dpi) 622 dest.writeInt(refreshRate) 623 } 624 625 companion object { 626 @JvmField 627 val CREATOR = 628 object : Parcelable.Creator<DisplayInfo> { createFromParcelnull629 override fun createFromParcel(source: Parcel): DisplayInfo = DisplayInfo(source) 630 631 override fun newArray(size: Int) = arrayOfNulls<DisplayInfo>(size) 632 } 633 } 634 } 635