totally overhaul choosing locales from app metadata based on LocaleList

This makes the selection logic heed the list of preferred locales from the
user Settings.

closes #987
closes #1186
refs #1440 #1882 #1730
!886
This commit is contained in:
Hans-Christoph Steiner 2021-02-05 16:36:47 +01:00
parent fbbf78dcf8
commit e35335d59c
7 changed files with 585 additions and 234 deletions

View File

@ -67,6 +67,7 @@ import org.apache.commons.net.util.SubnetUtils;
import org.fdroid.fdroid.Preferences.ChangeListener; import org.fdroid.fdroid.Preferences.ChangeListener;
import org.fdroid.fdroid.Preferences.Theme; import org.fdroid.fdroid.Preferences.Theme;
import org.fdroid.fdroid.compat.PRNGFixes; import org.fdroid.fdroid.compat.PRNGFixes;
import org.fdroid.fdroid.data.App;
import org.fdroid.fdroid.data.AppProvider; import org.fdroid.fdroid.data.AppProvider;
import org.fdroid.fdroid.data.InstalledAppProviderService; import org.fdroid.fdroid.data.InstalledAppProviderService;
import org.fdroid.fdroid.data.Repo; import org.fdroid.fdroid.data.Repo;
@ -330,6 +331,7 @@ public class FDroidApp extends Application {
public void onConfigurationChanged(Configuration newConfig) { public void onConfigurationChanged(Configuration newConfig) {
super.onConfigurationChanged(newConfig); super.onConfigurationChanged(newConfig);
Languages.setLanguage(this); Languages.setLanguage(this);
App.systemLocaleList = null;
// update the descriptions based on the new language preferences // update the descriptions based on the new language preferences
SharedPreferences atStartTime = getAtStartTimeSharedPreferences(); SharedPreferences atStartTime = getAtStartTimeSharedPreferences();

View File

@ -10,7 +10,6 @@ import android.content.res.AssetManager;
import android.content.res.Resources; import android.content.res.Resources;
import android.content.res.XmlResourceParser; import android.content.res.XmlResourceParser;
import android.database.Cursor; import android.database.Cursor;
import android.os.Build;
import android.os.Environment; import android.os.Environment;
import android.os.LocaleList; import android.os.LocaleList;
import android.os.Parcel; import android.os.Parcel;
@ -19,6 +18,8 @@ import android.text.TextUtils;
import android.util.Log; import android.util.Log;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.core.os.ConfigurationCompat;
import androidx.core.os.LocaleListCompat;
import com.fasterxml.jackson.annotation.JacksonInject; import com.fasterxml.jackson.annotation.JacksonInject;
import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonProperty;
@ -41,8 +42,8 @@ import java.util.Arrays;
import java.util.Collections; import java.util.Collections;
import java.util.Date; import java.util.Date;
import java.util.Enumeration; import java.util.Enumeration;
import java.util.HashMap;
import java.util.HashSet; import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Map; import java.util.Map;
@ -75,6 +76,14 @@ public class App extends ValueObject implements Comparable<App>, Parcelable {
@JsonIgnore @JsonIgnore
private static final String TAG = "App"; private static final String TAG = "App";
/**
* {@link LocaleListCompat} for finding the right app description material.
* It is set globally static to a) cache this value, since there are thousands
* of {@link App} entries, and b) make it easy to test {@link #setLocalized(Map)} )}
*/
@JsonIgnore
public static LocaleListCompat systemLocaleList;
// these properties are not from the index metadata, but represent the state on the device // these properties are not from the index metadata, but represent the state on the device
/** /**
* True if compatible with the device (i.e. if at least one apk is) * True if compatible with the device (i.e. if at least one apk is)
@ -98,8 +107,12 @@ public class App extends ValueObject implements Comparable<App>, Parcelable {
public String preferredSigner; public String preferredSigner;
@JsonIgnore @JsonIgnore
public boolean isApk; public boolean isApk;
/**
* Has this {@code App} been localized into one of the user's current locales.
*/
@JsonIgnore @JsonIgnore
boolean isLocalized = false; boolean isLocalized;
/** /**
* This is primarily for the purpose of saving app metadata when parsing an index.xml file. * This is primarily for the purpose of saving app metadata when parsing an index.xml file.
@ -484,110 +497,25 @@ public class App extends ValueObject implements Comparable<App>, Parcelable {
* {@code localized} block is included in the index. Also, null strings in * {@code localized} block is included in the index. Also, null strings in
* the {@code localized} block should not overwrite Name/Summary/Description * the {@code localized} block should not overwrite Name/Summary/Description
* strings with empty/null if they were set directly by Jackson. * strings with empty/null if they were set directly by Jackson.
* <p> * <ol>
* Choosing the locale to use follows two sets of rules, one for Android versions
* older than {@code android-24} and the other for {@code android-24} or newer.
* The system-wide language preference list was added in {@code android-24}.
* <ul>
* <li>{@code >= android-24}<ol>
* <li>the country variant {@code de-AT} from the user locale list * <li>the country variant {@code de-AT} from the user locale list
* <li>only the language {@code de} from the above locale * <li>only the language {@code de} from the above locale
* <li>next locale in the user's preference list ({@code >= android-24})
* <li>{@code en-US} since its the most common English for software * <li>{@code en-US} since its the most common English for software
* <li>the first available {@code en} locale * <li>the first available {@code en} locale
* </ol></li> * </ol>
* <li>{@code < android-24}<ol>
* <li>the country variant from the user locale: {@code de-AT}
* <li>only the language from the above locale: {@code de}
* <li>all available locales with the same language: {@code de-BE}
* <li>{@code en-US} since its the most common English for software
* <li>all available {@code en} locales
* </ol></li>
* </ul>
* On {@code >= android-24}, it is by design that this does not fallback to other
* country-specific locales, e.g. {@code fr-CH} does not fall back on {@code fr-FR}.
* If someone wants to fallback to {@code fr-FR}, they can add it to the system
* language list. There are many cases where it is inappropriate to fallback to a
* different country-specific locale, for example {@code de-DE --> de-CH} or
* {@code zh-CN --> zh-TW}.
* <p> * <p>
* On {@code < android-24}, the user can only set a single * The system-wide language preference list was added in {@code android-24}.
* locale with a country as an option, so here it makes sense to try to fallback *
* on other country-specific locales, rather than English. * @see <a href="https://developer.android.com/guide/topics/resources/multilingual-support">Android language and locale resolution overview</a>
*/ */
@JsonProperty("localized") @JsonProperty("localized")
void setLocalized(Map<String, Map<String, Object>> localized) { // NOPMD void setLocalized(Map<String, Map<String, Object>> localized) { // NOPMD
Locale defaultLocale = Locale.getDefault(); if (systemLocaleList == null) {
String languageTag = defaultLocale.getLanguage(); systemLocaleList = ConfigurationCompat.getLocales(Resources.getSystem().getConfiguration());
String countryTag = defaultLocale.getCountry();
String localeTag;
if (TextUtils.isEmpty(countryTag)) {
localeTag = languageTag;
} else {
localeTag = languageTag + "-" + countryTag;
} }
Set<String> localesToUse = localized.keySet();
Set<String> availableLocales = localized.keySet(); setIsLocalized(localesToUse);
Set<String> localesToUse = new LinkedHashSet<>();
if (availableLocales.contains(localeTag)) {
localesToUse.add(localeTag);
}
if (availableLocales.contains(languageTag)) {
localesToUse.add(languageTag);
}
if (localesToUse.isEmpty()) {
// In case of non-standard region like [en-SE]
for (String availableLocale : availableLocales) {
String availableLanguage = availableLocale.split("-")[0];
if (languageTag.equals(availableLanguage)) {
localesToUse.add(availableLocale);
}
}
}
if (Build.VERSION.SDK_INT >= 24) {
LocaleList localeList = Resources.getSystem().getConfiguration().getLocales();
String[] sortedLocaleList = localeList.toLanguageTags().split(",");
Arrays.sort(sortedLocaleList, new java.util.Comparator<String>() {
@Override
public int compare(String s1, String s2) {
return s1.length() - s2.length();
}
});
for (String toUse : sortedLocaleList) {
localesToUse.add(toUse);
for (String l : availableLocales) {
if (l.equals(toUse.split("-")[0])) {
localesToUse.add(l);
break;
}
}
}
} else {
for (String l : availableLocales) {
if (l.startsWith(languageTag)) {
localesToUse.add(l);
}
}
}
if (availableLocales.contains("en-US")) {
localesToUse.add("en-US");
}
for (String l : availableLocales) {
if (l.startsWith("en")) {
localesToUse.add(l);
break;
}
}
for (String l : localesToUse) {
if (l.startsWith(languageTag)) {
isLocalized = true;
break;
}
}
if (localesToUse.size() > 1) {
isLocalized = true;
}
String value = getLocalizedEntry(localized, localesToUse, "whatsNew"); String value = getLocalizedEntry(localized, localesToUse, "whatsNew");
if (!TextUtils.isEmpty(value)) { if (!TextUtils.isEmpty(value)) {
whatsNew = value; whatsNew = value;
@ -625,73 +553,148 @@ public class App extends ValueObject implements Comparable<App>, Parcelable {
} }
/** /**
* Returns the right localized version of this entry, based on an immitation of * Sets the boolean flag {@link #isLocalized} if this app entry has an localized
* the logic that Android/Java uses. On Android >= 24, this can get the * entry in one of the user's current locales.
* "Language Priority List", but it doesn't always seem to be properly sorted.
* So this method has to kind of fake it by using {@link Locale#getDefault()}
* as the first entry, then sorting the rest based on length (e.g. {@code de-AT}
* before {@code de}).
* *
* @see LocaleList * @see org.fdroid.fdroid.views.main.WhatsNewViewBinder#onCreateLoader(int, android.os.Bundle)
* @see Locale#getDefault()
* @see java.util.Locale.LanguageRange
*/ */
private String getLocalizedEntry(Map<String, Map<String, Object>> localized, private void setIsLocalized(Set<String> supportedLocales) {
Set<String> locales, String key) { isLocalized = false;
try { for (int i = 0; i < systemLocaleList.size(); i++) {
for (String locale : locales) { String language = systemLocaleList.get(i).getLanguage();
if (localized.containsKey(locale)) { for (String supportedLocale : supportedLocales) {
String value = (String) localized.get(locale).get(key); if (language.equals(supportedLocale.split("-")[0])) {
if (value != null) { isLocalized = true;
return value; return;
}
} }
} }
} catch (ClassCastException e) { }
Utils.debugLog(TAG, e.getMessage()); }
/**
* Returns the right localized version of this entry, based on an imitation of
* the logic that Android uses.
*
* @see LocaleList
*/
private String getLocalizedEntry(Map<String, Map<String, Object>> localized,
Set<String> locales, @NonNull String key) {
Map<String, Object> localizedLocaleMap = getLocalizedLocaleMap(localized, locales, key);
if (localizedLocaleMap != null && !localizedLocaleMap.isEmpty()) {
for (Object entry : localizedLocaleMap.values()) {
return (String) entry; // NOPMD
}
} }
return null; return null;
} }
private String getLocalizedGraphicsEntry(Map<String, Map<String, Object>> localized, private String getLocalizedGraphicsEntry(Map<String, Map<String, Object>> localized,
Set<String> locales, String key) { Set<String> locales, String key) {
try { Map<String, Object> localizedLocaleMap = getLocalizedLocaleMap(localized, locales, key);
for (String locale : locales) { if (localizedLocaleMap != null && !localizedLocaleMap.isEmpty()) {
Map<String, Object> entry = localized.get(locale); for (String locale : localizedLocaleMap.keySet()) {
if (entry != null) { return locale + "/" + localizedLocaleMap.get(locale); // NOPMD
Object value = entry.get(key);
if (value != null && value.toString().length() > 0) {
return locale + "/" + value;
}
}
} }
} catch (ClassCastException e) {
Utils.debugLog(TAG, e.getMessage());
} }
return null; return null;
} }
private String[] getLocalizedListEntry(Map<String, Map<String, Object>> localized, private String[] getLocalizedListEntry(Map<String, Map<String, Object>> localized,
Set<String> locales, String key) { Set<String> locales, String key) {
try { Map<String, Object> localizedLocaleMap = getLocalizedLocaleMap(localized, locales, key);
for (String locale : locales) { if (localizedLocaleMap != null && !localizedLocaleMap.isEmpty()) {
if (localized.containsKey(locale)) { for (String locale : localizedLocaleMap.keySet()) {
ArrayList<String> entry = (ArrayList<String>) localized.get(locale).get(key); ArrayList<String> entry = (ArrayList<String>) localizedLocaleMap.get(locale);
if (entry != null && entry.size() > 0) { if (entry != null && entry.size() > 0) {
String[] result = new String[entry.size()]; String[] result = new String[entry.size()];
int i = 0; int i = 0;
for (String e : entry) { for (String e : entry) {
result[i] = locale + "/" + key + "/" + e; result[i] = locale + "/" + key + "/" + e;
i++; i++;
}
return result;
}
}
}
return new String[0];
}
/**
* Return one matching entry from the {@code localized} block in the app entry
* in the index JSON.
*/
private Map<String, Object> getLocalizedLocaleMap(Map<String, Map<String, Object>> localized,
Set<String> locales, String key) {
String[] localesToUse = getLocalesForKey(localized, locales, key);
if (localesToUse.length > 0) {
Locale firstMatch = systemLocaleList.getFirstMatch(localesToUse);
if (firstMatch != null) {
for (String languageTag : new String[]{firstMatch.toLanguageTag(), null}) {
if (languageTag == null) {
languageTag = getFallbackLanguageTag(firstMatch, localesToUse); // NOPMD
}
Map<String, Object> localeEntry = localized.get(languageTag);
if (localeEntry != null && localeEntry.containsKey(key)) {
Object value = localeEntry.get(key);
if (value != null) {
Map<String, Object> localizedLocaleMap = new HashMap<>();
localizedLocaleMap.put(languageTag, value);
return localizedLocaleMap;
} }
return result;
} }
} }
} }
} catch (ClassCastException e) {
Utils.debugLog(TAG, e.getMessage());
} }
return new String[0]; return null;
}
/**
* Get all locales that have an entry for {@code key}.
*/
private String[] getLocalesForKey(Map<String, Map<String, Object>> localized,
Set<String> locales, String key) {
Set<String> localesToUse = new HashSet<>();
for (String locale : locales) {
Map<String, Object> localeEntry = localized.get(locale);
if (localeEntry != null && localeEntry.get(key) != null) {
localesToUse.add(locale);
}
}
return localesToUse.toArray(new String[0]);
}
/**
* Look for the first language-country match for languages with multiple scripts.
* Then look for a language-only match, for when there is no exact
* {@link Locale} match. Then try a locale with the same language, but
* different country. If there are still no matches, return the {@code en-US}
* entry. If all else fails, try to return the first existing English locale.
*/
private String getFallbackLanguageTag(Locale firstMatch, String[] localesToUse) {
final String firstMatchLanguageCountry = firstMatch.getLanguage() + "-" + firstMatch.getCountry();
for (String languageTag : localesToUse) {
if (languageTag.equals(firstMatchLanguageCountry)) {
return languageTag;
}
}
final String firstMatchLanguage = firstMatch.getLanguage();
String englishLastResort = null;
for (String languageTag : localesToUse) {
if (languageTag.equals(firstMatchLanguage)) {
return languageTag;
} else if ("en-US".equals(languageTag)) {
englishLastResort = languageTag;
}
}
for (String languageTag : localesToUse) {
String languageToUse = languageTag.split("-")[0];
if (firstMatchLanguage.equals(languageToUse)) {
return languageTag;
} else if (englishLastResort == null && "en".equals(languageToUse)) {
englishLastResort = languageTag;
}
}
return englishLastResort;
} }
/** /**

View File

@ -209,6 +209,13 @@ public interface Schema {
String TV_SCREENSHOTS = "tvScreenshots"; String TV_SCREENSHOTS = "tvScreenshots";
String WEAR_SCREENSHOTS = "wearScreenshots"; String WEAR_SCREENSHOTS = "wearScreenshots";
String IS_APK = "isApk"; String IS_APK = "isApk";
/**
* Has this {@code App} been localized into one of the user's current locales.
*
* @see App#setIsLocalized(java.util.Set)
* @see org.fdroid.fdroid.views.main.WhatsNewViewBinder#onCreateLoader(int, android.os.Bundle)
*/
String IS_LOCALIZED = "isLocalized"; String IS_LOCALIZED = "isLocalized";
interface AutoInstallApk { interface AutoInstallApk {

View File

@ -6,6 +6,7 @@ import android.content.ContentValues;
import android.content.Context; import android.content.Context;
import android.database.Cursor; import android.database.Cursor;
import android.net.Uri; import android.net.Uri;
import androidx.core.os.LocaleListCompat;
import org.fdroid.fdroid.Preferences; import org.fdroid.fdroid.Preferences;
import org.fdroid.fdroid.TestUtils; import org.fdroid.fdroid.TestUtils;
import org.fdroid.fdroid.data.Schema.AppMetadataTable.Cols; import org.fdroid.fdroid.data.Schema.AppMetadataTable.Cols;
@ -303,7 +304,7 @@ public class AppProviderTest extends FDroidProviderTest {
localized.put("es", es); localized.put("es", es);
localized.put("fr", fr); localized.put("fr", fr);
Locale.setDefault(new Locale("nl", "NL")); App.systemLocaleList = LocaleListCompat.forLanguageTags("nl-NL");
app.setLocalized(localized); app.setLocalized(localized);
assertFalse(app.isLocalized); assertFalse(app.isLocalized);
@ -324,7 +325,7 @@ public class AppProviderTest extends FDroidProviderTest {
app.setLocalized(localized); app.setLocalized(localized);
assertFalse(app.isLocalized); assertFalse(app.isLocalized);
Locale.setDefault(new Locale("en", "US")); App.systemLocaleList = LocaleListCompat.forLanguageTags("en-US");
app = new App(); app = new App();
localized.clear(); localized.clear();
localized.put("en-US", en); localized.put("en-US", en);

View File

@ -1,23 +1,27 @@
package org.fdroid.fdroid.data; package org.fdroid.fdroid.data;
import android.content.res.Configuration;
import android.os.Build; import android.os.Build;
import android.os.LocaleList; import androidx.core.os.LocaleListCompat;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.commons.io.FileUtils;
import org.fdroid.fdroid.TestUtils; import org.fdroid.fdroid.TestUtils;
import org.junit.Assume;
import org.junit.Before;
import org.junit.Test; import org.junit.Test;
import org.junit.runner.RunWith; import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner; import org.robolectric.RobolectricTestRunner;
import org.robolectric.shadows.ShadowLog;
import java.io.File;
import java.io.IOException;
import java.util.HashMap; import java.util.HashMap;
import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Map; import java.util.Map;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.when;
@RunWith(RobolectricTestRunner.class) @RunWith(RobolectricTestRunner.class)
@SuppressWarnings("LocalVariableName") @SuppressWarnings("LocalVariableName")
@ -25,83 +29,25 @@ public class LocaleSelectionTest {
private static final String KEY = "summary"; private static final String KEY = "summary";
@Test private static final String EN_US_NAME = "Checkey: info on local apps\n";
public void correctLocaleSelectionBeforeSDK24() throws Exception { private static final String EN_US_FEATURE_GRAPHIC = "en-US/featureGraphic.png";
TestUtils.setFinalStatic(Build.VERSION.class.getDeclaredField("SDK_INT"), 19); private static final String EN_US_PHONE_SCREENSHOT = "en-US/phoneScreenshots/First.png";
assertTrue(Build.VERSION.SDK_INT < 24); private static final String EN_US_SEVEN_INCH_SCREENSHOT = "en-US/sevenInchScreenshots/checkey-tablet.png";
App app; private static final String FR_FR_NAME = "Checkey : infos applis locales";
private static final String FR_CA_FEATURE_GRAPHIC = "fr-CA/featureGraphic.png";
private static final String FR_FR_FEATURE_GRAPHIC = "fr-FR/featureGraphic.png";
private static final String FR_FR_SEVEN_INCH_SCREENSHOT = "fr-FR/sevenInchScreenshots/checkey-tablet.png";
Map<String, Map<String, Object>> localized = new HashMap<>(); @Before
HashMap<String, Object> en_US = new HashMap<>(); public final void setUp() {
en_US.put(KEY, "summary-en_US"); ShadowLog.stream = System.out;
HashMap<String, Object> de_AT = new HashMap<>();
de_AT.put(KEY, "summary-de_AT");
HashMap<String, Object> de_DE = new HashMap<>();
de_DE.put(KEY, "summary-de_DE");
HashMap<String, Object> sv = new HashMap<>();
sv.put(KEY, "summary-sv");
HashMap<String, Object> sv_FI = new HashMap<>();
sv_FI.put(KEY, "summary-sv_FI");
localized.put("de-AT", de_AT);
localized.put("de-DE", de_DE);
localized.put("en-US", en_US);
localized.put("sv", sv);
localized.put("sv-FI", sv_FI);
// Easy mode. en-US metadata with an en-US locale
Locale.setDefault(new Locale("en", "US"));
app = new App();
app.setLocalized(localized);
assertEquals(en_US.get(KEY), app.summary);
// Fall back to en-US locale, when we have a different en locale
Locale.setDefault(new Locale("en", "UK"));
app = new App();
app.setLocalized(localized);
assertEquals(en_US.get(KEY), app.summary);
// Fall back to language only
Locale.setDefault(new Locale("en", "UK"));
app = new App();
app.setLocalized(localized);
assertEquals(en_US.get(KEY), app.summary);
// select the correct one out of multiple language locales
Locale.setDefault(new Locale("de", "DE"));
app = new App();
app.setLocalized(localized);
assertEquals(de_DE.get(KEY), app.summary);
// Even when we have a non-exact matching locale, we should fall back to the same language
Locale.setDefault(new Locale("de", "CH"));
app = new App();
app.setLocalized(localized);
assertEquals(de_AT.get(KEY), app.summary);
// Test fallback to base lang with not exact matching locale
Locale.setDefault(new Locale("sv", "SE"));
app = new App();
app.setLocalized(localized);
assertEquals(sv.get(KEY), app.summary);
} }
@Test @Test
public void correctLocaleSelectionFromSDK24() throws Exception { public void localeSelection() throws Exception {
TestUtils.setFinalStatic(Build.VERSION.class.getDeclaredField("SDK_INT"), 29); App app = new App();
assertTrue(Build.VERSION.SDK_INT >= 24); App.systemLocaleList = LocaleListCompat.forLanguageTags("en-US,de-DE");
App app = spy(new App());
// we mock both the getLocales call and the conversion to a language tag string.
Configuration configuration = mock(Configuration.class);
LocaleList localeList = mock(LocaleList.class);
doReturn(localeList).when(configuration).getLocales();
// Set both default locale as well as the locale list, because the algorithm uses both...
Locale.setDefault(new Locale("en", "US"));
when(localeList.toLanguageTags()).thenReturn("en-US,de-DE");
//no metadata present //no metadata present
Map<String, Map<String, Object>> localized = new HashMap<>(); Map<String, Map<String, Object>> localized = new HashMap<>();
@ -112,10 +58,18 @@ public class LocaleSelectionTest {
en_US.put(KEY, "summary-en_US"); en_US.put(KEY, "summary-en_US");
HashMap<String, Object> en_GB = new HashMap<>(); HashMap<String, Object> en_GB = new HashMap<>();
en_GB.put(KEY, "summary-en_GB"); en_GB.put(KEY, "summary-en_GB");
HashMap<String, Object> de = new HashMap<>();
de.put(KEY, "summary-de");
HashMap<String, Object> de_AT = new HashMap<>(); HashMap<String, Object> de_AT = new HashMap<>();
de_AT.put(KEY, "summary-de_AT"); de_AT.put(KEY, "summary-de_AT");
HashMap<String, Object> de_DE = new HashMap<>(); HashMap<String, Object> de_DE = new HashMap<>();
de_DE.put(KEY, "summary-de_DE"); de_DE.put(KEY, "summary-de_DE");
HashMap<String, Object> es_ES = new HashMap<>();
es_ES.put(KEY, "summary-es_ES");
HashMap<String, Object> fr_FR = new HashMap<>();
fr_FR.put(KEY, "summary-fr_FR");
HashMap<String, Object> it_IT = new HashMap<>();
it_IT.put(KEY, "summary-it_IT");
app.summary = "reset"; app.summary = "reset";
localized.put("de-AT", de_AT); localized.put("de-AT", de_AT);
@ -125,8 +79,7 @@ public class LocaleSelectionTest {
// just select the matching en-US locale, nothing special here // just select the matching en-US locale, nothing special here
assertEquals(en_US.get(KEY), app.summary); assertEquals(en_US.get(KEY), app.summary);
Locale.setDefault(new Locale("en", "SE")); App.systemLocaleList = LocaleListCompat.forLanguageTags("en-SE,de-DE");
when(localeList.toLanguageTags()).thenReturn("en-SE,de-DE");
app.setLocalized(localized); app.setLocalized(localized);
// Fall back to another en locale before de // Fall back to another en locale before de
assertEquals(en_US.get(KEY), app.summary); assertEquals(en_US.get(KEY), app.summary);
@ -138,8 +91,7 @@ public class LocaleSelectionTest {
localized.put("en-GB", en_GB); localized.put("en-GB", en_GB);
localized.put("en-US", en_US); localized.put("en-US", en_US);
Locale.setDefault(new Locale("de", "AT")); App.systemLocaleList = LocaleListCompat.forLanguageTags("de-AT,de-DE");
when(localeList.toLanguageTags()).thenReturn("de-AT,de-DE");
app.setLocalized(localized); app.setLocalized(localized);
// full match against a non-default locale // full match against a non-default locale
assertEquals(de_AT.get(KEY), app.summary); assertEquals(de_AT.get(KEY), app.summary);
@ -147,14 +99,13 @@ public class LocaleSelectionTest {
app.summary = "reset"; app.summary = "reset";
localized.clear(); localized.clear();
localized.put("de-AT", de_AT); localized.put("de-AT", de_AT);
localized.put("de", de_DE); localized.put("de", de);
localized.put("en-GB", en_GB); localized.put("en-GB", en_GB);
localized.put("en-US", en_US); localized.put("en-US", en_US);
Locale.setDefault(new Locale("de", "CH")); App.systemLocaleList = LocaleListCompat.forLanguageTags("de-CH,en-US");
when(localeList.toLanguageTags()).thenReturn("de-CH,en-US");
app.setLocalized(localized); app.setLocalized(localized);
assertEquals(de_DE.get(KEY), app.summary); assertEquals(de.get(KEY), app.summary);
app.summary = "reset"; app.summary = "reset";
localized.clear(); localized.clear();
@ -162,13 +113,12 @@ public class LocaleSelectionTest {
localized.put("en-US", en_US); localized.put("en-US", en_US);
Locale.setDefault(new Locale("en", "AU")); Locale.setDefault(new Locale("en", "AU"));
when(localeList.toLanguageTags()).thenReturn("en-AU"); App.systemLocaleList = LocaleListCompat.forLanguageTags("en-AU");
app.setLocalized(localized); app.setLocalized(localized);
assertEquals(en_US.get(KEY), app.summary); assertEquals(en_US.get(KEY), app.summary);
app.summary = "reset"; app.summary = "reset";
Locale.setDefault(new Locale("zh", "TW", "#Hant")); App.systemLocaleList = LocaleListCompat.forLanguageTags("zh-Hant-TW,zh-Hans-CN");
when(localeList.toLanguageTags()).thenReturn("zh-Hant-TW,zh-Hans-CN");
localized.clear(); localized.clear();
localized.put("en", en_GB); localized.put("en", en_GB);
localized.put("en-US", en_US); localized.put("en-US", en_US);
@ -197,5 +147,107 @@ public class LocaleSelectionTest {
localized.put("zh-CN", zh_CN); localized.put("zh-CN", zh_CN);
app.setLocalized(localized); app.setLocalized(localized);
assertEquals(zh_CN.get(KEY), app.summary); assertEquals(zh_CN.get(KEY), app.summary);
localized.clear();
localized.put("en-US", en_US);
localized.put("zh-CN", zh_CN);
app.setLocalized(localized);
assertEquals(zh_CN.get(KEY), app.summary);
// https://developer.android.com/guide/topics/resources/multilingual-support#resource-resolution-examples
App.systemLocaleList = LocaleListCompat.forLanguageTags("fr-CH");
localized.clear();
localized.put("en-US", en_US);
localized.put("de-DE", de_DE);
localized.put("es-ES", es_ES);
localized.put("fr-FR", fr_FR);
localized.put("it-IT", it_IT);
app.setLocalized(localized);
assertEquals(fr_FR.get(KEY), app.summary);
// https://developer.android.com/guide/topics/resources/multilingual-support#t-2d-choice
App.systemLocaleList = LocaleListCompat.forLanguageTags("fr-CH,it-CH");
localized.clear();
localized.put("en-US", en_US);
localized.put("de-DE", de_DE);
localized.put("es-ES", es_ES);
localized.put("it-IT", it_IT);
app.setLocalized(localized);
assertEquals(it_IT.get(KEY), app.summary);
}
@Test
public void testSetLocalized() throws IOException {
Assume.assumeTrue(Build.VERSION.SDK_INT >= 24);
File f = TestUtils.copyResourceToTempFile("localized.json");
Map<String, Object> result = new ObjectMapper().readValue(
FileUtils.readFileToString(f, (String) null), HashMap.class);
List<Map<String, Object>> apps = (List<Map<String, Object>>) result.get("apps");
Map<String, Map<String, Object>> localized = (Map<String, Map<String, Object>>) apps.get(0).get("localized");
App app = new App();
App.systemLocaleList = LocaleListCompat.create(Locale.US);
app.setLocalized(localized);
assertEquals(EN_US_NAME, app.name);
assertEquals(EN_US_FEATURE_GRAPHIC, app.featureGraphic);
assertEquals(EN_US_PHONE_SCREENSHOT, app.phoneScreenshots[0]);
assertEquals(EN_US_SEVEN_INCH_SCREENSHOT, app.sevenInchScreenshots[0]);
assertTrue(app.isLocalized);
// choose the language when there is an exact locale match
App.systemLocaleList = LocaleListCompat.forLanguageTags("fr-FR");
app.setLocalized(localized);
assertEquals(FR_FR_NAME, app.name);
assertEquals(FR_FR_FEATURE_GRAPHIC, app.featureGraphic);
assertEquals(EN_US_PHONE_SCREENSHOT, app.phoneScreenshots[0]);
assertEquals(FR_FR_SEVEN_INCH_SCREENSHOT, app.sevenInchScreenshots[0]);
assertTrue(app.isLocalized);
// choose the language from a different country when the preferred country is not available,
// while still choosing featureGraphic from exact match
App.systemLocaleList = LocaleListCompat.create(Locale.CANADA_FRENCH);
app.setLocalized(localized);
assertEquals(FR_FR_NAME, app.name);
assertEquals(FR_CA_FEATURE_GRAPHIC, app.featureGraphic);
assertEquals(EN_US_PHONE_SCREENSHOT, app.phoneScreenshots[0]);
assertEquals(FR_FR_SEVEN_INCH_SCREENSHOT, app.sevenInchScreenshots[0]);
assertTrue(app.isLocalized);
// choose the third preferred language when first and second lack translations
App.systemLocaleList = LocaleListCompat.forLanguageTags("bo-IN,sr-RS,fr-FR");
app.setLocalized(localized);
assertEquals(FR_FR_NAME, app.name);
assertEquals(FR_FR_FEATURE_GRAPHIC, app.featureGraphic);
assertEquals(EN_US_PHONE_SCREENSHOT, app.phoneScreenshots[0]);
assertEquals(FR_FR_SEVEN_INCH_SCREENSHOT, app.sevenInchScreenshots[0]);
assertTrue(app.isLocalized);
// choose first language from different country, rather than 2nd full lang/country match
App.systemLocaleList = LocaleListCompat.forLanguageTags("en-GB,fr-FR");
app.setLocalized(localized);
assertEquals(EN_US_NAME, app.name);
assertEquals(EN_US_FEATURE_GRAPHIC, app.featureGraphic);
assertEquals(EN_US_PHONE_SCREENSHOT, app.phoneScreenshots[0]);
assertEquals(EN_US_SEVEN_INCH_SCREENSHOT, app.sevenInchScreenshots[0]);
assertTrue(app.isLocalized);
// choose en_US when no match, and mark as not localized
App.systemLocaleList = LocaleListCompat.forLanguageTags("bo-IN,sr-RS");
app.setLocalized(localized);
assertEquals(EN_US_NAME, app.name);
assertEquals(EN_US_FEATURE_GRAPHIC, app.featureGraphic);
assertEquals(EN_US_PHONE_SCREENSHOT, app.phoneScreenshots[0]);
assertEquals(EN_US_SEVEN_INCH_SCREENSHOT, app.sevenInchScreenshots[0]);
assertFalse(app.isLocalized);
// When English is the preferred language and the second language has no entries
App.systemLocaleList = LocaleListCompat.forLanguageTags("en-US,sr-RS");
app.setLocalized(localized);
assertEquals(EN_US_NAME, app.name);
assertEquals(EN_US_FEATURE_GRAPHIC, app.featureGraphic);
assertEquals(EN_US_PHONE_SCREENSHOT, app.phoneScreenshots[0]);
assertEquals(EN_US_SEVEN_INCH_SCREENSHOT, app.sevenInchScreenshots[0]);
assertTrue(app.isLocalized);
} }
} }

View File

@ -370,6 +370,7 @@ public class IndexV1UpdaterTest extends FDroidProviderTest {
"isLocalized", "isLocalized",
"preferredSigner", "preferredSigner",
"prefs", "prefs",
"systemLocaleList",
"TAG", "TAG",
}; };
runJsonIgnoreTest(new App(), allowedInApp, ignoredInApp); runJsonIgnoreTest(new App(), allowedInApp, ignoredInApp);

File diff suppressed because one or more lines are too long