• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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