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 }