blob: 3d98cc381b60a2cd2ac135a10d13ea437125e979 [file] [log] [blame]
/*
* Copyright (C) 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.testutils
import android.Manifest.permission.READ_DEVICE_CONFIG
import android.Manifest.permission.WRITE_DEVICE_CONFIG
import android.provider.DeviceConfig
import android.util.Log
import com.android.modules.utils.build.SdkLevel
import com.android.testutils.FunctionalUtils.ThrowingRunnable
import org.junit.rules.TestRule
import org.junit.runner.Description
import org.junit.runners.model.Statement
import java.util.concurrent.CompletableFuture
import java.util.concurrent.Executor
import java.util.concurrent.TimeUnit
private val TAG = DeviceConfigRule::class.simpleName
private const val TIMEOUT_MS = 20_000L
/**
* A [TestRule] that helps set [DeviceConfig] for tests and clean up the test configuration
* automatically on teardown.
*
* The rule can also optionally retry tests when they fail following an external change of
* DeviceConfig before S; this typically happens because device config flags are synced while the
* test is running, and DisableConfigSyncTargetPreparer is only usable starting from S.
*
* @param retryCountBeforeSIfConfigChanged if > 0, when the test fails before S, check if
* the configs that were set through this rule were changed, and retry the test
* up to the specified number of times if yes.
*/
class DeviceConfigRule @JvmOverloads constructor(
val retryCountBeforeSIfConfigChanged: Int = 0
) : TestRule {
// Maps (namespace, key) -> value
private val originalConfig = mutableMapOf<Pair<String, String>, String?>()
private val usedConfig = mutableMapOf<Pair<String, String>, String?>()
/**
* Actions to be run after cleanup of the config, for the current test only.
*/
private val currentTestCleanupActions = mutableListOf<ThrowingRunnable>()
override fun apply(base: Statement, description: Description): Statement {
return TestValidationUrlStatement(base, description)
}
private inner class TestValidationUrlStatement(
private val base: Statement,
private val description: Description
) : Statement() {
override fun evaluate() {
var retryCount = if (SdkLevel.isAtLeastS()) 1 else retryCountBeforeSIfConfigChanged + 1
while (retryCount > 0) {
retryCount--
tryTest {
base.evaluate()
// Can't use break/return out of a loop here because this is a tryTest lambda,
// so set retryCount to exit instead
retryCount = 0
}.catch<Throwable> { e -> // junit AssertionFailedError does not extend Exception
if (retryCount == 0) throw e
usedConfig.forEach { (key, value) ->
val currentValue = runAsShell(READ_DEVICE_CONFIG) {
DeviceConfig.getProperty(key.first, key.second)
}
if (currentValue != value) {
Log.w(TAG, "Test failed with unexpected device config change, retrying")
return@catch
}
}
throw e
} cleanupStep {
runAsShell(WRITE_DEVICE_CONFIG) {
originalConfig.forEach { (key, value) ->
DeviceConfig.setProperty(
key.first, key.second, value, false /* makeDefault */)
}
}
} cleanupStep {
originalConfig.clear()
usedConfig.clear()
} cleanup {
// Fold all cleanup actions into cleanup steps of an empty tryTest, so they are
// all run even if exceptions are thrown, and exceptions are reported properly.
currentTestCleanupActions.fold(tryTest { }) {
tryBlock, action -> tryBlock.cleanupStep { action.run() }
}.cleanup {
currentTestCleanupActions.clear()
}
}
}
}
}
/**
* Set a configuration key/value. After the test case ends, it will be restored to the value it
* had when this method was first called.
*/
fun setConfig(namespace: String, key: String, value: String?): String? {
Log.i(TAG, "Setting config \"$key\" to \"$value\"")
val readWritePermissions = arrayOf(READ_DEVICE_CONFIG, WRITE_DEVICE_CONFIG)
val keyPair = Pair(namespace, key)
val existingValue = runAsShell(*readWritePermissions) {
DeviceConfig.getProperty(namespace, key)
}
if (!originalConfig.containsKey(keyPair)) {
originalConfig[keyPair] = existingValue
}
usedConfig[keyPair] = value
if (existingValue == value) {
// Already the correct value. There may be a race if a change is already in flight,
// but if multiple threads update the config there is no way to fix that anyway.
Log.i(TAG, "\"$key\" already had value \"$value\"")
return value
}
val future = CompletableFuture<String>()
val listener = DeviceConfig.OnPropertiesChangedListener {
// The listener receives updates for any change to any key, so don't react to
// changes that do not affect the relevant key
if (!it.keyset.contains(key)) return@OnPropertiesChangedListener
// "null" means absent in DeviceConfig : there is no such thing as a present but
// null value, so the following works even if |value| is null.
if (it.getString(key, null) == value) {
future.complete(value)
}
}
return tryTest {
runAsShell(*readWritePermissions) {
DeviceConfig.addOnPropertiesChangedListener(
DeviceConfig.NAMESPACE_CONNECTIVITY,
inlineExecutor,
listener)
DeviceConfig.setProperty(
DeviceConfig.NAMESPACE_CONNECTIVITY,
key,
value,
false /* makeDefault */)
// Don't drop the permission until the config is applied, just in case
future.get(TIMEOUT_MS, TimeUnit.MILLISECONDS)
}.also {
Log.i(TAG, "Config \"$key\" successfully set to \"$value\"")
}
} cleanup {
DeviceConfig.removeOnPropertiesChangedListener(listener)
}
}
private val inlineExecutor get() = Executor { r -> r.run() }
/**
* Add an action to be run after config cleanup when the current test case ends.
*/
fun runAfterNextCleanup(action: ThrowingRunnable) {
currentTestCleanupActions.add(action)
}
}