1 /*
2  * Copyright (C) 2022 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.cuttlefish.tests;
17 
18 import static com.google.common.truth.Truth.assertThat;
19 
20 import android.platform.test.annotations.LargeTest;
21 
22 import com.android.cuttlefish.tests.utils.CuttlefishHostTest;
23 import com.android.tradefed.device.DeviceNotAvailableException;
24 import com.android.tradefed.device.ITestDevice;
25 import com.android.tradefed.log.LogUtil.CLog;
26 import com.android.tradefed.testtype.DeviceJUnit4ClassRunner;
27 import com.android.tradefed.testtype.junit4.BaseHostJUnit4Test;
28 import com.android.tradefed.util.AbiUtils;
29 import com.android.tradefed.util.CommandResult;
30 import com.android.tradefed.util.CommandStatus;
31 import com.google.auto.value.AutoValue;
32 import com.google.common.base.Splitter;
33 import com.google.common.base.Strings;
34 import com.google.common.collect.Lists;
35 import com.google.common.collect.MapDifference;
36 import com.google.common.collect.Maps;
37 import com.google.common.collect.Range;
38 import com.google.common.truth.Correspondence;
39 
40 import java.io.BufferedReader;
41 import java.io.File;
42 import java.io.FileNotFoundException;
43 import java.io.InputStreamReader;
44 import java.util.ArrayList;
45 import java.util.Arrays;
46 import java.util.Collection;
47 import java.util.Collections;
48 import java.util.HashMap;
49 import java.util.Iterator;
50 import java.util.List;
51 import java.util.Map;
52 import java.util.Objects;
53 import java.util.UUID;
54 import java.util.regex.Matcher;
55 import java.util.regex.Pattern;
56 
57 import org.json.JSONArray;
58 import org.json.JSONException;
59 import org.json.JSONObject;
60 import org.junit.After;
61 import org.junit.Before;
62 import org.junit.Test;
63 import org.junit.runner.RunWith;
64 
65 /**
66  * Tests that a Cuttlefish device can interactively connect and disconnect displays.
67  */
68 @RunWith(DeviceJUnit4ClassRunner.class)
69 public class CuttlefishDisplayHotplugTest extends CuttlefishHostTest {
70 
71     private static final long DEFAULT_TIMEOUT_MS = 5000;
72 
73     private static final String CVD_BINARY_BASENAME = "cvd";
74 
75     private static final String CVD_DISPLAY_BINARY_BASENAME = "cvd_internal_display";
76 
runCvdCommand(Collection<String> commandArgs)77     private CommandResult runCvdCommand(Collection<String> commandArgs) throws FileNotFoundException {
78         // TODO: Switch back to using `cvd` after either:
79         //  * Commands under `cvd` can be used with instances launched through `launch_cvd`.
80         //  * ATP launches instances using `cvd start` instead of `launch_cvd`.
81         String cvdBinary = runner.getHostBinaryPath(CVD_DISPLAY_BINARY_BASENAME);
82 
83         List<String> fullCommand = new ArrayList<String>(commandArgs);
84         fullCommand.add(0, cvdBinary);
85 
86         // Remove the "display" part of the command until switching back to `cvd`.
87         fullCommand.remove(1);
88 
89         return runner.run(DEFAULT_TIMEOUT_MS, fullCommand.toArray(new String[0]));
90     }
91 
92     private static final String HELPER_APP_APK = "CuttlefishDisplayHotplugHelperApp.apk";
93 
94     private static final String HELPER_APP_PKG = "com.android.cuttlefish.displayhotplughelper";
95 
96     private static final String HELPER_APP_ACTIVITY = "com.android.cuttlefish.displayhotplughelper/.DisplayHotplugHelperApp";
97 
98     private static final String HELPER_APP_UUID_FLAG = "display_hotplug_uuid";
99 
100     private static final int HELPER_APP_LOG_CHECK_ATTEMPTS = 5;
101 
102     private static final int HELPER_APP_LOG_CHECK_TIMEOUT_MILLISECONDS = 1000;
103 
104     private static final int CHECK_FOR_UPDATED_GUEST_DISPLAYS_ATTEMPTS = 5;
105 
106     private static final int CHECK_FOR_UPDATED_GUEST_DISPLAYS_SLEEP_MILLISECONDS = 500;
107 
108     private static final Splitter LOGCAT_NEWLINE_SPLITTER = Splitter.on('\n').trimResults();
109 
110     @Before
setUp()111     public void setUp() throws Exception {
112         getDevice().uninstallPackage(HELPER_APP_PKG);
113         installPackage(HELPER_APP_APK);
114     }
115 
116     @After
tearDown()117     public void tearDown() throws Exception {
118         getDevice().uninstallPackage(HELPER_APP_PKG);
119     }
120 
121     /**
122      * Display information as seen from the host (i.e. from Crosvm via a `cvd display` command).
123      */
124     @AutoValue
125     public static abstract class HostDisplayInfo {
create(int id, int width, int height)126         static HostDisplayInfo create(int id, int width, int height) {
127             return new AutoValue_CuttlefishDisplayHotplugTest_HostDisplayInfo(id, width, height);
128         }
129 
id()130         abstract int id();
width()131         abstract int width();
height()132         abstract int height();
133     }
134 
135     /**
136      * Display information as seen from the guest (i.e. from SurfaceFlinger/DisplayManager).
137      */
138     @AutoValue
139     public static abstract class GuestDisplayInfo {
create(int id, int width, int height)140         static GuestDisplayInfo create(int id, int width, int height) {
141             return new AutoValue_CuttlefishDisplayHotplugTest_GuestDisplayInfo(id, width, height);
142         }
143 
id()144         abstract int id();
width()145         abstract int width();
height()146         abstract int height();
147     }
148 
149     /**
150      * Expected input JSON format:
151      *
152      *   {
153      *     "displays" : {
154      *       "<display id>": {
155      *         "mode": {
156      *           "windowed": [
157      *             <width>,
158      *             <height>,
159      *           ],
160      *         },
161      *         ...
162      *       },
163      *       ...
164      *     }
165      *   }
166      *
167      */
parseHostDisplayInfos(String inputJson)168     private Map<Integer, HostDisplayInfo> parseHostDisplayInfos(String inputJson) {
169         if (Strings.isNullOrEmpty(inputJson)) {
170             throw new IllegalArgumentException("Null display info json.");
171         }
172 
173         Map<Integer, HostDisplayInfo> displayInfos = new HashMap<Integer, HostDisplayInfo>();
174 
175         try {
176             JSONObject json = new JSONObject(inputJson);
177             JSONObject jsonDisplays = json.getJSONObject("displays");
178             for (Iterator<String> keyIt = jsonDisplays.keys(); keyIt.hasNext(); ) {
179                 String displayNumberString = keyIt.next();
180 
181                 JSONObject jsonDisplay = jsonDisplays.getJSONObject(displayNumberString);
182                 JSONObject jsonDisplayMode = jsonDisplay.getJSONObject("mode");
183                 JSONArray jsonDisplayModeWindowed = jsonDisplayMode.getJSONArray("windowed");
184 
185                 int id = Integer.parseInt(displayNumberString);
186                 int w = jsonDisplayModeWindowed.getInt(0);
187                 int h = jsonDisplayModeWindowed.getInt(1);
188 
189                 displayInfos.put(id, HostDisplayInfo.create(id, w, h));
190             }
191         } catch (JSONException e) {
192             throw new IllegalArgumentException("Invalid display info json: " + inputJson, e);
193         }
194 
195         return displayInfos;
196     }
197 
198 
199     /**
200      * Expected input JSON format:
201      *
202      *   {
203      *     "displays" : [
204      *       {
205      *           "id": <id>,
206      *           "name": <name>,
207      *           "width": <width>,
208      *           "height": <height>,
209      *       },
210      *       ...
211      *     ]
212      *   }
213      */
parseGuestDisplayInfos(String inputJson)214     private Map<Integer, GuestDisplayInfo> parseGuestDisplayInfos(String inputJson) {
215         if (Strings.isNullOrEmpty(inputJson)) {
216             throw new NullPointerException("Null display info json.");
217         }
218 
219         Map<Integer, GuestDisplayInfo> displayInfos = new HashMap<Integer, GuestDisplayInfo>();
220 
221         try {
222             JSONObject json = new JSONObject(inputJson);
223             JSONArray jsonDisplays = json.getJSONArray("displays");
224             for (int i = 0; i < jsonDisplays.length(); i++) {
225                 JSONObject jsonDisplay = jsonDisplays.getJSONObject(i);
226                 int id = jsonDisplay.getInt("id");
227                 int w = jsonDisplay.getInt("width");
228                 int h = jsonDisplay.getInt("height");
229                 displayInfos.put(id, GuestDisplayInfo.create(id, w, h));
230             }
231         } catch (JSONException e) {
232             throw new IllegalArgumentException("Invalid display info json: " + inputJson, e);
233         }
234 
235         return displayInfos;
236     }
237 
getDisplayHotplugHelperAppOutput()238     private String getDisplayHotplugHelperAppOutput() throws Exception {
239         final String uuid = UUID.randomUUID().toString();
240 
241         final Pattern guestDisplayInfoPattern =
242             Pattern.compile(
243                 String.format("^.*DisplayHotplugHelper.*%s.* displays: (\\{.*\\})", uuid));
244 
245         getDevice().executeShellCommand(
246             String.format("am start -n %s --es %s %s", HELPER_APP_ACTIVITY, HELPER_APP_UUID_FLAG, uuid));
247 
248         for (int attempt = 0; attempt < HELPER_APP_LOG_CHECK_ATTEMPTS; attempt++) {
249             String logcat = getDevice().executeAdbCommand("logcat", "-d", "DisplayHotplugHelper:E", "*:S");
250 
251             List<String> logcatLines = Lists.newArrayList(LOGCAT_NEWLINE_SPLITTER.split(logcat));
252 
253             // Inspect latest first:
254             Collections.reverse(logcatLines);
255 
256             for (String logcatLine : logcatLines) {
257                 Matcher matcher = guestDisplayInfoPattern.matcher(logcatLine);
258                 if (matcher.find()) {
259                     return matcher.group(1);
260                 }
261             }
262 
263             Thread.sleep(HELPER_APP_LOG_CHECK_TIMEOUT_MILLISECONDS);
264         }
265 
266         throw new IllegalStateException("Failed to find display info from helper app using uuid:" + uuid);
267     }
268 
getGuestDisplays()269     private Map<Integer, GuestDisplayInfo> getGuestDisplays() throws Exception {
270         return parseGuestDisplayInfos(getDisplayHotplugHelperAppOutput());
271     }
272 
getHostDisplays()273     public Map<Integer, HostDisplayInfo> getHostDisplays() throws FileNotFoundException {
274         CommandResult listDisplaysResult = runCvdCommand(Lists.newArrayList("display", "list"));
275         if (!CommandStatus.SUCCESS.equals(listDisplaysResult.getStatus())) {
276             throw new IllegalStateException(
277                     String.format("Failed to run list displays command:%s\n%s",
278                                   listDisplaysResult.getStdout(),
279                                   listDisplaysResult.getStderr()));
280         }
281         return parseHostDisplayInfos(listDisplaysResult.getStdout());
282     }
283 
284     @AutoValue
285     public static abstract class AddDisplayParams {
create(int width, int height)286         static AddDisplayParams create(int width, int height) {
287             return new AutoValue_CuttlefishDisplayHotplugTest_AddDisplayParams(width, height);
288         }
289 
width()290         abstract int width();
height()291         abstract int height();
292     }
293 
294     /* As supported by `cvd display add` */
295     private static final int MAX_ADD_DISPLAYS = 4;
296 
addDisplays(List<AddDisplayParams> params)297     public void addDisplays(List<AddDisplayParams> params) throws FileNotFoundException {
298         if (params.size() > MAX_ADD_DISPLAYS) {
299             throw new IllegalArgumentException(
300                 "`cvd display add` only supports adding up to " + MAX_ADD_DISPLAYS +
301                 " at once but was requested to add " + params.size() + " displays.");
302         }
303 
304         List<String> addDisplaysCommand = Lists.newArrayList("display", "add");
305         for (int i = 0; i < params.size(); i++) {
306             AddDisplayParams display = params.get(i);
307 
308             addDisplaysCommand.add(String.format(
309                 "--display%d=width=%d,height=%d", i, display.width(), display.height()));
310         }
311 
312         CommandResult addDisplayResult = runCvdCommand(addDisplaysCommand);
313         if (!CommandStatus.SUCCESS.equals(addDisplayResult.getStatus())) {
314             throw new IllegalStateException(
315                     String.format("Failed to run add display command:%s\n%s",
316                                   addDisplayResult.getStdout(),
317                                   addDisplayResult.getStderr()));
318         }
319     }
320 
addDisplay(int width, int height)321     public void addDisplay(int width, int height) throws FileNotFoundException {
322         addDisplays(List.of(AddDisplayParams.create(width, height)));
323     }
324 
removeDisplays(List<Integer> displayIds)325     public void removeDisplays(List<Integer> displayIds) throws FileNotFoundException {
326         List<String> removeDisplaysCommand = Lists.newArrayList("display", "remove");
327         for (Integer displayId : displayIds) {
328             removeDisplaysCommand.add("--display=" + displayId.toString());
329         }
330 
331         CommandResult removeDisplayResult = runCvdCommand(removeDisplaysCommand);
332         if (!CommandStatus.SUCCESS.equals(removeDisplayResult.getStatus())) {
333             throw new IllegalStateException(
334                     String.format("Failed to run remove display command:%s\n%s",
335                                   removeDisplayResult.getStdout(),
336                                   removeDisplayResult.getStderr()));
337         }
338     }
339 
removeDisplay(int displayId)340     public void removeDisplay(int displayId) throws FileNotFoundException {
341         removeDisplays(List.of(displayId));
342     }
343 
344     Correspondence<GuestDisplayInfo, AddDisplayParams> GUEST_DISPLAY_MATCHES =
345         Correspondence.from((GuestDisplayInfo lhs, AddDisplayParams rhs) -> {
346             return lhs.width() == rhs.width() &&
347                    lhs.height() == rhs.height();
348         }, "matches the display info of");
349 
350     Correspondence<HostDisplayInfo, AddDisplayParams> HOST_DISPLAY_MATCHES =
351         Correspondence.from((HostDisplayInfo lhs, AddDisplayParams rhs) -> {
352             return lhs.width() == rhs.width() &&
353                    lhs.height() == rhs.height();
354         }, "matches the display info of");
355 
doOneConnectAndDisconnectCycle(List<AddDisplayParams> params)356     private void doOneConnectAndDisconnectCycle(List<AddDisplayParams> params) throws Exception {
357         // Check which displays Crosvm is aware of originally.
358         Map<Integer, HostDisplayInfo> originalHostDisplays = getHostDisplays();
359         assertThat(originalHostDisplays).isNotNull();
360         assertThat(originalHostDisplays).isNotEmpty();
361 
362         // Check which displays SurfaceFlinger and DisplayManager are aware of originally.
363         Map<Integer, GuestDisplayInfo> originalGuestDisplays = getGuestDisplays();
364         assertThat(originalGuestDisplays).isNotNull();
365         assertThat(originalGuestDisplays).isNotEmpty();
366 
367         // Perform the hotplug connect.
368         addDisplays(params);
369 
370         // Check that Crosvm is aware of the new display (the added displays should
371         // be visible immediately after the host command completes and this should
372         // not need retries).
373         Map<Integer, HostDisplayInfo> afterAddHostDisplays = getHostDisplays();
374         assertThat(afterAddHostDisplays).isNotNull();
375 
376         MapDifference<Integer, HostDisplayInfo> addedHostDisplaysDiff =
377             Maps.difference(afterAddHostDisplays, originalHostDisplays);
378         assertThat(addedHostDisplaysDiff.entriesOnlyOnLeft()).hasSize(params.size());
379         assertThat(addedHostDisplaysDiff.entriesOnlyOnRight()).isEmpty();
380 
381         Map<Integer, HostDisplayInfo> addedHostDisplays =
382             addedHostDisplaysDiff.entriesOnlyOnLeft();
383         assertThat(addedHostDisplays.values())
384             .comparingElementsUsing(HOST_DISPLAY_MATCHES)
385             .containsExactlyElementsIn(params);
386 
387         // Check that SurfaceFlinger and DisplayManager are aware of the new display.
388         Map<Integer, GuestDisplayInfo> afterAddGuestDisplays = null;
389         for (int attempt = 0; attempt < CHECK_FOR_UPDATED_GUEST_DISPLAYS_ATTEMPTS; attempt++) {
390             // Guest components (HWComposer/SurfaceFlinger/etc) may take some time to process.
391             Thread.sleep(CHECK_FOR_UPDATED_GUEST_DISPLAYS_SLEEP_MILLISECONDS);
392 
393             afterAddGuestDisplays = getGuestDisplays();
394             assertThat(afterAddGuestDisplays).isNotNull();
395 
396             int expectedNumberOfGuestDisplaysAfterAdd =
397                 originalGuestDisplays.size() + params.size();
398 
399             int numberOfGuestDisplaysAfterAdd = afterAddGuestDisplays.size();
400             if (numberOfGuestDisplaysAfterAdd == expectedNumberOfGuestDisplaysAfterAdd) {
401                 break;
402             }
403 
404             CLog.i("Number of guest displays after add command did not yet match expected on " +
405                     "attempt %d (actual:%d vs expected:%d)",
406                     attempt, numberOfGuestDisplaysAfterAdd, expectedNumberOfGuestDisplaysAfterAdd);
407         }
408         MapDifference<Integer, GuestDisplayInfo> addedGuestDisplaysDiff =
409             Maps.difference(afterAddGuestDisplays, originalGuestDisplays);;
410         assertThat(addedGuestDisplaysDiff.entriesOnlyOnLeft()).hasSize(params.size());
411         assertThat(addedGuestDisplaysDiff.entriesOnlyOnRight()).isEmpty();
412 
413         Map<Integer, GuestDisplayInfo> addedGuestDisplays =
414             addedGuestDisplaysDiff.entriesOnlyOnLeft();
415         assertThat(addedGuestDisplays.values())
416             .comparingElementsUsing(GUEST_DISPLAY_MATCHES)
417             .containsExactlyElementsIn(params);
418 
419         // Perform the hotplug disconnect.
420         List<Integer> addedHostDisplayIds = new ArrayList<Integer>();
421         for (HostDisplayInfo addedHostDisplay : addedHostDisplays.values()) {
422             addedHostDisplayIds.add(addedHostDisplay.id());
423         }
424         removeDisplays(addedHostDisplayIds);
425 
426         // Check that Crosvm does not show the removed display (the removed displays
427         // should be visible immediately after the host command completes and this
428         // should not need retries).
429         Map<Integer, HostDisplayInfo> afterRemoveHostDisplays = getHostDisplays();
430         assertThat(afterRemoveHostDisplays).isNotNull();
431 
432         MapDifference<Integer, HostDisplayInfo> removedHostDisplaysDiff =
433             Maps.difference(afterRemoveHostDisplays, originalHostDisplays);
434         assertThat(removedHostDisplaysDiff.entriesDiffering()).isEmpty();
435 
436         // Check that SurfaceFlinger and DisplayManager do not show the removed display.
437         Map<Integer, GuestDisplayInfo> afterRemoveGuestDisplays = null;
438         for (int attempt = 0; attempt < CHECK_FOR_UPDATED_GUEST_DISPLAYS_ATTEMPTS; attempt++) {
439             // Guest components (HWComposer/SurfaceFlinger/etc) may take some time to process.
440             Thread.sleep(CHECK_FOR_UPDATED_GUEST_DISPLAYS_SLEEP_MILLISECONDS);
441 
442             afterRemoveGuestDisplays = getGuestDisplays();
443             assertThat(afterRemoveGuestDisplays).isNotNull();
444 
445             int expectedNumberOfGuestDisplaysAfterRemove = originalGuestDisplays.size();
446 
447             int numberOfGuestDisplaysAfterRemove = afterRemoveGuestDisplays.size();
448             if (numberOfGuestDisplaysAfterRemove == expectedNumberOfGuestDisplaysAfterRemove) {
449                 break;
450             }
451 
452             CLog.i("Number of guest displays after remove command did not yet match expected on " +
453                    "attempt %d (actual:%d vs expected:%d)",
454                    attempt, numberOfGuestDisplaysAfterRemove,
455                    expectedNumberOfGuestDisplaysAfterRemove);
456         }
457         MapDifference<Integer, GuestDisplayInfo> removedGuestDisplaysDiff
458             = Maps.difference(afterRemoveGuestDisplays, originalGuestDisplays);
459         assertThat(removedGuestDisplaysDiff.entriesDiffering()).isEmpty();
460     }
461 
462     @Test
testDisplayHotplug()463     public void testDisplayHotplug() throws Exception {
464         doOneConnectAndDisconnectCycle(
465             List.of(AddDisplayParams.create(600, 500)));
466     }
467 
468     @Test
testDisplayHotplugMultipleDisplays()469     public void testDisplayHotplugMultipleDisplays() throws Exception {
470         doOneConnectAndDisconnectCycle(
471             List.of(
472                 AddDisplayParams.create(1920, 1080),
473                 AddDisplayParams.create(1280, 720)));
474     }
475 
476     @Test
testDisplayHotplugSeries()477     public void testDisplayHotplugSeries() throws Exception {
478         doOneConnectAndDisconnectCycle(
479             List.of(AddDisplayParams.create(640, 480)));
480 
481         doOneConnectAndDisconnectCycle(
482             List.of(AddDisplayParams.create(1280, 720)));
483 
484         doOneConnectAndDisconnectCycle(
485             List.of(AddDisplayParams.create(1920, 1080)));
486 
487         doOneConnectAndDisconnectCycle(
488             List.of(AddDisplayParams.create(3840, 2160)));
489     }
490 
491     @AutoValue
492     public static abstract class MemoryInfo {
create(int usedRam)493         static MemoryInfo create(int usedRam) {
494             return new AutoValue_CuttlefishDisplayHotplugTest_MemoryInfo(usedRam);
495         }
496 
usedRamBytes()497         abstract int usedRamBytes();
498     }
499 
500     private static final String GET_USED_RAM_COMMAND = "dumpsys meminfo";
501 
502     private static final Pattern USED_RAM_PATTERN = Pattern.compile("Used RAM: (.*?)K \\(");
503 
getMemoryInfo()504     private MemoryInfo getMemoryInfo() throws Exception {
505         ITestDevice device = getDevice();
506 
507         CommandResult getUsedRamResult = device.executeShellV2Command(GET_USED_RAM_COMMAND);
508         if (!CommandStatus.SUCCESS.equals(getUsedRamResult.getStatus())) {
509             throw new IllegalStateException(
510                     String.format("Failed to run |%s|: stdout: %s\n stderr: %s",
511                                   GET_USED_RAM_COMMAND,
512                                   getUsedRamResult.getStdout(),
513                                   getUsedRamResult.getStderr()));
514         }
515         // Ex:
516         //    ...
517         //    GPU:              0K (        0K dmabuf +         0K private)
518         //    Used RAM: 1,155,524K (  870,488K used pss +   285,036K kernel)
519         //    Lost RAM:    59,469K
520         //    ...
521         String usedRamString = getUsedRamResult.getStdout();
522         Matcher m = USED_RAM_PATTERN.matcher(usedRamString);
523         if (!m.find()) {
524             throw new IllegalStateException(
525                      String.format("Failed to parse 'Used RAM' from stdout:\n%s",
526                                    getUsedRamResult.getStdout()));
527         }
528         // Ex: "1,228,768"
529         usedRamString = m.group(1);
530         usedRamString = usedRamString.replaceAll(",", "");
531         int usedRam = Integer.parseInt(usedRamString) * 1000;
532 
533         return MemoryInfo.create(usedRam);
534     }
535 
536     private static final int MAX_ALLOWED_RAM_BYTES_DIFF = 32 * 1024 * 1024;
537 
doCheckForLeaks(MemoryInfo base)538     private void doCheckForLeaks(MemoryInfo base) throws Exception {
539         MemoryInfo current = getMemoryInfo();
540 
541         assertThat(current.usedRamBytes()).isIn(
542                 Range.closed(base.usedRamBytes() - MAX_ALLOWED_RAM_BYTES_DIFF,
543                              base.usedRamBytes() + MAX_ALLOWED_RAM_BYTES_DIFF));
544     }
545 
546     @Test
547     @LargeTest
testDisplayHotplugDoesNotLeakMemory()548     public void testDisplayHotplugDoesNotLeakMemory() throws Exception {
549         List<AddDisplayParams> toAdd = List.of(AddDisplayParams.create(600, 500));
550 
551         // Warm up to potentially reach any steady state memory usage.
552         for (int i = 0; i < 50; i++) {
553             doOneConnectAndDisconnectCycle(toAdd);
554         }
555 
556         MemoryInfo original = getMemoryInfo();
557         for (int i = 0; i <= 500; i++) {
558             doOneConnectAndDisconnectCycle(toAdd);
559 
560             if (i % 100 == 0) {
561                 doCheckForLeaks(original);
562             }
563         }
564     }
565 }
566