1 /* <lambda>null2 * 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 17 package com.android.testutils 18 19 import android.Manifest.permission.NETWORK_SETTINGS 20 import android.Manifest.permission.READ_PRIVILEGED_PHONE_STATE 21 import android.content.pm.PackageManager.FEATURE_TELEPHONY 22 import android.content.pm.PackageManager.FEATURE_WIFI 23 import android.device.collectors.BaseMetricListener 24 import android.device.collectors.DataRecord 25 import android.net.ConnectivityManager.NetworkCallback 26 import android.net.ConnectivityManager.NetworkCallback.FLAG_INCLUDE_LOCATION_INFO 27 import android.net.Network 28 import android.net.NetworkCapabilities 29 import android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET 30 import android.net.NetworkCapabilities.NET_CAPABILITY_VALIDATED 31 import android.net.NetworkCapabilities.TRANSPORT_CELLULAR 32 import android.net.NetworkCapabilities.TRANSPORT_VPN 33 import android.net.NetworkCapabilities.TRANSPORT_WIFI 34 import android.net.NetworkRequest 35 import android.net.wifi.WifiInfo 36 import android.net.wifi.WifiManager 37 import android.os.Build 38 import android.os.ParcelFileDescriptor 39 import android.os.ParcelFileDescriptor.AutoCloseInputStream 40 import android.telephony.TelephonyManager 41 import android.telephony.TelephonyManager.SIM_STATE_UNKNOWN 42 import android.util.Log 43 import androidx.annotation.RequiresApi 44 import androidx.test.platform.app.InstrumentationRegistry 45 import com.android.modules.utils.build.SdkLevel.isAtLeastS 46 import java.io.ByteArrayOutputStream 47 import java.io.CharArrayWriter 48 import java.io.File 49 import java.io.FileReader 50 import java.io.InputStream 51 import java.io.OutputStream 52 import java.io.OutputStreamWriter 53 import java.io.PrintWriter 54 import java.io.Reader 55 import java.time.ZonedDateTime 56 import java.time.format.DateTimeFormatter 57 import java.util.concurrent.CompletableFuture 58 import java.util.concurrent.TimeUnit 59 import java.util.concurrent.TimeoutException 60 import kotlin.test.assertNull 61 import org.json.JSONObject 62 import org.junit.AssumptionViolatedException 63 import org.junit.runner.Description 64 import org.junit.runner.Result 65 import org.junit.runner.notification.Failure 66 67 /** 68 * A diagnostics collector that outputs diagnostics files as test artifacts. 69 * 70 * <p>Collects diagnostics automatically by default on non-local builds. Can be enabled/disabled 71 * manually with: 72 * ``` 73 * atest MyModule -- \ 74 * --module-arg MyModule:instrumentation-arg:connectivity-diagnostics-on-failure:=false 75 * ``` 76 */ 77 class ConnectivityDiagnosticsCollector : BaseMetricListener() { 78 companion object { 79 private const val ARG_RUN_ON_FAILURE = "connectivity-diagnostics-on-failure" 80 private const val COLLECTOR_DIR = "run_listeners/connectivity_diagnostics" 81 private const val FILENAME_SUFFIX = "_conndiag.txt" 82 private const val MAX_DUMPS = 20 83 84 private val TAG = ConnectivityDiagnosticsCollector::class.simpleName 85 @JvmStatic 86 var instance: ConnectivityDiagnosticsCollector? = null 87 } 88 89 /** 90 * Indicates tcpdump should be started and written to the diagnostics file on test case failure. 91 */ 92 annotation class CollectTcpdumpOnFailure 93 94 private class DumpThread( 95 // Keep a reference to the ParcelFileDescriptor otherwise GC would close it 96 private val fd: ParcelFileDescriptor, 97 private val reader: Reader 98 ) : Thread() { 99 private val writer = CharArrayWriter() 100 override fun run() { 101 reader.copyTo(writer) 102 } 103 104 fun closeAndWriteTo(output: OutputStream?) { 105 join() 106 fd.close() 107 if (output != null) { 108 val outputWriter = OutputStreamWriter(output) 109 outputWriter.write("--- tcpdump stopped at ${ZonedDateTime.now()} ---\n") 110 writer.writeTo(outputWriter) 111 } 112 } 113 } 114 115 private data class TcpdumpRun(val pid: Int, val reader: DumpThread) 116 117 private var failureHeader: String? = null 118 119 // Accessed from the test listener methods which are synchronized by junit (see TestListener) 120 private var tcpdumpRun: TcpdumpRun? = null 121 private val buffer = ByteArrayOutputStream() 122 private val failureHeaderExtras = mutableMapOf<String, Any>() 123 private val collectorDir: File by lazy { 124 createAndEmptyDirectory(COLLECTOR_DIR) 125 } 126 private val outputFiles = mutableSetOf<String>() 127 private val cbHelper = NetworkCallbackHelper() 128 private val networkCallback = MonitoringNetworkCallback() 129 130 inner class MonitoringNetworkCallback : NetworkCallback() { 131 val currentMobileDataNetworks = mutableMapOf<Network, NetworkCapabilities>() 132 val currentVpnNetworks = mutableMapOf<Network, NetworkCapabilities>() 133 val currentWifiNetworks = mutableMapOf<Network, NetworkCapabilities>() 134 135 override fun onLost(network: Network) { 136 currentWifiNetworks.remove(network) 137 currentMobileDataNetworks.remove(network) 138 } 139 140 override fun onCapabilitiesChanged(network: Network, nc: NetworkCapabilities) { 141 if (nc.hasTransport(TRANSPORT_VPN)) { 142 currentVpnNetworks[network] = nc 143 } else if (nc.hasTransport(TRANSPORT_WIFI)) { 144 currentWifiNetworks[network] = nc 145 } else if (nc.hasTransport(TRANSPORT_CELLULAR)) { 146 currentMobileDataNetworks[network] = nc 147 } 148 } 149 } 150 151 override fun onSetUp() { 152 assertNull(instance, "ConnectivityDiagnosticsCollectors were set up multiple times") 153 instance = this 154 TryTestConfig.swapDiagnosticsCollector { throwable -> 155 if (runOnFailure(throwable)) { 156 collectTestFailureDiagnostics(throwable) 157 } 158 } 159 } 160 161 override fun onCleanUp() { 162 instance = null 163 } 164 165 override fun onTestRunStart(runData: DataRecord?, description: Description?) { 166 runAsShell(NETWORK_SETTINGS) { 167 cbHelper.registerNetworkCallback( 168 NetworkRequest.Builder() 169 .addCapability(NET_CAPABILITY_INTERNET) 170 .addTransportType(TRANSPORT_WIFI) 171 .addTransportType(TRANSPORT_CELLULAR) 172 .build(), 173 networkCallback 174 ) 175 } 176 } 177 178 override fun onTestRunEnd(runData: DataRecord?, result: Result?) { 179 // onTestRunEnd is called regardless of success/failure, and the Result contains summary of 180 // run/failed/ignored... tests. 181 cbHelper.unregisterAll() 182 } 183 184 override fun onTestFail(testData: DataRecord, description: Description, failure: Failure) { 185 // TODO: find a way to disable this behavior only on local runs, to avoid slowing them down 186 // when iterating on failing tests. 187 if (!runOnFailure(failure.exception)) return 188 if (outputFiles.size >= MAX_DUMPS) return 189 Log.i( 190 TAG, 191 "Collecting diagnostics for test failure. Disable by running tests with: " + 192 "atest MyModule -- " + 193 "--module-arg MyModule:instrumentation-arg:$ARG_RUN_ON_FAILURE:=false" 194 ) 195 collectTestFailureDiagnostics(failure.exception) 196 197 val baseFilename = "${description.className}#${description.methodName}_failure" 198 flushBufferToFileMetric(testData, baseFilename) 199 } 200 201 override fun onTestStart(testData: DataRecord, description: Description) { 202 val tcpdumpAnn = description.annotations.firstOrNull { it is CollectTcpdumpOnFailure } 203 as? CollectTcpdumpOnFailure 204 if (tcpdumpAnn != null) { 205 startTcpdumpForTestcaseIfSupported() 206 } 207 } 208 209 private fun startTcpdumpForTestcaseIfSupported() { 210 if (!DeviceInfoUtils.isDebuggable()) { 211 Log.d(TAG, "Cannot start tcpdump, build is not debuggable") 212 return 213 } 214 if (tcpdumpRun != null) { 215 Log.e(TAG, "Cannot start tcpdump: it is already running") 216 return 217 } 218 // executeShellCommand won't tokenize quoted arguments containing spaces (like pcap filters) 219 // properly, so pass in the command in stdin instead of using sh -c 'command' 220 val fds = instrumentation.uiAutomation.executeShellCommandRw("sh") 221 222 val stdout = fds[0] 223 val stdin = fds[1] 224 ParcelFileDescriptor.AutoCloseOutputStream(stdin).use { writer -> 225 // Echo the current pid, and replace it (with exec) with the tcpdump process, so the 226 // tcpdump pid is known. 227 writer.write( 228 "echo $$; exec su 0 tcpdump -n -i any -l -xx".encodeToByteArray() 229 ) 230 } 231 val reader = FileReader(stdout.fileDescriptor).buffered() 232 val tcpdumpPid = Integer.parseInt(reader.readLine()) 233 val dumpThread = DumpThread(stdout, reader) 234 dumpThread.start() 235 tcpdumpRun = TcpdumpRun(tcpdumpPid, dumpThread) 236 } 237 238 private fun stopTcpdumpIfRunning(output: OutputStream?) { 239 val run = tcpdumpRun ?: return 240 // Send SIGTERM for graceful shutdown of tcpdump so that it can flush its output 241 executeCommandBlocking("su 0 kill ${run.pid}") 242 run.reader.closeAndWriteTo(output) 243 tcpdumpRun = null 244 } 245 246 override fun onTestEnd(testData: DataRecord, description: Description) { 247 // onTestFail is called before onTestEnd, so if the test failed tcpdump would already have 248 // been stopped and output dumped. Here this stops tcpdump if the test succeeded, throwing 249 // away its output. 250 stopTcpdumpIfRunning(output = null) 251 252 // Tests may call methods like collectDumpsysConnectivity to collect diagnostics at any time 253 // during the run, for example to observe state at various points to investigate a flake 254 // and compare passing/failing cases. 255 // Flush the contents of the buffer to a file when the test ends, even when successful. 256 if (buffer.size() == 0) return 257 if (outputFiles.size >= MAX_DUMPS) return 258 259 // Flush any data that the test added to the buffer for dumping 260 val baseFilename = "${description.className}#${description.methodName}_testdump" 261 flushBufferToFileMetric(testData, baseFilename) 262 } 263 264 private fun runOnFailure(exception: Throwable): Boolean { 265 // Assumption failures (assumeTrue/assumeFalse) are not actual failures 266 if (exception is AssumptionViolatedException) return false 267 268 // Do not run on local builds (which have ro.build.version.incremental set to eng.username) 269 // to avoid slowing down local runs. 270 val enabledByDefault = !Build.VERSION.INCREMENTAL.startsWith("eng.") 271 return argsBundle.getString(ARG_RUN_ON_FAILURE)?.toBooleanStrictOrNull() ?: enabledByDefault 272 } 273 274 private fun flushBufferToFileMetric(testData: DataRecord, baseFilename: String) { 275 var filename = baseFilename 276 // In case a method was run multiple times (typically retries), append a number 277 var i = 2 278 while (outputFiles.contains(filename)) { 279 filename = baseFilename + "_$i" 280 i++ 281 } 282 val outFile = File(collectorDir, filename + FILENAME_SUFFIX) 283 outputFiles.add(filename) 284 getOutputStreamViaShell(outFile).use { fos -> 285 failureHeader?.let { 286 fos.write(it.toByteArray()) 287 fos.write("\n".toByteArray()) 288 } 289 fos.write(buffer.toByteArray()) 290 stopTcpdumpIfRunning(fos) 291 } 292 failureHeader = null 293 buffer.reset() 294 val fileKey = "${ConnectivityDiagnosticsCollector::class.qualifiedName}_$filename" 295 testData.addFileMetric(fileKey, outFile) 296 } 297 298 private fun maybeCollectFailureHeader() { 299 if (failureHeader != null) { 300 Log.i(TAG, "Connectivity diagnostics failure header already collected, skipping") 301 return 302 } 303 304 val instr = InstrumentationRegistry.getInstrumentation() 305 val ctx = instr.context 306 val pm = ctx.packageManager 307 val hasWifi = pm.hasSystemFeature(FEATURE_WIFI) 308 val hasMobileData = pm.hasSystemFeature(FEATURE_TELEPHONY) 309 val tm = if (hasMobileData) ctx.getSystemService(TelephonyManager::class.java) else null 310 // getAdoptedShellPermissions is S+. Optimistically assume that tests are not holding on 311 // shell permissions during failure/cleanup on R. 312 val canUseShell = !isAtLeastS() || 313 instr.uiAutomation.getAdoptedShellPermissions().isNullOrEmpty() 314 val headerObj = JSONObject() 315 failureHeaderExtras.forEach { (k, v) -> headerObj.put(k, v) } 316 failureHeaderExtras.clear() 317 if (canUseShell) { 318 runAsShell(READ_PRIVILEGED_PHONE_STATE, NETWORK_SETTINGS) { 319 headerObj.apply { 320 put("deviceSerial", Build.getSerial()) 321 // The network callback filed on start cannot get the WifiInfo as it would need 322 // to keep NETWORK_SETTINGS permission throughout the test run. Try to 323 // obtain it while holding the permission at the end of the test. 324 val wifiInfo = networkCallback.currentWifiNetworks.keys.firstOrNull()?.let { 325 getWifiInfo(it) 326 } 327 put("ssid", wifiInfo?.ssid) 328 put("bssid", wifiInfo?.bssid) 329 put("simState", tm?.simState ?: SIM_STATE_UNKNOWN) 330 put("mccMnc", tm?.simOperator) 331 } 332 } 333 } else { 334 Log.w( 335 TAG, 336 "The test is still holding shell permissions, cannot collect privileged " + 337 "device info" 338 ) 339 headerObj.put("shellPermissionsUnavailable", true) 340 } 341 failureHeader = headerObj.apply { 342 put("time", DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(ZonedDateTime.now())) 343 put( 344 "wifiEnabled", 345 hasWifi && ctx.getSystemService(WifiManager::class.java).isWifiEnabled 346 ) 347 put("connectedWifiCount", networkCallback.currentWifiNetworks.size) 348 put("validatedWifiCount", networkCallback.currentWifiNetworks.filterValues { 349 it.hasCapability(NET_CAPABILITY_VALIDATED) 350 }.size) 351 put("mobileDataConnectivityPossible", tm?.isDataConnectivityPossible ?: false) 352 put("connectedMobileDataCount", networkCallback.currentMobileDataNetworks.size) 353 put("validatedMobileDataCount", 354 networkCallback.currentMobileDataNetworks.filterValues { 355 it.hasCapability(NET_CAPABILITY_VALIDATED) 356 }.size 357 ) 358 }.toString() 359 } 360 361 private class WifiInfoCallback : NetworkCallback { 362 private val network: Network 363 val wifiInfoFuture = CompletableFuture<WifiInfo?>() 364 constructor(network: Network) : super() { 365 this.network = network 366 } 367 @RequiresApi(Build.VERSION_CODES.S) 368 constructor(network: Network, flags: Int) : super(flags) { 369 this.network = network 370 } 371 override fun onCapabilitiesChanged(net: Network, nc: NetworkCapabilities) { 372 if (network == net) { 373 wifiInfoFuture.complete(nc.transportInfo as? WifiInfo) 374 } 375 } 376 } 377 378 private fun getWifiInfo(network: Network): WifiInfo? { 379 // Get the SSID via network callbacks, as the Networks are obtained via callbacks, and 380 // synchronous calls (CM#getNetworkCapabilities) and callbacks should not be mixed. 381 // A new callback needs to be filed and received while holding NETWORK_SETTINGS permission. 382 val cb = if (isAtLeastS()) { 383 WifiInfoCallback(network, FLAG_INCLUDE_LOCATION_INFO) 384 } else { 385 WifiInfoCallback(network) 386 } 387 cbHelper.registerNetworkCallback( 388 NetworkRequest.Builder() 389 .addTransportType(TRANSPORT_WIFI) 390 .addCapability(NET_CAPABILITY_INTERNET).build(), 391 cb 392 ) 393 return try { 394 cb.wifiInfoFuture.get(1L, TimeUnit.SECONDS) 395 } catch (e: TimeoutException) { 396 null 397 } finally { 398 cbHelper.unregisterNetworkCallback(cb) 399 } 400 } 401 402 /** 403 * Add connectivity diagnostics to the test data dump. 404 * 405 * <p>This collects a set of diagnostics that are relevant to connectivity test failures. 406 * <p>The dump will be collected immediately, and exported to a test artifact file when the 407 * test ends. 408 * @param exceptionContext An exception to write a stacktrace to the dump for context. 409 */ 410 fun collectTestFailureDiagnostics(exceptionContext: Throwable? = null) { 411 maybeCollectFailureHeader() 412 collectDumpsysConnectivity(exceptionContext) 413 } 414 415 /** 416 * Add dumpsys connectivity to the test data dump. 417 * 418 * <p>The dump will be collected immediately, and exported to a test artifact file when the 419 * test ends. 420 * @param exceptionContext An exception to write a stacktrace to the dump for context. 421 */ 422 fun collectDumpsysConnectivity(exceptionContext: Throwable? = null) { 423 collectDumpsys("connectivity --dump-priority HIGH", exceptionContext) 424 } 425 426 /** 427 * Add a dumpsys to the test data dump. 428 * 429 * <p>The dump will be collected immediately, and exported to a test artifact file when the 430 * test ends. 431 * @param dumpsysCmd The dumpsys command to run (for example "connectivity"). 432 * @param exceptionContext An exception to write a stacktrace to the dump for context. 433 */ 434 fun collectDumpsys(dumpsysCmd: String, exceptionContext: Throwable? = null) = 435 collectCommandOutput("dumpsys $dumpsysCmd", exceptionContext = exceptionContext) 436 437 /** 438 * Add the output of a command to the test data dump. 439 * 440 * <p>The output will be collected immediately, and exported to a test artifact file when the 441 * test ends. 442 * @param cmd The command to run. Stdout of the command will be collected. 443 * @param shell The shell to run the command in, for example "sh". 444 * @param exceptionContext An exception to write a stacktrace to the dump for context. 445 */ 446 @RequiresApi(Build.VERSION_CODES.S) 447 fun collectCommandOutput( 448 cmd: String, 449 shell: String, 450 exceptionContext: Throwable? = null 451 ) = collectCommandOutput(cmd, exceptionContext) { c, outputProcessor -> 452 runCommandInShell(c, shell, outputProcessor) 453 } 454 455 /** 456 * Add the output of a command to the test data dump. 457 * 458 * <p>The output will be collected immediately, and exported to a test artifact file when the 459 * test ends. 460 * 461 * <p>Note this does not support shell pipes, redirections, or quoted arguments. See the S+ 462 * overload if that is needed. 463 * @param cmd The command to run. Stdout of the command will be collected. 464 * @param exceptionContext An exception to write a stacktrace to the dump for context. 465 */ 466 fun collectCommandOutput( 467 cmd: String, 468 exceptionContext: Throwable? = null 469 ) = collectCommandOutput(cmd, exceptionContext) { c, outputProcessor -> 470 AutoCloseInputStream( 471 InstrumentationRegistry.getInstrumentation().uiAutomation.executeShellCommand(c) 472 ).use { 473 outputProcessor(it) 474 } 475 } 476 477 private fun collectCommandOutput( 478 cmd: String, 479 exceptionContext: Throwable? = null, 480 commandRunner: (String, (InputStream) -> Unit) -> Unit 481 ) { 482 Log.i(TAG, "Collecting '$cmd' for test artifacts") 483 PrintWriter(buffer).let { 484 it.println("--- $cmd at ${ZonedDateTime.now()} ---") 485 maybeWriteExceptionContext(it, exceptionContext) 486 it.flush() 487 } 488 489 commandRunner(cmd) { stdout -> 490 stdout.copyTo(buffer) 491 } 492 } 493 494 /** 495 * Add a key->value attribute to the failure data, to be written to the diagnostics file. 496 * 497 * <p>This is to be called by tests that know they will fail. 498 */ 499 fun addFailureAttribute(key: String, value: Any) { 500 failureHeaderExtras[key] = value 501 } 502 503 private fun maybeWriteExceptionContext(writer: PrintWriter, exceptionContext: Throwable?) { 504 if (exceptionContext == null) return 505 writer.println("At: ") 506 exceptionContext.printStackTrace(writer) 507 } 508 } 509