• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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