1 /*
<lambda>null2  * Copyright 2019 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 androidx.testutils
18 
19 import android.content.Context
20 import android.content.res.Configuration
21 import android.content.res.Resources
22 import android.os.Build
23 import android.os.LocaleList
24 import androidx.core.os.ConfigurationCompat
25 import androidx.core.os.LocaleListCompat
26 import java.util.Locale
27 
28 /**
29  * Utility class to save and restore the locale of the system.
30  *
31  * Inspired by
32  * [com.android.dialer.util.LocaleTestUtils](https://android.googlesource.com/platform/packages/apps/Dialer/+/94b10b530c0fc297e2974e57e094c500d3ee6003/tests/src/com/android/dialer/util/LocaleTestUtils.java)
33  *
34  * This can be used for tests that assume to be run in a certain locale, e.g., because they check
35  * against strings in a particular language or require an assumption on how the system will behave
36  * in a specific locale.
37  *
38  * In your test, you can change the locale with the following code:
39  * <pre>
40  * public class CanadaFrenchTest extends AndroidTestCase {
41  *     private LocaleTestUtils mLocaleTestUtils;
42  *
43  *     &#64;Override
44  *     public void setUp() throws Exception {
45  *         super.setUp();
46  *         mLocaleTestUtils = new LocaleTestUtils(getContext());
47  *         mLocaleTestUtils.setLocale(Locale.CANADA_FRENCH);
48  *     }
49  *
50  *     &#64;Override
51  *     public void tearDown() throws Exception {
52  *         mLocaleTestUtils.resetLocale();
53  *         mLocaleTestUtils = null;
54  *         super.tearDown();
55  *     }
56  *
57  *     ...
58  * }
59  * </pre>
60  * Note that one should not call [setLocale] more than once without calling [resetLocale] first.
61  *
62  * This class is not thread-safe. Usually its methods should be invoked only from the test thread.
63  *
64  * @constructor Create a new instance that can be used to set and reset the locale for the given
65  *   context.
66  * @property mContext the context on which to alter the locale
67  */
68 class LocaleTestUtils(private val mContext: Context) {
69     companion object {
70         const val DEFAULT_TEST_LANGUAGE = "en_US"
71         const val RTL_LANGUAGE = "ar_SA"
72         const val LTR_LANGUAGE = "fr_FR"
73     }
74 
75     private var saved: Boolean = false
76     private var savedContextLocale: LocaleListCompat? = null
77     private var savedSystemLocale: LocaleListCompat? = null
78     private var locale: Locale? = null
79     private var canSave: Boolean = true
80 
81     /**
82      * Set the locale to the given value and saves the previous value.
83      *
84      * @param lang the language to which the locale should be set, in the same format as
85      *   Locale.toString()
86      * @throws IllegalStateException if the locale was already set
87      */
88     fun setLocale(lang: String) {
89         synchronized(this) {
90             val locale = findLocale(lang)
91             if (saved) {
92                 throw IllegalStateException("call restoreLocale() before calling setLocale() again")
93             }
94             if (!canSave) {
95                 throw IllegalStateException(
96                     "can't set locale after isLocaleChangedAndLock() is " + "called"
97                 )
98             }
99             this.locale = locale
100             val locales = LocaleListCompat.create(locale)
101             savedContextLocale = updateResources(mContext.resources, locales)
102             savedSystemLocale = updateResources(Resources.getSystem(), locales)
103             saved = true
104             canSave = false
105         }
106     }
107 
108     /**
109      * Restores the original locale, if it was changed, and unlocks the ability to change the locale
110      * for this object, if it was locked by [isLocaleChangedAndLock]. If the locale wasn't changed,
111      * it leaves the locales untouched, but will still unlock this object if it was locked.
112      */
113     fun resetLocale() {
114         synchronized(this) {
115             canSave = true
116             if (!saved) {
117                 return
118             }
119             updateResources(mContext.resources, savedContextLocale!!)
120             updateResources(Resources.getSystem(), savedSystemLocale!!)
121             saved = false
122         }
123     }
124 
125     /**
126      * Gets the Locale to which the locale has been changed, or null if the locale hasn't been
127      * changed.
128      */
129     fun getLocale(): Locale? {
130         synchronized(this) {
131             return locale
132         }
133     }
134 
135     /** Returns if the locale has been changed. */
136     fun isLocaleChanged(): Boolean {
137         synchronized(this) {
138             return saved
139         }
140     }
141 
142     /**
143      * Returns if the locale has been changed, and disables all future locale changes until
144      * [resetLocale] has been called. Calling [setLocale] after calling this method will throw an
145      * exception.
146      *
147      * Use this check-and-lock if the behavior of a component depends on whether or not the locale
148      * has been changed, and it only checks it when initializing the component. E.g., when starting
149      * a test Activity.
150      */
151     fun isLocaleChangedAndLock(): Boolean {
152         synchronized(this) {
153             canSave = false
154             return saved
155         }
156     }
157 
158     /** Finds the best matching Locale on the system for the given language */
159     private fun findLocale(lang: String): Locale {
160         // Build list of prefixes ("ar_SA_xx_foo_bar" -> ["ar", "ar_SA", "ar_SA_xx", etc..])
161         val prefixes =
162             lang.split("_").fold(mutableListOf<String>()) { prefixes, elem ->
163                 prefixes.also { it.add(if (it.isEmpty()) elem else "${it.last()}_$elem") }
164             }
165 
166         // Build lists of matches per prefix
167         val matches = List<MutableList<Locale>>(prefixes.size) { mutableListOf() }
168         for (locale in Locale.getAvailableLocales()) {
169             val language = locale.toString()
170             prefixes.forEachIndexed { i, prefix ->
171                 if (language.startsWith(prefix)) {
172                     if (language == prefix) {
173                         // Exact matches are preferred, so put them in front
174                         matches[i].add(0, locale)
175                     } else if (matches[i].isEmpty()) {
176                         // We only need one inexact match per prefix
177                         matches[i].add(locale)
178                     }
179                 }
180             }
181         }
182 
183         // Find best match: the locale that has the longest common prefix with the given language
184         return matches.lastOrNull { it.isNotEmpty() }?.first() ?: Locale.getDefault()
185     }
186 
187     /**
188      * Sets the locale(s) for the given resources and returns the previous locales.
189      *
190      * @param resources the resources on which to set the locales
191      * @param locales the value(s) to which to set the locales
192      * @return the previous value of the locales for the resources
193      */
194     private fun updateResources(resources: Resources, locales: LocaleListCompat): LocaleListCompat {
195         val savedLocales = ConfigurationCompat.getLocales(resources.configuration)
196         val newConfig = Configuration(resources.configuration)
197         when {
198             Build.VERSION.SDK_INT >= Build.VERSION_CODES.N ->
199                 newConfig.setLocales(locales.unwrap() as LocaleList)
200             else -> newConfig.setLocale(locales.get(0))
201         }
202         @Suppress("DEPRECATION") resources.updateConfiguration(newConfig, resources.displayMetrics)
203         return savedLocales
204     }
205 }
206