diff --git a/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/device/network/Network.kt b/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/device/network/Network.kt index 3fa1373c0..c580ff1b0 100644 --- a/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/device/network/Network.kt +++ b/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/device/network/Network.kt @@ -41,12 +41,38 @@ interface Network { fun disable() /** - * Toggles only mobile data. Note: it works only if flight mode is off. + * Toggles only mobile data. Note: it works only if airplane mode is off. */ fun toggleMobileData(enable: Boolean) /** - * Toggles only wi-fi. Note: it works only if flight mode is off. + * Toggles only wi-fi. Note: it works only if airplane mode is off. */ fun toggleWiFi(enable: Boolean) + + /** + * Toggles airplane mode on or off. + * + * The implementation strategy depends on the Android API level: + * + * **API <= 23 (up to Android 6.0 Marshmallow):** + * Uses `settings put global airplane_mode_on <0|1>` to update the global setting, + * then fires an `android.intent.action.AIRPLANE_MODE` broadcast to notify the system. + * This is the only reliable method on older devices, where `cmd connectivity` does not exist. + * + * **API >= 25 (Android 7.1 Nougat and above):** + * Uses `adb shell cmd connectivity airplane-mode `, which is the modern + * and preferred way to toggle airplane mode programmatically without requiring root. + * If this command fails (e.g. ADB server is unavailable), falls back to toggling the + * switch via the Android Settings UI. + * + * **Known edge case on API 24 (Android 7.0 Nougat):** + * API 24 is in an unfortunate middle ground: sending the `AIRPLANE_MODE` broadcast was + * already restricted for third-party apps in this version, but `cmd connectivity airplane-mode` + * had not been introduced yet. As a result, the ADB command path may fail silently on API 24, + * and the implementation will fall back to toggling the setting via the Android Settings UI. + * + * @param enable `true` to enable airplane mode, `false` to disable it. + */ + fun toggleAirplaneMode(enable: Boolean) } diff --git a/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/device/network/NetworkImpl.kt b/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/device/network/NetworkImpl.kt index 0958188b6..64589113a 100644 --- a/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/device/network/NetworkImpl.kt +++ b/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/device/network/NetworkImpl.kt @@ -10,6 +10,7 @@ import com.kaspersky.components.kautomator.system.UiSystem import com.kaspersky.kaspresso.device.server.AdbServer import com.kaspersky.kaspresso.flakysafety.algorithm.FlakySafetyAlgorithm import com.kaspersky.kaspresso.internal.exceptions.AdbServerException +import com.kaspersky.kaspresso.internal.systemscreen.AirplaneModeSettingsScreen import com.kaspersky.kaspresso.internal.systemscreen.DataUsageSettingsScreen import com.kaspersky.kaspresso.internal.systemscreen.NotificationsFullScreen import com.kaspersky.kaspresso.internal.systemscreen.NotificationsMobileDataScreen @@ -49,16 +50,24 @@ class NetworkImpl( companion object { private const val CMD_STATE_ENABLE = "enable" private const val CMD_STATE_DISABLE = "disable" + private const val NETWORK_STATE_CHANGE_CMD = "svc data" private const val NETWORK_STATE_CHANGE_ROOT_CMD = "su 0 svc data" private const val NETWORK_STATE_CHECK_CMD = "settings get global mobile_data" private const val NETWORK_STATE_CHECK_RESULT_ENABLED = "1" private const val NETWORK_STATE_CHECK_RESULT_DISABLED = "0" + private const val WIFI_STATE_CHANGE_CMD = "svc wifi" private const val WIFI_STATE_CHANGE_ROOT_CMD = "su 0 svc wifi" private const val WIFI_STATE_CHECK_CMD = "settings get global wifi_on" private const val WIFI_STATE_CHECK_RESULT_ENABLED = "1" private const val WIFI_STATE_CHECK_RESULT_DISABLED = "0" + + private const val AIRPLANE_MODE_CHANGE_CMD = "cmd connectivity airplane-mode" + private const val AIRPLANE_MODE_CHANGE_ROOT_CMD = "su 0 cmd connectivity airplane-mode" + private const val AIRPLANE_MODE_STATE_CHECK_CMD = "settings get global airplane_mode_on" + private const val AIRPLANE_MODE_STATE_CHECK_RESULT_ENABLED = "1" + private const val AIRPLANE_MODE_STATE_CHECK_RESULT_DISABLED = "0" private val ADB_RESULT_REGEX = Regex("exitCode=(\\d+), message=(.+)") } @@ -102,8 +111,8 @@ class NetworkImpl( !toggleMobileDataUsingAdbServer(enable, NETWORK_STATE_CHANGE_CMD) ) { toggleMobileDataUsingAndroidSettings(enable) - logger.i("Mobile data ${if (enable) "en" else "dis"}abled") } + logger.i("Mobile data ${if (enable) "en" else "dis"}abled") } private fun toggleMobileDataUsingAdbServer(enable: Boolean, changeCommand: String): Boolean = @@ -176,8 +185,8 @@ class NetworkImpl( !changeWiFiStateUsingAdbServer(enable, WIFI_STATE_CHANGE_CMD) ) { changeWifiStateUsingAndroidSettings(enable) - logger.i("Wi-fi ${if (enable) "en" else "dis"}abled") } + logger.i("Wi-fi ${if (enable) "en" else "dis"}abled") } /** @@ -240,6 +249,54 @@ class NetworkImpl( } } + override fun toggleAirplaneMode(enable: Boolean) { + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.M) { + toggleAirplaneModeUsingAdbOnOdlerApi(enable) + } else { + if (!toggleAirplaneModeUsingAdb(enable, AIRPLANE_MODE_CHANGE_CMD) && + !toggleAirplaneModeUsingAdb(enable, AIRPLANE_MODE_CHANGE_ROOT_CMD)) { + toggleAirplaneModeAndroidSettings(enable) + } + } + + if (enable) { + logger.i("Airplane mode enabled") + } else { + logger.i("Airplane mode disabled") + } + } + + private fun toggleAirplaneModeUsingAdb(isEnabled: Boolean, changeCommand: String): Boolean { + return try { + val (state, expectedResult) = when (isEnabled) { + true -> CMD_STATE_ENABLE to AIRPLANE_MODE_STATE_CHECK_RESULT_ENABLED + false -> CMD_STATE_DISABLE to AIRPLANE_MODE_STATE_CHECK_RESULT_DISABLED + } + adbServer.performShell("$changeCommand $state") + flakySafetyAlgorithm.invokeFlakySafely(flakySafetyParams) { + val result = adbServer.performShell(AIRPLANE_MODE_STATE_CHECK_CMD) + if (parseAdbResponse(result)?.trim() == expectedResult) true else + throw AdbServerException("Failed to change airplane mode state using ABD") + } + } catch (_: AdbServerException) { + false + } + } + + private fun toggleAirplaneModeUsingAdbOnOdlerApi(isEnabled: Boolean) { + val state = if (isEnabled) "1" else "0" + adbServer.performShell("settings", listOf("put", "global", "airplane_mode_on", state, + "&&", "am", "broadcast", "-a", "android.intent.action.AIRPLANE_MODE")) + } + + private fun toggleAirplaneModeAndroidSettings(isEnabled: Boolean) { + AirplaneModeSettingsScreen { + open(targetContext) + airplaneModeSwitch.setChecked(isEnabled) + close(targetContext) + } + } + private fun parseAdbResponse(response: List): String? { val result = response.firstOrNull()?.lineSequence()?.first() ?: return null val match = ADB_RESULT_REGEX.find(result) ?: return null diff --git a/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/internal/systemscreen/AirplaneModeSettingsScreen.kt b/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/internal/systemscreen/AirplaneModeSettingsScreen.kt new file mode 100644 index 000000000..def1b8cc9 --- /dev/null +++ b/kaspresso/src/main/kotlin/com/kaspersky/kaspresso/internal/systemscreen/AirplaneModeSettingsScreen.kt @@ -0,0 +1,32 @@ +package com.kaspersky.kaspresso.internal.systemscreen + +import android.content.Context +import android.content.Intent +import android.provider.Settings +import android.widget.Switch +import com.kaspersky.components.kautomator.component.switch.UiSwitch +import com.kaspersky.components.kautomator.screen.UiScreen + +object AirplaneModeSettingsScreen : UiScreen() { + + private const val TIMEOUT = 5_000L + + override val packageName: String = "com.android.settings" + val airplaneModeSwitch = UiSwitch { + withClassName(Switch::class.java) + } + + fun open(context: Context) { + context.startActivity( + Intent(Settings.ACTION_AIRPLANE_MODE_SETTINGS).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + ) + waitForWindowUpdate(WiFiSettingsScreen.packageName, TIMEOUT) + } + + fun close(context: Context) { + pressBack() + waitForWindowUpdate(context.packageName, TIMEOUT) + } +} diff --git a/samples/kaspresso-sample/src/androidTest/kotlin/com/kaspersky/kaspressample/device_tests/DeviceNetworkSampleTest.kt b/samples/kaspresso-sample/src/androidTest/kotlin/com/kaspersky/kaspressample/device_tests/DeviceNetworkSampleTest.kt index 00ae8c740..66c7e50c5 100644 --- a/samples/kaspresso-sample/src/androidTest/kotlin/com/kaspersky/kaspressample/device_tests/DeviceNetworkSampleTest.kt +++ b/samples/kaspresso-sample/src/androidTest/kotlin/com/kaspersky/kaspressample/device_tests/DeviceNetworkSampleTest.kt @@ -9,8 +9,6 @@ import android.net.NetworkCapabilities import android.net.NetworkRequest import android.net.wifi.WifiManager import android.os.Build -import android.provider.Settings -import android.telephony.TelephonyManager import androidx.test.ext.junit.rules.activityScenarioRule import androidx.test.rule.GrantPermissionRule import com.kaspersky.kaspressample.device.DeviceSampleActivity @@ -88,6 +86,13 @@ class DeviceNetworkSampleTest : TestCase() { device.network.toggleMobileData(true) assertTrueSafely { isDataConnected() } } + + step("Toggle Airplane mode") { + device.network.toggleAirplaneMode(true) + assertFalseSafely { isDataConnected() } + device.network.toggleAirplaneMode(false) + assertTrueSafely { isDataConnected() } + } } } @@ -125,12 +130,10 @@ class DeviceNetworkSampleTest : TestCase() { } private fun BaseTestContext.isDataConnectedInLowAndroid(): Boolean { - val telephonyManager: TelephonyManager? = - device.context.getSystemService(Context.TELEPHONY_SERVICE) as TelephonyManager? - if (telephonyManager?.simState != TelephonyManager.SIM_STATE_READY) { - return false - } - return Settings.Global.getInt(device.context.contentResolver, "mobile_data", 0) == 1 + val connectivityManager = device.context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + val networkInfo = connectivityManager.activeNetworkInfo + + return networkInfo?.isConnected ?: false } private fun BaseTestContext.checkWifi(shouldBeEnabled: Boolean) {