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.telephony.TelephonyManager
40 import android.telephony.TelephonyManager.SIM_STATE_UNKNOWN
41 import android.util.Log
42 import androidx.annotation.RequiresApi
43 import androidx.test.platform.app.InstrumentationRegistry
44 import com.android.modules.utils.build.SdkLevel.isAtLeastS
45 import java.io.ByteArrayOutputStream
46 import java.io.File
47 import java.io.FileOutputStream
48 import java.io.PrintWriter
49 import java.time.ZonedDateTime
50 import java.time.format.DateTimeFormatter
51 import java.util.concurrent.CompletableFuture
52 import java.util.concurrent.TimeUnit
53 import java.util.concurrent.TimeoutException
54 import kotlin.test.assertNull
55 import org.json.JSONObject
56 import org.junit.AssumptionViolatedException
57 import org.junit.runner.Description
58 import org.junit.runner.Result
59 import org.junit.runner.notification.Failure
60 
61 /**
62  * A diagnostics collector that outputs diagnostics files as test artifacts.
63  *
64  * <p>Collects diagnostics automatically by default on non-local builds. Can be enabled/disabled
65  * manually with:
66  * ```
67  * atest MyModule -- \
68  *     --module-arg MyModule:instrumentation-arg:connectivity-diagnostics-on-failure:=false
69  * ```
70  */
71 class ConnectivityDiagnosticsCollector : BaseMetricListener() {
72     companion object {
73         private const val ARG_RUN_ON_FAILURE = "connectivity-diagnostics-on-failure"
74         private const val COLLECTOR_DIR = "run_listeners/connectivity_diagnostics"
75         private const val FILENAME_SUFFIX = "_conndiag.txt"
76         private const val MAX_DUMPS = 20
77 
78         private val TAG = ConnectivityDiagnosticsCollector::class.simpleName
79         @JvmStatic
80         var instance: ConnectivityDiagnosticsCollector? = null
81     }
82 
83     private var failureHeader: String? = null
84     private val buffer = ByteArrayOutputStream()
85     private val failureHeaderExtras = mutableMapOf<String, Any>()
86     private val collectorDir: File by lazy {
87         createAndEmptyDirectory(COLLECTOR_DIR)
88     }
89     private val outputFiles = mutableSetOf<String>()
90     private val cbHelper = NetworkCallbackHelper()
91     private val networkCallback = MonitoringNetworkCallback()
92 
93     inner class MonitoringNetworkCallback : NetworkCallback() {
94         val currentMobileDataNetworks = mutableMapOf<Network, NetworkCapabilities>()
95         val currentVpnNetworks = mutableMapOf<Network, NetworkCapabilities>()
96         val currentWifiNetworks = mutableMapOf<Network, NetworkCapabilities>()
97 
98         override fun onLost(network: Network) {
99             currentWifiNetworks.remove(network)
100             currentMobileDataNetworks.remove(network)
101         }
102 
103         override fun onCapabilitiesChanged(network: Network, nc: NetworkCapabilities) {
104             if (nc.hasTransport(TRANSPORT_VPN)) {
105                 currentVpnNetworks[network] = nc
106             } else if (nc.hasTransport(TRANSPORT_WIFI)) {
107                 currentWifiNetworks[network] = nc
108             } else if (nc.hasTransport(TRANSPORT_CELLULAR)) {
109                 currentMobileDataNetworks[network] = nc
110             }
111         }
112     }
113 
114     override fun onSetUp() {
115         assertNull(instance, "ConnectivityDiagnosticsCollectors were set up multiple times")
116         instance = this
117         TryTestConfig.swapDiagnosticsCollector { throwable ->
118             if (runOnFailure(throwable)) {
119                 collectTestFailureDiagnostics(throwable)
120             }
121         }
122     }
123 
124     override fun onCleanUp() {
125         instance = null
126     }
127 
128     override fun onTestRunStart(runData: DataRecord?, description: Description?) {
129         runAsShell(NETWORK_SETTINGS) {
130             cbHelper.registerNetworkCallback(
131                 NetworkRequest.Builder()
132                     .addCapability(NET_CAPABILITY_INTERNET)
133                     .addTransportType(TRANSPORT_WIFI)
134                     .addTransportType(TRANSPORT_CELLULAR)
135                     .build(), networkCallback
136             )
137         }
138     }
139 
140     override fun onTestRunEnd(runData: DataRecord?, result: Result?) {
141         // onTestRunEnd is called regardless of success/failure, and the Result contains summary of
142         // run/failed/ignored... tests.
143         cbHelper.unregisterAll()
144     }
145 
146     override fun onTestFail(testData: DataRecord, description: Description, failure: Failure) {
147         // TODO: find a way to disable this behavior only on local runs, to avoid slowing them down
148         // when iterating on failing tests.
149         if (!runOnFailure(failure.exception)) return
150         if (outputFiles.size >= MAX_DUMPS) return
151         Log.i(TAG, "Collecting diagnostics for test failure. Disable by running tests with: " +
152                 "atest MyModule -- " +
153                 "--module-arg MyModule:instrumentation-arg:$ARG_RUN_ON_FAILURE:=false")
154         collectTestFailureDiagnostics(failure.exception)
155 
156         val baseFilename = "${description.className}#${description.methodName}_failure"
157         flushBufferToFileMetric(testData, baseFilename)
158     }
159 
160     override fun onTestEnd(testData: DataRecord, description: Description) {
161         // Tests may call methods like collectDumpsysConnectivity to collect diagnostics at any time
162         // during the run, for example to observe state at various points to investigate a flake
163         // and compare passing/failing cases.
164         // Flush the contents of the buffer to a file when the test ends, even when successful.
165         if (buffer.size() == 0) return
166         if (outputFiles.size >= MAX_DUMPS) return
167 
168         // Flush any data that the test added to the buffer for dumping
169         val baseFilename = "${description.className}#${description.methodName}_testdump"
170         flushBufferToFileMetric(testData, baseFilename)
171     }
172 
173     private fun runOnFailure(exception: Throwable): Boolean {
174         // Assumption failures (assumeTrue/assumeFalse) are not actual failures
175         if (exception is AssumptionViolatedException) return false
176 
177         // Do not run on local builds (which have ro.build.version.incremental set to eng.username)
178         // to avoid slowing down local runs.
179         val enabledByDefault = !Build.VERSION.INCREMENTAL.startsWith("eng.")
180         return argsBundle.getString(ARG_RUN_ON_FAILURE)?.toBooleanStrictOrNull() ?: enabledByDefault
181     }
182 
183     private fun flushBufferToFileMetric(testData: DataRecord, baseFilename: String) {
184         var filename = baseFilename
185         // In case a method was run multiple times (typically retries), append a number
186         var i = 2
187         while (outputFiles.contains(filename)) {
188             filename = baseFilename + "_$i"
189             i++
190         }
191         val outFile = File(collectorDir, filename + FILENAME_SUFFIX)
192         outputFiles.add(filename)
193         FileOutputStream(outFile).use { fos ->
194             failureHeader?.let {
195                 fos.write(it.toByteArray())
196                 fos.write("\n".toByteArray())
197             }
198             fos.write(buffer.toByteArray())
199         }
200         failureHeader = null
201         buffer.reset()
202         val fileKey = "${ConnectivityDiagnosticsCollector::class.qualifiedName}_$filename"
203         testData.addFileMetric(fileKey, outFile)
204     }
205 
206     private fun maybeCollectFailureHeader() {
207         if (failureHeader != null) {
208             Log.i(TAG, "Connectivity diagnostics failure header already collected, skipping")
209             return
210         }
211 
212         val instr = InstrumentationRegistry.getInstrumentation()
213         val ctx = instr.context
214         val pm = ctx.packageManager
215         val hasWifi = pm.hasSystemFeature(FEATURE_WIFI)
216         val hasMobileData = pm.hasSystemFeature(FEATURE_TELEPHONY)
217         val tm = if (hasMobileData) ctx.getSystemService(TelephonyManager::class.java) else null
218         // getAdoptedShellPermissions is S+. Optimistically assume that tests are not holding on
219         // shell permissions during failure/cleanup on R.
220         val canUseShell = !isAtLeastS() ||
221                 instr.uiAutomation.getAdoptedShellPermissions().isNullOrEmpty()
222         val headerObj = JSONObject()
223         failureHeaderExtras.forEach { (k, v) -> headerObj.put(k, v) }
224         failureHeaderExtras.clear()
225         if (canUseShell) {
226             runAsShell(READ_PRIVILEGED_PHONE_STATE, NETWORK_SETTINGS) {
227                 headerObj.apply {
228                     put("deviceSerial", Build.getSerial())
229                     // The network callback filed on start cannot get the WifiInfo as it would need
230                     // to keep NETWORK_SETTINGS permission throughout the test run. Try to
231                     // obtain it while holding the permission at the end of the test.
232                     val wifiInfo = networkCallback.currentWifiNetworks.keys.firstOrNull()?.let {
233                         getWifiInfo(it)
234                     }
235                     put("ssid", wifiInfo?.ssid)
236                     put("bssid", wifiInfo?.bssid)
237                     put("simState", tm?.simState ?: SIM_STATE_UNKNOWN)
238                     put("mccMnc", tm?.simOperator)
239                 }
240             }
241         } else {
242             Log.w(TAG, "The test is still holding shell permissions, cannot collect privileged " +
243                     "device info")
244             headerObj.put("shellPermissionsUnavailable", true)
245         }
246         failureHeader = headerObj.apply {
247             put("time", DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(ZonedDateTime.now()))
248             put(
249                 "wifiEnabled",
250                 hasWifi && ctx.getSystemService(WifiManager::class.java).isWifiEnabled
251             )
252             put("connectedWifiCount", networkCallback.currentWifiNetworks.size)
253             put("validatedWifiCount", networkCallback.currentWifiNetworks.filterValues {
254                 it.hasCapability(NET_CAPABILITY_VALIDATED)
255             }.size)
256             put("mobileDataConnectivityPossible", tm?.isDataConnectivityPossible ?: false)
257             put("connectedMobileDataCount", networkCallback.currentMobileDataNetworks.size)
258             put("validatedMobileDataCount",
259                 networkCallback.currentMobileDataNetworks.filterValues {
260                     it.hasCapability(NET_CAPABILITY_VALIDATED)
261                 }.size
262             )
263         }.toString()
264     }
265 
266     private class WifiInfoCallback : NetworkCallback {
267         private val network: Network
268         val wifiInfoFuture = CompletableFuture<WifiInfo?>()
269         constructor(network: Network) : super() {
270             this.network = network
271         }
272         @RequiresApi(Build.VERSION_CODES.S)
273         constructor(network: Network, flags: Int) : super(flags) {
274             this.network = network
275         }
276         override fun onCapabilitiesChanged(net: Network, nc: NetworkCapabilities) {
277             if (network == net) {
278                 wifiInfoFuture.complete(nc.transportInfo as? WifiInfo)
279             }
280         }
281     }
282 
283     private fun getWifiInfo(network: Network): WifiInfo? {
284         // Get the SSID via network callbacks, as the Networks are obtained via callbacks, and
285         // synchronous calls (CM#getNetworkCapabilities) and callbacks should not be mixed.
286         // A new callback needs to be filed and received while holding NETWORK_SETTINGS permission.
287         val cb = if (isAtLeastS()) {
288             WifiInfoCallback(network, FLAG_INCLUDE_LOCATION_INFO)
289         } else {
290             WifiInfoCallback(network)
291         }
292         cbHelper.registerNetworkCallback(
293             NetworkRequest.Builder()
294                 .addTransportType(TRANSPORT_WIFI)
295                 .addCapability(NET_CAPABILITY_INTERNET).build(), cb)
296         return try {
297             cb.wifiInfoFuture.get(1L, TimeUnit.SECONDS)
298         } catch (e: TimeoutException) {
299             null
300         } finally {
301             cbHelper.unregisterNetworkCallback(cb)
302         }
303     }
304 
305     /**
306      * Add connectivity diagnostics to the test data dump.
307      *
308      * <p>This collects a set of diagnostics that are relevant to connectivity test failures.
309      * <p>The dump will be collected immediately, and exported to a test artifact file when the
310      * test ends.
311      * @param exceptionContext An exception to write a stacktrace to the dump for context.
312      */
313     fun collectTestFailureDiagnostics(exceptionContext: Throwable? = null) {
314         maybeCollectFailureHeader()
315         collectDumpsysConnectivity(exceptionContext)
316     }
317 
318     /**
319      * Add dumpsys connectivity to the test data dump.
320      *
321      * <p>The dump will be collected immediately, and exported to a test artifact file when the
322      * test ends.
323      * @param exceptionContext An exception to write a stacktrace to the dump for context.
324      */
325     fun collectDumpsysConnectivity(exceptionContext: Throwable? = null) {
326         Log.i(TAG, "Collecting dumpsys connectivity for test artifacts")
327         PrintWriter(buffer).let {
328             it.println("--- Dumpsys connectivity at ${ZonedDateTime.now()} ---")
329             maybeWriteExceptionContext(it, exceptionContext)
330             it.flush()
331         }
332         ParcelFileDescriptor.AutoCloseInputStream(
333             InstrumentationRegistry.getInstrumentation().uiAutomation.executeShellCommand(
334                 "dumpsys connectivity --dump-priority HIGH")).use {
335             it.copyTo(buffer)
336         }
337     }
338 
339     /**
340      * Add a key->value attribute to the failure data, to be written to the diagnostics file.
341      *
342      * <p>This is to be called by tests that know they will fail.
343      */
344     fun addFailureAttribute(key: String, value: Any) {
345         failureHeaderExtras[key] = value
346     }
347 
348     private fun maybeWriteExceptionContext(writer: PrintWriter, exceptionContext: Throwable?) {
349         if (exceptionContext == null) return
350         writer.println("At: ")
351         exceptionContext.printStackTrace(writer)
352     }
353 }