• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
<lambda>null2  * Copyright (C) 2022 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.READ_DEVICE_CONFIG
20 import android.Manifest.permission.WRITE_DEVICE_CONFIG
21 import android.provider.DeviceConfig
22 import android.util.Log
23 import com.android.modules.utils.build.SdkLevel
24 import com.android.testutils.FunctionalUtils.ThrowingRunnable
25 import java.util.concurrent.CompletableFuture
26 import java.util.concurrent.Executor
27 import java.util.concurrent.TimeUnit
28 import org.junit.rules.TestRule
29 import org.junit.runner.Description
30 import org.junit.runners.model.Statement
31 
32 private val TAG = DeviceConfigRule::class.simpleName
33 
34 private const val TIMEOUT_MS = 20_000L
35 
36 /**
37  * A [TestRule] that helps set [DeviceConfig] for tests and clean up the test configuration
38  * automatically on teardown.
39  *
40  * The rule can also optionally retry tests when they fail following an external change of
41  * DeviceConfig before S; this typically happens because device config flags are synced while the
42  * test is running, and DisableConfigSyncTargetPreparer is only usable starting from S.
43  *
44  * @param retryCountBeforeSIfConfigChanged if > 0, when the test fails before S, check if
45  *        the configs that were set through this rule were changed, and retry the test
46  *        up to the specified number of times if yes.
47  */
48 class DeviceConfigRule @JvmOverloads constructor(
49     val retryCountBeforeSIfConfigChanged: Int = 0
50 ) : TestRule {
51     // Maps (namespace, key) -> value
52     private val originalConfig = mutableMapOf<Pair<String, String>, String?>()
53     private val usedConfig = mutableMapOf<Pair<String, String>, String?>()
54 
55     /**
56      * Actions to be run after cleanup of the config, for the current test only.
57      */
58     private val currentTestCleanupActions = mutableListOf<ThrowingRunnable>()
59 
60     override fun apply(base: Statement, description: Description): Statement {
61         return TestValidationUrlStatement(base, description)
62     }
63 
64     private inner class TestValidationUrlStatement(
65         private val base: Statement,
66         private val description: Description
67     ) : Statement() {
68         override fun evaluate() {
69             var retryCount = if (SdkLevel.isAtLeastS()) 1 else retryCountBeforeSIfConfigChanged + 1
70             while (retryCount > 0) {
71                 retryCount--
72                 tryTest {
73                     base.evaluate()
74                     // Can't use break/return out of a loop here because this is a tryTest lambda,
75                     // so set retryCount to exit instead
76                     retryCount = 0
77                 }.catch<Throwable> { e -> // junit AssertionFailedError does not extend Exception
78                     if (retryCount == 0) throw e
79                     usedConfig.forEach { (key, value) ->
80                         val currentValue = runAsShell(READ_DEVICE_CONFIG) {
81                             DeviceConfig.getProperty(key.first, key.second)
82                         }
83                         if (currentValue != value) {
84                             Log.w(TAG, "Test failed with unexpected device config change, retrying")
85                             return@catch
86                         }
87                     }
88                     throw e
89                 } cleanupStep {
90                     runAsShell(WRITE_DEVICE_CONFIG) {
91                         originalConfig.forEach { (key, value) ->
92                             DeviceConfig.setProperty(
93                                     key.first, key.second, value, false /* makeDefault */)
94                         }
95                     }
96                 } cleanupStep {
97                     originalConfig.clear()
98                     usedConfig.clear()
99                 } cleanup {
100                     // Fold all cleanup actions into cleanup steps of an empty tryTest, so they are
101                     // all run even if exceptions are thrown, and exceptions are reported properly.
102                     currentTestCleanupActions.fold(tryTest { }) {
103                         tryBlock, action -> tryBlock.cleanupStep { action.run() }
104                     }.cleanup {
105                         currentTestCleanupActions.clear()
106                     }
107                 }
108             }
109         }
110     }
111 
112     /**
113      * Set a configuration key/value. After the test case ends, it will be restored to the value it
114      * had when this method was first called.
115      */
116     fun setConfig(namespace: String, key: String, value: String?): String? {
117         Log.i(TAG, "Setting config \"$key\" to \"$value\"")
118         val readWritePermissions = arrayOf(READ_DEVICE_CONFIG, WRITE_DEVICE_CONFIG)
119 
120         val keyPair = Pair(namespace, key)
121         val existingValue = runAsShell(*readWritePermissions) {
122             DeviceConfig.getProperty(namespace, key)
123         }
124         if (!originalConfig.containsKey(keyPair)) {
125             originalConfig[keyPair] = existingValue
126         }
127         usedConfig[keyPair] = value
128         if (existingValue == value) {
129             // Already the correct value. There may be a race if a change is already in flight,
130             // but if multiple threads update the config there is no way to fix that anyway.
131             Log.i(TAG, "\"$key\" already had value \"$value\"")
132             return value
133         }
134 
135         val future = CompletableFuture<String>()
136         val listener = DeviceConfig.OnPropertiesChangedListener {
137             // The listener receives updates for any change to any key, so don't react to
138             // changes that do not affect the relevant key
139             if (!it.keyset.contains(key)) return@OnPropertiesChangedListener
140             // "null" means absent in DeviceConfig : there is no such thing as a present but
141             // null value, so the following works even if |value| is null.
142             if (it.getString(key, null) == value) {
143                 future.complete(value)
144             }
145         }
146 
147         return tryTest {
148             runAsShell(*readWritePermissions) {
149                 DeviceConfig.addOnPropertiesChangedListener(
150                         namespace,
151                         inlineExecutor,
152                         listener)
153                 DeviceConfig.setProperty(
154                         namespace,
155                         key,
156                         value,
157                         false /* makeDefault */)
158                 // Don't drop the permission until the config is applied, just in case
159                 future.get(TIMEOUT_MS, TimeUnit.MILLISECONDS)
160             }.also {
161                 Log.i(TAG, "Config \"$key\" successfully set to \"$value\"")
162             }
163         } cleanup {
164             DeviceConfig.removeOnPropertiesChangedListener(listener)
165         }
166     }
167 
168     private val inlineExecutor get() = Executor { r -> r.run() }
169 
170     /**
171      * Add an action to be run after config cleanup when the current test case ends.
172      */
173     fun runAfterNextCleanup(action: ThrowingRunnable) {
174         currentTestCleanupActions.add(action)
175     }
176 }
177