• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * 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.tradefed.targetprep.incremental;
17 
18 import static com.google.common.collect.ImmutableList.toImmutableList;
19 
20 import com.android.annotations.VisibleForTesting;
21 import com.android.ddmlib.MultiLineReceiver;
22 import com.android.tradefed.device.DeviceNotAvailableException;
23 import com.android.tradefed.device.ITestDevice;
24 import com.android.tradefed.log.LogUtil.CLog;
25 import com.google.common.base.Splitter;
26 import com.google.common.collect.Sets;
27 import com.google.common.hash.Hashing;
28 import java.io.File;
29 import java.io.FileInputStream;
30 import java.io.IOException;
31 import java.nio.file.Paths;
32 import java.util.HashSet;
33 import java.util.List;
34 import java.util.Set;
35 import java.util.StringTokenizer;
36 import javax.annotation.Nullable;
37 
38 /**
39  * This class detects whether the APKs to be installed are different from those on the device, in
40  * order to decide whether to skip app installation and uninstallation during {@link
41  * TestAppInstallSetup}'s setUp and tearDown.
42  */
43 public class ApkChangeDetector {
44 
45     private static final long MIN_FREE_DISK_SPACE_THRESHOLD_IN_BYTES = 10000000L;
46     private static final double DISK_SPACE_TO_USE_ESTIMATE_FACTOR = 1.5;
47     @VisibleForTesting
48     static final String PACKAGE_INSTALLED_FILE_PATH =
49         "/sdcard/.tradefed_package_installation_cache";
50 
51     @VisibleForTesting
52     final Set<String> mPackagesHandledInCurrentTestRun = new HashSet<>();
53 
54     private Set<String> mPackagesHandledInPreviousTestRuns;
55     private Boolean incrementalSetupSupportEnsureResult;
56 
ApkChangeDetector()57     public ApkChangeDetector() {
58         this(null);
59     }
60 
61     @VisibleForTesting
ApkChangeDetector(Set<String> packagesHandledInPreviousTestRuns)62     ApkChangeDetector(Set<String> packagesHandledInPreviousTestRuns) {
63         mPackagesHandledInPreviousTestRuns = packagesHandledInPreviousTestRuns;
64     }
65 
66     /**
67      * Handle app pre-install process.
68      *
69      * @param packageName The name of the package.
70      * @param testApps Indicate all APK files in the package with the name {@link packageName}.
71      * @param device Indicates the device on which the test is running.
72      * @param userId The current user ID.
73      * @param forAllUsers Indicates whether the cleanup should be done for all users.
74      * @return Whether the APKs in {@link packageName} are fully handled under local incremental
75      *     setup. Default to false, which does not oblige to re-install the package APKs.
76      */
handleTestAppsPreinstall( String packageName, List<File> testApps, ITestDevice device, Integer userId, boolean forAllUsers)77     public boolean handleTestAppsPreinstall(
78         String packageName, List<File> testApps, ITestDevice device, Integer userId,
79         boolean forAllUsers)
80         throws DeviceNotAvailableException {
81         if (!forAllUsers && userId != null && userId != 0) {
82             CLog.d(
83                 "Not skipping the installation of %s because user %s is not the owner.",
84                 packageName, userId);
85             return false;
86         }
87         if (!ensureIncrementalSetupSupported(device)) {
88             CLog.d(
89                 "Not skipping the installation of %s because incremental setup is not supported",
90                 packageName);
91             return false;
92         }
93         loadPackagesHandledInPreviousTestRuns(device);
94         if (!cleanupAppsIfNecessary(device, testApps)) {
95             CLog.d(
96                 "Not skipping the installation of %s because app cleanup is not successful",
97                 packageName);
98             return false;
99         }
100         updateInstalledPackageCache(device, packageName);
101 
102         boolean couldSkipAppInstallation = true;
103         List<String> apkInstallPaths = getApkInstallPaths(packageName, device);
104         if (apkInstallPaths.size() != testApps.size()) {
105             CLog.d(
106                 "The file count of APKs to be installed is not equal to the number of APKs on "
107                     + "the device for the package '%s'. Install the APKs.", packageName);
108             couldSkipAppInstallation = false;
109         } else {
110             Set<String> sha256SetOnDevice = getSha256SumsOnDevice(apkInstallPaths, device);
111             CLog.d("The SHA256Sums on device contains: ");
112             sha256SetOnDevice.forEach(sha256 -> {
113                 CLog.d("%s", sha256);
114             });
115 
116             try {
117                 Set<String> sha256SumsOnHost = new HashSet<>();
118                 for (File testApp : testApps) {
119                     sha256SumsOnHost.add(calculateSHA256OnHost(testApp));
120                 }
121                 couldSkipAppInstallation = sha256SetOnDevice.equals(sha256SumsOnHost);
122             } catch (IOException ex) {
123                 CLog.d(
124                     "Exception occurred when calculating the SHA256Sums of APKs to be installed. "
125                         + "Install the APKs. Error message: %s", ex);
126                 couldSkipAppInstallation = false;
127             }
128         }
129 
130         if (couldSkipAppInstallation) {
131             CLog.d(
132                 "Skipping the installation of %s because incremental setup is turned on.",
133                 packageName);
134         } else if (getPackagesHandledInPreviousTestRuns(device).contains(packageName)) {
135             // If the package needs installation and it is previously handled by this detector,
136             // uninstall the obsolete package.
137             // TODO(ihcinihsdk): Ideally, only uninstall the package if the user specifies APKs
138             // need cleanup.
139             CLog.d(
140                 "Not skipping the installation of %s because the APKs are likely to have changed.",
141                 packageName);
142             device.uninstallPackage(packageName);
143         }
144         return couldSkipAppInstallation;
145     }
146 
147     /**
148      * Handle package cleanup process.
149      *
150      * @param packageName the name of package to be cleaned up.
151      * @param device Indicates the device on which the test is running.
152      * @param userId The current user ID.
153      * @param forAllUsers Indicates whether the cleanup should be done for all users.
154      * @return Whether the cleanup of an indicated package is done. Default to false, which
155      *     indicates that the cleanup is not done.
156      */
handlePackageCleanup( String packageName, ITestDevice device, Integer userId, boolean forAllUsers)157     public boolean handlePackageCleanup(
158         String packageName, ITestDevice device, Integer userId, boolean forAllUsers)
159         throws DeviceNotAvailableException {
160         if (!mPackagesHandledInCurrentTestRun.contains(packageName)) {
161             // In case incremental setup is not supported for the package, skip package cleanup of
162             // this detector.
163             return false;
164         }
165         // For the current implementation, we stop the app process. If successful, skip the app
166         // uninstallation.
167         String commandToRun = String.format("am force-stop %s", packageName);
168         device.executeShellCommand(commandToRun);
169         CLog.d(
170             "Skipping the uninstallation of %s because incremental setup is turned on.",
171             packageName);
172         return true;
173     }
174 
175     /** The receiver class for SHA256Sum outputs. */
176     private static class Sha256SumCommandLineReceiver extends MultiLineReceiver {
177 
178         private Set<String> mSha256Sums = new HashSet<>();
179 
180         /** Return the calculated SHA256Sums of parsed APK files.*/
getSha256Sums()181         Set<String> getSha256Sums() {
182             return mSha256Sums;
183         }
184 
185         /** {@inheritDoc} */
186         @Override
isCancelled()187         public boolean isCancelled() {
188             return false;
189         }
190 
191         /** {@inheritDoc} */
192         @Override
processNewLines(String[] lines)193         public void processNewLines(String[] lines) {
194             for (String line : lines) {
195                 StringTokenizer tokenizer = new StringTokenizer(line);
196                 if (tokenizer.hasMoreTokens()) {
197                     mSha256Sums.add(tokenizer.nextToken());
198                 }
199             }
200         }
201     }
202 
203     /** Obtain the APK install paths of the package with {@code packageName}. */
204     @VisibleForTesting
205     @Nullable
getApkInstallPaths(String packageName, ITestDevice device)206     List<String> getApkInstallPaths(String packageName, ITestDevice device)
207         throws DeviceNotAvailableException {
208         String commandToRun = String.format("pm path %s", packageName);
209         Splitter splitter = Splitter.on('\n').trimResults().omitEmptyStrings();
210         return splitter.splitToList(device.executeShellCommand(commandToRun))
211                 .stream()
212                 .filter(line -> line.startsWith("package:"))
213                 .map(line -> line.substring("package:".length()))
214                 .collect(toImmutableList());
215     }
216 
217     /** Collect the SHA256Sums of all APK files under {@code apkInstallPaths}. */
218     @VisibleForTesting
getSha256SumsOnDevice(List<String> apkInstallPaths, ITestDevice device)219     Set<String> getSha256SumsOnDevice(List<String> apkInstallPaths, ITestDevice device)
220         throws DeviceNotAvailableException {
221         Set<String> packageInstallPaths = new HashSet<>();
222         apkInstallPaths.forEach(apkInstallPath -> {
223             packageInstallPaths.add(Paths.get(apkInstallPath).getParent().toString());
224         });
225 
226         Set<String> sha256Sums = new HashSet<>();
227         for (String packageInstallPath : packageInstallPaths) {
228             Sha256SumCommandLineReceiver receiver = new Sha256SumCommandLineReceiver();
229             String commandToRun =
230                 String.format("find %s -name \"*.apk\" -exec sha256sum {} \\;", packageInstallPath);
231             device.executeShellCommand(commandToRun, receiver);
232             sha256Sums.addAll(receiver.getSha256Sums());
233         }
234         return sha256Sums;
235     }
236 
237     @VisibleForTesting
calculateSHA256OnHost(File file)238     String calculateSHA256OnHost(File file) throws IOException {
239         byte[] byteArray = new byte[(int) file.length()];
240         try (FileInputStream inputStream = new FileInputStream(file)) {
241             inputStream.read(byteArray);
242         }
243         return Hashing.sha256().hashBytes(byteArray).toString();
244     }
245 
246     /**
247      * Returns if the processes of checking free disk space and app cleanup are successful.
248      *
249      * Note that this method only returns {@code false} if any issue happens. Upon no needing to
250      * clean up, this method returns {@code true}.
251      */
cleanupAppsIfNecessary(ITestDevice device, List<File> testApps)252     private boolean cleanupAppsIfNecessary(ITestDevice device, List<File> testApps)
253         throws DeviceNotAvailableException {
254         long freeDiskSpace;
255         try {
256             freeDiskSpace = getFreeDiskSpaceForAppInstallation(device);
257         } catch (IllegalArgumentException illegalArgumentEx) {
258             CLog.d(
259                 "Not able to obtain free disk space: %s. App cleanup not successful.",
260                 illegalArgumentEx);
261             return false;
262         }
263         long totalAppSize = testApps.stream().mapToLong(File::length).sum();
264         if (freeDiskSpace - totalAppSize * DISK_SPACE_TO_USE_ESTIMATE_FACTOR
265                 < MIN_FREE_DISK_SPACE_THRESHOLD_IN_BYTES) {
266             // First, get the list of packages to be uninstalled.
267             Set<String> packagesToBeUninstalled =
268                 Sets.difference(
269                     getPackagesHandledInPreviousTestRuns(device),
270                     mPackagesHandledInCurrentTestRun);
271 
272             // Then, uninstall the packages.
273             boolean anyUninstallationFailed = false;
274             for (String packageName : packagesToBeUninstalled) {
275                 if (device.uninstallPackage(packageName) != null) {
276                     anyUninstallationFailed = true;
277                 }
278             }
279 
280             // Finally, remove the file indicating the packages to be uninstalled if there is no
281             // uninstallation failure; otherwise, return false to indicate the cleanup is not
282             // successful.
283             if (anyUninstallationFailed) {
284                 return false;
285             }
286             device.deleteFile(PACKAGE_INSTALLED_FILE_PATH);
287             mPackagesHandledInPreviousTestRuns = new HashSet<>();
288         }
289         return true;
290     }
291 
292     /** Get the free disk space in bytes of the folder "/data" of {@code device}. */
293     @VisibleForTesting
getFreeDiskSpaceForAppInstallation(ITestDevice device)294     long getFreeDiskSpaceForAppInstallation(ITestDevice device)
295         throws DeviceNotAvailableException {
296         String commandToRun = "df /data";
297         return getFreeDiskSpaceFromDfCommandLine(device.executeShellCommand(commandToRun));
298     }
299 
getFreeDiskSpaceFromDfCommandLine(String output)300     private long getFreeDiskSpaceFromDfCommandLine(String output) {
301         if (output == null) {
302             throw new IllegalArgumentException(
303                 "No output available for obtaining the device's free disk space.");
304         }
305         // The format of the output of `df /data` is as follows:
306         // Filesystem        1K-blocks    Used Available Use% Mounted on
307         // [PATH_FS]         [TOTAL]    [USED] [FREE]    [FREE_PCT] [PATH_MOUNTED_ON]
308         // Thus we need to skip the first line and take token 3 of the second line.
309         final long bytesInKiloBytes = 1024L;
310         Splitter splitter = Splitter.on('\n').trimResults().omitEmptyStrings();
311         List<String> outputLines = splitter.splitToList(output);
312         if (outputLines.size() < 2) {
313             throw new IllegalArgumentException("No free disk space info was emitted.");
314         }
315         String[] tokens = outputLines.get(1).split("\\s+");
316         if (tokens.length < 4) {
317             throw new IllegalArgumentException(
318                 "Free disk space info under /data was malformatted.");
319         }
320         return Long.parseLong(tokens[3]) * bytesInKiloBytes;
321     }
322 
323     /**
324      * Load the packages installed on the device and handled by the APK change detector in previous
325      * test runs.
326      */
327     @VisibleForTesting
loadPackagesHandledInPreviousTestRuns(ITestDevice device)328     void loadPackagesHandledInPreviousTestRuns(ITestDevice device)
329         throws DeviceNotAvailableException {
330         if (mPackagesHandledInPreviousTestRuns != null) {
331             return;
332         }
333 
334         String fileContents = device.pullFileContents(PACKAGE_INSTALLED_FILE_PATH);
335         if (fileContents != null) {
336             Splitter splitter = Splitter.on('\n').trimResults().omitEmptyStrings();
337             mPackagesHandledInPreviousTestRuns =
338                 Sets.newHashSet(splitter.split(fileContents));
339         } else {
340             mPackagesHandledInPreviousTestRuns = new HashSet<>();
341         }
342     }
343 
344     /**
345      * Get the set of packages installed on the device and handled by the APK change detector in
346      * previous test runs.
347      */
348     @VisibleForTesting
getPackagesHandledInPreviousTestRuns(ITestDevice device)349     Set<String> getPackagesHandledInPreviousTestRuns(ITestDevice device) {
350         return mPackagesHandledInPreviousTestRuns;
351     }
352 
353     /**
354      * Return the incremental setup is supported on {@code device}.
355      *
356      * Note that this method has the side effect of creating a cache file under "/sdcard/." if it
357      * does not exist.
358      */
359     @VisibleForTesting
ensureIncrementalSetupSupported(ITestDevice device)360     boolean ensureIncrementalSetupSupported(ITestDevice device)
361         throws DeviceNotAvailableException {
362         if (incrementalSetupSupportEnsureResult != null) {
363             return incrementalSetupSupportEnsureResult;
364         }
365 
366         // Check if the device has sha256sum command installed.
367         String sha256SumDryRunOutput = device.executeShellCommand("sha256sum --help");
368         if (sha256SumDryRunOutput.contains("sha256sum: inaccessible or not found")) {
369             incrementalSetupSupportEnsureResult = false;
370             return false;
371         }
372 
373         // Check if we have access to "/sdcard/.".
374         if (device.doesFileExist(PACKAGE_INSTALLED_FILE_PATH)) {
375             incrementalSetupSupportEnsureResult = true;
376         } else {
377             incrementalSetupSupportEnsureResult =
378                 device.pushString("", PACKAGE_INSTALLED_FILE_PATH);
379         }
380         return incrementalSetupSupportEnsureResult;
381     }
382 
updateInstalledPackageCache(ITestDevice device, String packageName)383     private void updateInstalledPackageCache(ITestDevice device, String packageName)
384         throws DeviceNotAvailableException {
385         mPackagesHandledInCurrentTestRun.add(packageName);
386         Set<String> packagesHandledByIncrementalSetup =
387             Sets.union(
388                 getPackagesHandledInPreviousTestRuns(device),
389                 mPackagesHandledInCurrentTestRun);
390         device.pushString(
391             String.join("\n", packagesHandledByIncrementalSetup),
392             PACKAGE_INSTALLED_FILE_PATH);
393     }
394 }
395