1 /* 2 * Copyright (C) 2019 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 android.compat.cts; 18 19 import static com.google.common.truth.Truth.assertThat; 20 import static com.google.common.truth.Truth.assertWithMessage; 21 22 import android.cts.statsdatom.lib.ReportUtils; 23 24 import com.android.compatibility.common.tradefed.build.CompatibilityBuildHelper; 25 import com.android.ddmlib.testrunner.RemoteAndroidTestRunner; 26 import com.android.ddmlib.testrunner.TestResult.TestStatus; 27 import com.android.internal.os.StatsdConfigProto; 28 import com.android.os.AtomsProto; 29 import com.android.os.AtomsProto.Atom; 30 import com.android.os.StatsLog; 31 import com.android.os.StatsLog.ConfigMetricsReport; 32 import com.android.os.StatsLog.ConfigMetricsReportList; 33 import com.android.tradefed.build.IBuildInfo; 34 import com.android.tradefed.device.CollectingByteOutputReceiver; 35 import com.android.tradefed.device.CollectingOutputReceiver; 36 import com.android.tradefed.device.DeviceNotAvailableException; 37 import com.android.tradefed.device.ITestDevice; 38 import com.android.tradefed.log.LogUtil.CLog; 39 import com.android.tradefed.result.CollectingTestListener; 40 import com.android.tradefed.result.TestDescription; 41 import com.android.tradefed.result.TestResult; 42 import com.android.tradefed.result.TestRunResult; 43 import com.android.tradefed.testtype.DeviceTestCase; 44 import com.android.tradefed.testtype.IBuildReceiver; 45 46 import com.google.common.io.Files; 47 import com.google.protobuf.InvalidProtocolBufferException; 48 49 import java.io.File; 50 import java.io.FileNotFoundException; 51 import java.io.IOException; 52 import java.util.ArrayList; 53 import java.util.Arrays; 54 import java.util.Collections; 55 import java.util.List; 56 import java.util.Map; 57 import java.util.Objects; 58 import java.util.Set; 59 import java.util.stream.Collectors; 60 61 import javax.annotation.Nonnull; 62 63 // Shamelessly plagiarised from incident's ProtoDumpTestCase and statsd's BaseTestCase family 64 public class CompatChangeGatingTestCase extends DeviceTestCase implements IBuildReceiver { 65 protected IBuildInfo mCtsBuild; 66 67 private static final String UPDATE_CONFIG_CMD = "cat %s | cmd stats config update %d"; 68 private static final String DUMP_REPORT_CMD = 69 "cmd stats dump-report %d --include_current_bucket --proto"; 70 private static final String REMOVE_CONFIG_CMD = "cmd stats config remove %d"; 71 72 private static final String TEST_RUNNER = "androidx.test.runner.AndroidJUnitRunner"; 73 74 @Override setUp()75 protected void setUp() throws Exception { 76 super.setUp(); 77 assertThat(mCtsBuild).isNotNull(); 78 } 79 80 @Override setBuild(IBuildInfo buildInfo)81 public void setBuild(IBuildInfo buildInfo) { 82 mCtsBuild = buildInfo; 83 } 84 85 /** 86 * Install a device side test package. 87 * 88 * @param appFileName Apk file name, such as "CtsNetStatsApp.apk". 89 * @param grantPermissions whether to give runtime permissions. 90 */ installPackage(String appFileName, boolean grantPermissions)91 protected void installPackage(String appFileName, boolean grantPermissions) 92 throws FileNotFoundException, DeviceNotAvailableException { 93 CLog.d("Installing app " + appFileName); 94 CompatibilityBuildHelper buildHelper = new CompatibilityBuildHelper(mCtsBuild); 95 final String result = getDevice().installPackage(buildHelper.getTestFile(appFileName), true, 96 grantPermissions); 97 assertWithMessage("Failed to install %s: %s", appFileName, result).that(result).isNull(); 98 } 99 100 /** 101 * Uninstall a device side test package. 102 * 103 * @param appFileName Apk file name, such as "CtsNetStatsApp.apk". 104 * @param shouldSucceed Whether to assert on failure. 105 */ uninstallPackage(String packageName, boolean shouldSucceed)106 protected void uninstallPackage(String packageName, boolean shouldSucceed) 107 throws DeviceNotAvailableException { 108 final String result = getDevice().uninstallPackage(packageName); 109 if (shouldSucceed) { 110 assertWithMessage("uninstallPackage(%s) failed: %s", packageName, result) 111 .that(result).isNull(); 112 } 113 } 114 115 /** 116 * Run a device side compat test. 117 * 118 * @param pkgName Test package name, such as 119 * "com.android.server.cts.netstats". 120 * @param testClassName Test class name; either a fully qualified name, or "." 121 * + a class name. 122 * @param testMethodName Test method name. 123 * @param enabledChanges Set of compat changes to enable. 124 * @param disabledChanges Set of compat changes to disable. 125 */ runDeviceCompatTest(@onnull String pkgName, @Nonnull String testClassName, @Nonnull String testMethodName, Set<Long> enabledChanges, Set<Long> disabledChanges)126 protected void runDeviceCompatTest(@Nonnull String pkgName, @Nonnull String testClassName, 127 @Nonnull String testMethodName, 128 Set<Long> enabledChanges, Set<Long> disabledChanges) 129 throws DeviceNotAvailableException { 130 runDeviceCompatTestReported(pkgName, testClassName, testMethodName, enabledChanges, 131 disabledChanges, enabledChanges, disabledChanges); 132 } 133 134 /** 135 * Run a device side compat test where not all changes are reported through statsd. 136 * 137 * @param pkgName Test package name, such as 138 * "com.android.server.cts.netstats". 139 * @param testClassName Test class name; either a fully qualified name, or "." 140 * + a class name. 141 * @param testMethodName Test method name. 142 * @param enabledChanges Set of compat changes to enable. 143 * @param disabledChanges Set of compat changes to disable. 144 * @param reportedEnabledChanges Expected enabled changes in statsd report. 145 * @param reportedDisabledChanges Expected disabled changes in statsd report. 146 */ runDeviceCompatTestReported(@onnull String pkgName, @Nonnull String testClassName, @Nonnull String testMethodName, Set<Long> enabledChanges, Set<Long> disabledChanges, Set<Long> reportedEnabledChanges, Set<Long> reportedDisabledChanges)147 protected void runDeviceCompatTestReported(@Nonnull String pkgName, @Nonnull String testClassName, 148 @Nonnull String testMethodName, 149 Set<Long> enabledChanges, Set<Long> disabledChanges, 150 Set<Long> reportedEnabledChanges, Set<Long> reportedDisabledChanges) 151 throws DeviceNotAvailableException { 152 153 // Set compat overrides 154 setCompatConfig(enabledChanges, disabledChanges, pkgName); 155 // Send statsd config 156 final long configId = getClass().getCanonicalName().hashCode(); 157 createAndUploadStatsdConfig(configId, pkgName); 158 159 try { 160 // Run device-side test 161 if (testClassName.startsWith(".")) { 162 testClassName = pkgName + testClassName; 163 } 164 RemoteAndroidTestRunner testRunner = new RemoteAndroidTestRunner(pkgName, TEST_RUNNER, 165 getDevice().getIDevice()); 166 testRunner.setMethodName(testClassName, testMethodName); 167 CollectingTestListener listener = new CollectingTestListener(); 168 assertThat(getDevice().runInstrumentationTests(testRunner, listener)).isTrue(); 169 170 // Check that device side test occurred as expected 171 final TestRunResult result = listener.getCurrentRunResults(); 172 assertWithMessage("Failed to successfully run device tests for %s: %s", 173 result.getName(), result.getRunFailureMessage()) 174 .that(result.isRunFailure()).isFalse(); 175 assertWithMessage("Should run only exactly one test method!") 176 .that(result.getNumTests()).isEqualTo(1); 177 if (result.hasFailedTests()) { 178 // build a meaningful error message 179 StringBuilder errorBuilder = new StringBuilder("On-device test failed:\n"); 180 for (Map.Entry<TestDescription, TestResult> resultEntry : 181 result.getTestResults().entrySet()) { 182 if (!resultEntry.getValue().getStatus().equals(TestStatus.PASSED)) { 183 errorBuilder.append(resultEntry.getKey().toString()); 184 errorBuilder.append(":\n"); 185 errorBuilder.append(resultEntry.getValue().getStackTrace()); 186 } 187 } 188 throw new AssertionError(errorBuilder.toString()); 189 } 190 191 } finally { 192 // Cleanup compat overrides 193 resetCompatConfig(pkgName, enabledChanges, disabledChanges); 194 // Validate statsd report 195 validatePostRunStatsdReport(configId, pkgName, reportedEnabledChanges, 196 reportedDisabledChanges); 197 } 198 199 } 200 201 /** 202 * Gets the statsd report. Note that this also deletes that report from statsd. 203 */ getReportList(long configId)204 private ConfigMetricsReportList getReportList(long configId) 205 throws DeviceNotAvailableException { 206 try { 207 final CollectingByteOutputReceiver receiver = new CollectingByteOutputReceiver(); 208 getDevice().executeShellCommand(String.format(DUMP_REPORT_CMD, configId), receiver); 209 return ConfigMetricsReportList.parser() 210 .parseFrom(receiver.getOutput()); 211 } catch (InvalidProtocolBufferException e) { 212 throw new IllegalStateException("Failed to fetch and parse the statsd output report.", 213 e); 214 } 215 } 216 getEventMetricDataList( ConfigMetricsReportList reportList)217 private static List<StatsLog.EventMetricData> getEventMetricDataList( 218 ConfigMetricsReportList reportList) { 219 try { 220 return ReportUtils.getEventMetricDataList(reportList); 221 } catch (Exception e) { 222 throw new IllegalStateException("Failed to parse ConfigMetrisReportList", e); 223 } 224 } 225 226 /** 227 * Creates and uploads a statsd config that matches the AppCompatibilityChangeReported atom 228 * logged by a given package name. 229 * 230 * @param configId A unique config id. 231 * @param pkgName The package name of the app that is expected to report the atom. It will be 232 * the only allowed log source. 233 */ createAndUploadStatsdConfig(long configId, String pkgName)234 protected void createAndUploadStatsdConfig(long configId, String pkgName) 235 throws DeviceNotAvailableException { 236 final String atomName = "Atom" + System.nanoTime(); 237 final String eventName = "Event" + System.nanoTime(); 238 final ITestDevice device = getDevice(); 239 240 StatsdConfigProto.StatsdConfig.Builder configBuilder = 241 StatsdConfigProto.StatsdConfig.newBuilder() 242 .setId(configId) 243 .addAllowedLogSource(pkgName) 244 .addWhitelistedAtomIds(Atom.APP_COMPATIBILITY_CHANGE_REPORTED_FIELD_NUMBER); 245 StatsdConfigProto.SimpleAtomMatcher.Builder simpleAtomMatcherBuilder = 246 StatsdConfigProto.SimpleAtomMatcher 247 .newBuilder().setAtomId( 248 Atom.APP_COMPATIBILITY_CHANGE_REPORTED_FIELD_NUMBER); 249 configBuilder.addAtomMatcher( 250 StatsdConfigProto.AtomMatcher.newBuilder() 251 .setId(atomName.hashCode()) 252 .setSimpleAtomMatcher(simpleAtomMatcherBuilder)); 253 configBuilder.addEventMetric( 254 StatsdConfigProto.EventMetric.newBuilder() 255 .setId(eventName.hashCode()) 256 .setWhat(atomName.hashCode())); 257 StatsdConfigProto.StatsdConfig config = configBuilder.build(); 258 try { 259 File configFile = File.createTempFile("statsdconfig", ".config"); 260 configFile.deleteOnExit(); 261 Files.write(config.toByteArray(), configFile); 262 String remotePath = "/data/local/tmp/" + configFile.getName(); 263 device.pushFile(configFile, remotePath); 264 device.executeShellCommand(String.format(UPDATE_CONFIG_CMD, remotePath, configId)); 265 device.executeShellCommand("rm " + remotePath); 266 } catch (IOException e) { 267 throw new RuntimeException("IO error when writing to temp file.", e); 268 } 269 // Purge data 270 getReportList(configId); 271 } 272 273 /** 274 * Gets the uid of the test app. 275 */ getUid(@onnull String packageName)276 protected int getUid(@Nonnull String packageName) throws DeviceNotAvailableException { 277 int currentUser = getDevice().getCurrentUser(); 278 String uidLines = getDevice() 279 .executeShellCommand( 280 "cmd package list packages -U --user " + currentUser + " " 281 + packageName); 282 for (String uidLine : uidLines.split("\n")) { 283 if (uidLine.startsWith("package:" + packageName + " uid:")) { 284 String[] uidLineParts = uidLine.split(":"); 285 // 3rd entry is package uid 286 assertThat(uidLineParts.length).isGreaterThan(2); 287 int uid = Integer.parseInt(uidLineParts[2].trim()); 288 assertThat(uid).isGreaterThan(10000); 289 return uid; 290 } 291 } 292 throw new IllegalStateException("Failed to find the test app on the device"); 293 } 294 295 /** 296 * Set the compat config using adb. 297 * 298 * @param enabledChanges Changes to be enabled. 299 * @param disabledChanges Changes to be disabled. 300 * @param packageName Package name for the app whose config is being changed. 301 */ setCompatConfig(Set<Long> enabledChanges, Set<Long> disabledChanges, @Nonnull String packageName)302 protected void setCompatConfig(Set<Long> enabledChanges, Set<Long> disabledChanges, 303 @Nonnull String packageName) throws DeviceNotAvailableException { 304 for (Long enabledChange : enabledChanges) { 305 runCommand("am compat enable " + enabledChange + " " + packageName); 306 } 307 for (Long disabledChange : disabledChanges) { 308 runCommand("am compat disable " + disabledChange + " " + packageName); 309 } 310 } 311 312 /** 313 * Reset changes to default for a package. 314 */ resetCompatChanges(Set<Long> changes, @Nonnull String packageName)315 protected void resetCompatChanges(Set<Long> changes, @Nonnull String packageName) 316 throws DeviceNotAvailableException { 317 for (Long change : changes) { 318 runCommand("am compat reset " + change + " " + packageName); 319 } 320 } 321 322 /** 323 * Remove statsd config for a given id. 324 */ removeStatsdConfig(long configId)325 private void removeStatsdConfig(long configId) throws DeviceNotAvailableException { 326 getDevice().executeShellCommand( 327 String.join(" ", REMOVE_CONFIG_CMD, String.valueOf(configId))); 328 } 329 330 /** 331 * Get the compat changes that were logged. 332 */ getReportedChanges(long configId, String pkgName)333 private Map<Long, Boolean> getReportedChanges(long configId, String pkgName) 334 throws DeviceNotAvailableException { 335 final int packageUid = getUid(pkgName); 336 return getEventMetricDataList(getReportList(configId)).stream() 337 .filter(eventMetricData -> eventMetricData.hasAtom()) 338 .map(eventMetricData -> eventMetricData.getAtom()) 339 .map(atom -> atom.getAppCompatibilityChangeReported()) 340 .filter(atom -> atom != null && atom.getUid() == packageUid) // Should be redundant 341 .collect(Collectors.toMap( 342 atom -> atom.getChangeId(), // Key 343 atom -> atom.getState() == // Value 344 AtomsProto.AppCompatibilityChangeReported.State.ENABLED, 345 (a, b) -> { 346 if (!Objects.equals(a, b)) { 347 throw new IllegalStateException( 348 "inconsistent compatibility states"); 349 } 350 return a; 351 })); 352 } 353 354 /** 355 * Cleanup the altered change ids under test. 356 * 357 * @param pkgName Package name of the app under test. 358 * @param enabledChanges Set of changes that were enabled during the test and need to be 359 * reset to the default value. 360 * @param disabledChanges Set of changes that were disabled during the test and need to 361 * be reset to the default value. 362 */ 363 protected void resetCompatConfig( String pkgName, Set<Long> enabledChanges, 364 Set<Long> disabledChanges) throws DeviceNotAvailableException { 365 // Clear overrides. 366 resetCompatChanges(enabledChanges, pkgName); 367 resetCompatChanges(disabledChanges, pkgName); 368 } 369 370 /** 371 * Validate that all overridden changes were logged while running the test. 372 * 373 * @param configId The unique config id used to track change id queries. 374 * @param pkgName Package name of the app under test. 375 * @param loggedEnabledChanges Changes expected to be logged as enabled during the test. 376 * @param loggedDisabledChanges Changes expected to be logged as disabled during the test. 377 */ 378 protected void validatePostRunStatsdReport(long configId, String pkgName, 379 Set<Long> loggedEnabledChanges, Set<Long> loggedDisabledChanges) 380 throws DeviceNotAvailableException { 381 // Clear statsd report data and remove config 382 Map<Long, Boolean> reportedChanges = getReportedChanges(configId, pkgName); 383 removeStatsdConfig(configId); 384 385 for (Long enabledChange : loggedEnabledChanges) { 386 assertThat(reportedChanges) 387 .containsEntry(enabledChange, true); 388 } 389 for (Long disabledChange : loggedDisabledChanges) { 390 assertThat(reportedChanges) 391 .containsEntry(disabledChange, false); 392 } 393 } 394 395 /** 396 * Execute the given command, and returns the output. 397 */ 398 protected String runCommand(String command) throws DeviceNotAvailableException { 399 final CollectingOutputReceiver receiver = new CollectingOutputReceiver(); 400 getDevice().executeShellCommand(command, receiver); 401 return receiver.getOutput(); 402 } 403 404 /** 405 * Get the on device compat config. 406 */ 407 protected List<Change> getOnDeviceCompatConfig() throws Exception { 408 String config = runCommand("dumpsys platform_compat"); 409 return Arrays.stream(config.split("\n")) 410 .map(Change::fromString) 411 .collect(Collectors.toList()); 412 } 413 414 protected Change getOnDeviceChangeIdConfig(long changeId) throws Exception { 415 List<Change> changes = getOnDeviceCompatConfig(); 416 for (Change change : changes) { 417 if (change.changeId == changeId) { 418 return change; 419 } 420 } 421 return null; 422 } 423 } 424