1 /*
2  * Copyright (C) 2020 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 
17 package com.android.internal.util.test;
18 
19 import static org.junit.Assert.assertEquals;
20 import static org.junit.Assert.assertTrue;
21 
22 import com.android.tradefed.device.DeviceNotAvailableException;
23 import com.android.tradefed.device.ITestDevice;
24 
25 import org.junit.Assert;
26 import org.junit.ClassRule;
27 import org.junit.rules.ExternalResource;
28 import org.junit.rules.TemporaryFolder;
29 import org.junit.rules.TestRule;
30 import org.junit.runner.Description;
31 import org.junit.runners.model.Statement;
32 
33 import java.io.File;
34 import java.io.FileOutputStream;
35 import java.io.IOException;
36 import java.io.InputStream;
37 import java.nio.file.Path;
38 import java.nio.file.Paths;
39 import java.util.ArrayList;
40 
41 import javax.annotation.Nullable;
42 
43 /**
44  * Allows pushing files onto the device and various options for rebooting. Useful for installing
45  * APKs/files to system partitions which otherwise wouldn't be easily changed.
46  *
47  * It's strongly recommended to pass in a {@link ClassRule} annotated {@link TestRuleDelegate} to
48  * do a full reboot at the end of a test to ensure the device is in a valid state, assuming the
49  * default {@link RebootStrategy#FULL} isn't used.
50  */
51 public class SystemPreparer extends ExternalResource {
52     private static final long OVERLAY_ENABLE_TIMEOUT_MS = 30000;
53 
54     // The paths of the files pushed onto the device through this rule to be removed after.
55     private ArrayList<String> mPushedFiles = new ArrayList<>();
56 
57     // The package names of packages installed through this rule.
58     private ArrayList<String> mInstalledPackages = new ArrayList<>();
59 
60     private final TemporaryFolder mHostTempFolder;
61     private final DeviceProvider mDeviceProvider;
62     private final RebootStrategy mRebootStrategy;
63     private final TearDownRule mTearDownRule;
64 
65     // When debugging, it may be useful to run a test case without rebooting the device afterwards,
66     // to manually verify the device state.
67     private boolean mDebugSkipAfterReboot;
68 
SystemPreparer(TemporaryFolder hostTempFolder, DeviceProvider deviceProvider)69     public SystemPreparer(TemporaryFolder hostTempFolder, DeviceProvider deviceProvider) {
70         this(hostTempFolder, RebootStrategy.FULL, null, deviceProvider);
71     }
72 
SystemPreparer(TemporaryFolder hostTempFolder, RebootStrategy rebootStrategy, @Nullable TestRuleDelegate testRuleDelegate, DeviceProvider deviceProvider)73     public SystemPreparer(TemporaryFolder hostTempFolder, RebootStrategy rebootStrategy,
74             @Nullable TestRuleDelegate testRuleDelegate, DeviceProvider deviceProvider) {
75         this(hostTempFolder, rebootStrategy, testRuleDelegate, false, deviceProvider);
76     }
77 
SystemPreparer(TemporaryFolder hostTempFolder, RebootStrategy rebootStrategy, @Nullable TestRuleDelegate testRuleDelegate, boolean debugSkipAfterReboot, DeviceProvider deviceProvider)78     public SystemPreparer(TemporaryFolder hostTempFolder, RebootStrategy rebootStrategy,
79             @Nullable TestRuleDelegate testRuleDelegate, boolean debugSkipAfterReboot,
80             DeviceProvider deviceProvider) {
81         mHostTempFolder = hostTempFolder;
82         mDeviceProvider = deviceProvider;
83         mRebootStrategy = rebootStrategy;
84         mTearDownRule = new TearDownRule(mDeviceProvider);
85         if (testRuleDelegate != null) {
86             testRuleDelegate.setDelegate(mTearDownRule);
87         }
88         mDebugSkipAfterReboot = debugSkipAfterReboot;
89     }
90 
91     /** Copies a file within the host test jar to a path on device. */
pushResourceFile(String filePath, String outputPath)92     public SystemPreparer pushResourceFile(String filePath, String outputPath)
93             throws DeviceNotAvailableException, IOException {
94         final ITestDevice device = mDeviceProvider.getDevice();
95         remount();
96         assertTrue(device.pushFile(copyResourceToTemp(filePath), outputPath));
97         addPushedFile(device, outputPath);
98         return this;
99     }
100 
101     /** Copies a file directly from the host file system to a path on device. */
pushFile(File file, String outputPath)102     public SystemPreparer pushFile(File file, String outputPath)
103             throws DeviceNotAvailableException {
104         final ITestDevice device = mDeviceProvider.getDevice();
105         remount();
106         assertTrue(device.pushFile(file, outputPath));
107         addPushedFile(device, outputPath);
108         return this;
109     }
110 
addPushedFile(ITestDevice device, String outputPath)111     private void addPushedFile(ITestDevice device, String outputPath)
112             throws DeviceNotAvailableException {
113         Path pathCreated = Paths.get(outputPath);
114 
115         // Find the top most parent that is new to the device
116         while (pathCreated.getParent() != null
117                 && !device.doesFileExist(pathCreated.getParent().toString())) {
118             pathCreated = pathCreated.getParent();
119         }
120 
121         mPushedFiles.add(pathCreated.toString());
122     }
123 
124     /** Deletes the given path from the device */
deleteFile(String file)125     public SystemPreparer deleteFile(String file) throws DeviceNotAvailableException {
126         final ITestDevice device = mDeviceProvider.getDevice();
127         remount();
128         device.deleteFile(file);
129         return this;
130     }
131 
132     /** Installs an APK within the host test jar onto the device. */
installResourceApk(String resourcePath, String packageName)133     public SystemPreparer installResourceApk(String resourcePath, String packageName)
134             throws DeviceNotAvailableException, IOException {
135         final ITestDevice device = mDeviceProvider.getDevice();
136         final File tmpFile = copyResourceToTemp(resourcePath);
137         final String result = device.installPackage(tmpFile, true /* reinstall */);
138         Assert.assertNull(result);
139         mInstalledPackages.add(packageName);
140         return this;
141     }
142 
143     /** Stages multiple APEXs within the host test jar onto the device. */
stageMultiplePackages(String[] resourcePaths, String[] packageNames)144     public SystemPreparer stageMultiplePackages(String[] resourcePaths, String[] packageNames)
145             throws DeviceNotAvailableException, IOException {
146         assertEquals(resourcePaths.length, packageNames.length);
147         final ITestDevice device = mDeviceProvider.getDevice();
148         final String[] adbCommandLine = new String[resourcePaths.length + 2];
149         adbCommandLine[0] = "install-multi-package";
150         adbCommandLine[1] = "--staged";
151         for (int i = 0; i < resourcePaths.length; i++) {
152             final File tmpFile = copyResourceToTemp(resourcePaths[i]);
153             adbCommandLine[i + 2] = tmpFile.getAbsolutePath();
154             mInstalledPackages.add(packageNames[i]);
155         }
156         final String output = device.executeAdbCommand(adbCommandLine);
157         assertTrue(output.contains("Success. Reboot device to apply staged session"));
158         return this;
159     }
160 
161     /** Sets the enable state of an overlay package. */
setOverlayEnabled(String packageName, boolean enabled)162     public SystemPreparer setOverlayEnabled(String packageName, boolean enabled)
163             throws DeviceNotAvailableException {
164         final ITestDevice device = mDeviceProvider.getDevice();
165         final String enable = enabled ? "enable" : "disable";
166 
167         // Wait for the overlay to change its enabled state.
168         final long endMillis = System.currentTimeMillis() + OVERLAY_ENABLE_TIMEOUT_MS;
169         String result;
170         while (System.currentTimeMillis() <= endMillis) {
171             device.executeShellCommand(String.format("cmd overlay %s %s", enable, packageName));
172             result = device.executeShellCommand("cmd overlay dump isenabled "
173                     + packageName);
174             if (((enabled) ? "true\n" : "false\n").equals(result)) {
175                 return this;
176             }
177 
178             try {
179                 Thread.sleep(200);
180             } catch (InterruptedException ignore) {
181             }
182         }
183 
184         throw new IllegalStateException(String.format("Failed to %s overlay %s:\n%s", enable,
185                 packageName, device.executeShellCommand("cmd overlay list")));
186     }
187 
188     /** Restarts the device and waits until after boot is completed. */
reboot()189     public SystemPreparer reboot() throws DeviceNotAvailableException {
190         ITestDevice device = mDeviceProvider.getDevice();
191         switch (mRebootStrategy) {
192             case FULL:
193                 device.reboot();
194                 break;
195             case UNTIL_ONLINE:
196                 device.rebootUntilOnline();
197                 break;
198             case USERSPACE:
199                 device.rebootUserspace();
200                 break;
201             case USERSPACE_UNTIL_ONLINE:
202                 device.rebootUserspaceUntilOnline();
203                 break;
204             // TODO(b/159540015): Make this START_STOP instead of default once it's fixed. Can't
205             //  currently be done because START_STOP is commented out.
206             default:
207                 device.executeShellCommand("stop");
208                 device.executeShellCommand("start");
209                 device.waitForDeviceAvailable();
210                 break;
211         }
212         return this;
213     }
214 
remount()215     public SystemPreparer remount() throws DeviceNotAvailableException {
216         mTearDownRule.remount();
217         return this;
218     }
219 
getFileExtension(@ullable String path)220     private static @Nullable String getFileExtension(@Nullable String path) {
221         if (path == null) {
222             return null;
223         }
224         final int lastDot = path.lastIndexOf('.');
225         if (lastDot >= 0) {
226             return path.substring(lastDot + 1);
227         } else {
228             return null;
229         }
230     }
231 
232     /** Copies a file within the host test jar to a temporary file on the host machine. */
copyResourceToTemp(String resourcePath)233     private File copyResourceToTemp(String resourcePath) throws IOException {
234         final String ext = getFileExtension(resourcePath);
235         final File tempFile;
236         if (ext != null) {
237             tempFile = File.createTempFile("junit", "." + ext, mHostTempFolder.getRoot());
238         } else {
239             tempFile = mHostTempFolder.newFile();
240         }
241         final ClassLoader classLoader = getClass().getClassLoader();
242         try (InputStream assetIs = classLoader.getResourceAsStream(resourcePath);
243              FileOutputStream assetOs = new FileOutputStream(tempFile)) {
244             if (assetIs == null) {
245                 throw new IllegalStateException("Failed to find resource " + resourcePath);
246             }
247 
248             int b;
249             while ((b = assetIs.read()) >= 0) {
250                 assetOs.write(b);
251             }
252         }
253 
254         return tempFile;
255     }
256 
257     /** Removes installed packages and files that were pushed to the device. */
258     @Override
after()259     public void after() {
260         final ITestDevice device = mDeviceProvider.getDevice();
261         try {
262             remount();
263             for (final String file : mPushedFiles) {
264                 device.deleteFile(file);
265             }
266             for (final String packageName : mInstalledPackages) {
267                 device.uninstallPackage(packageName);
268             }
269             if (!mDebugSkipAfterReboot) {
270                 reboot();
271             }
272         } catch (DeviceNotAvailableException e) {
273             Assert.fail(e.toString());
274         }
275     }
276 
277     /**
278      * A hacky workaround since {@link org.junit.AfterClass} and {@link ClassRule} require static
279      * members. Will defer assignment of the actual {@link TestRule} to execute until after any
280      * test case has been run.
281      *
282      * In effect, this makes the {@link ITestDevice} to be accessible after all test cases have
283      * been executed, allowing {@link ITestDevice#reboot()} to be used to fully restore the device.
284      */
285     public static class TestRuleDelegate implements TestRule {
286 
287         private boolean mThrowOnNull;
288 
289         @Nullable
290         private TestRule mTestRule;
291 
TestRuleDelegate(boolean throwOnNull)292         public TestRuleDelegate(boolean throwOnNull) {
293             mThrowOnNull = throwOnNull;
294         }
295 
setDelegate(TestRule testRule)296         public void setDelegate(TestRule testRule) {
297             mTestRule = testRule;
298         }
299 
300         @Override
apply(Statement base, Description description)301         public Statement apply(Statement base, Description description) {
302             if (mTestRule == null) {
303                 if (mThrowOnNull) {
304                     throw new IllegalStateException("TestRule delegate was not set");
305                 } else {
306                     return new Statement() {
307                         @Override
308                         public void evaluate() throws Throwable {
309                             base.evaluate();
310                         }
311                     };
312                 }
313             }
314 
315             Statement statement = mTestRule.apply(base, description);
316             mTestRule = null;
317             return statement;
318         }
319     }
320 
321     /**
322      * Forces a full reboot at the end of the test class to restore any device state.
323      */
324     private static class TearDownRule extends ExternalResource {
325 
326         private DeviceProvider mDeviceProvider;
327         private boolean mInitialized;
328         private boolean mWasVerityEnabled;
329         private boolean mWasAdbRoot;
330         private boolean mIsVerityEnabled;
331 
332         TearDownRule(DeviceProvider deviceProvider) {
333             mDeviceProvider = deviceProvider;
334         }
335 
336         @Override
337         protected void before() {
338             // This method will never be run
339         }
340 
341         @Override
342         protected void after() {
343             try {
344                 initialize();
345                 ITestDevice device = mDeviceProvider.getDevice();
346                 if (mWasVerityEnabled != mIsVerityEnabled) {
347                     device.executeShellCommand(
348                             mWasVerityEnabled ? "enable-verity" : "disable-verity");
349                 }
350                 device.reboot();
351                 if (!mWasAdbRoot) {
352                     device.disableAdbRoot();
353                 }
354             } catch (DeviceNotAvailableException e) {
355                 Assert.fail(e.toString());
356             }
357         }
358 
359         /**
360          * Remount is done inside this class so that the verity state can be tracked.
361          */
362         public void remount() throws DeviceNotAvailableException {
363             initialize();
364             ITestDevice device = mDeviceProvider.getDevice();
365             device.enableAdbRoot();
366             if (mIsVerityEnabled) {
367                 mIsVerityEnabled = false;
368                 device.executeShellCommand("disable-verity");
369                 device.reboot();
370             }
371             device.enableAdbRoot();
372             device.remountSystemWritable();
373             device.remountVendorWritable();
374             device.waitForDeviceAvailable();
375         }
376 
377         private void initialize() throws DeviceNotAvailableException {
378             if (mInitialized) {
379                 return;
380             }
381             mInitialized = true;
382             ITestDevice device = mDeviceProvider.getDevice();
383             mWasAdbRoot = device.isAdbRoot();
384             device.enableAdbRoot();
385             String veritySystem = device.getProperty("partition.system.verified");
386             String verityVendor = device.getProperty("partition.vendor.verified");
387             mWasVerityEnabled = (veritySystem != null && !veritySystem.isEmpty())
388                     || (verityVendor != null && !verityVendor.isEmpty());
389             mIsVerityEnabled = mWasVerityEnabled;
390         }
391     }
392 
393     public interface DeviceProvider {
394         ITestDevice getDevice();
395     }
396 
397     /**
398      * How to reboot the device. Ordered from slowest to fastest.
399      */
400     @SuppressWarnings("DanglingJavadoc")
401     public enum RebootStrategy {
402         /** @see ITestDevice#reboot() */
403         FULL,
404 
405         /** @see ITestDevice#rebootUntilOnline() () */
406         UNTIL_ONLINE,
407 
408         /** @see ITestDevice#rebootUserspace() */
409         USERSPACE,
410 
411         /** @see ITestDevice#rebootUserspaceUntilOnline() () */
412         USERSPACE_UNTIL_ONLINE,
413 
414         /**
415          * Uses shell stop && start to "reboot" the device. May leave invalid state after each test.
416          * Whether this matters or not depends on what's being tested.
417          *
418          * TODO(b/159540015): There's a bug with this causing unnecessary disk space usage, which
419          *  can eventually lead to an insufficient storage space error.
420          *
421          * This can be uncommented for local development, but should be left out when merging.
422          * It is done this way to hopefully be caught by code review, since merging this will
423          * break all of postsubmit. But the nearly 50% reduction in test runtime is worth having
424          * this option exist.
425          *
426          * @deprecated do not use this in merged code until bug is resolved
427          */
428 //        @Deprecated
429 //        START_STOP
430     }
431 }
432