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