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 * @Override 44 * public void setUp() throws Exception { 45 * super.setUp(); 46 * mLocaleTestUtils = new LocaleTestUtils(getContext()); 47 * mLocaleTestUtils.setLocale(Locale.CANADA_FRENCH); 48 * } 49 * 50 * @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