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 
closes 
refs   
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
app/src
main/java/org/fdroid/fdroid
test

@ -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();

@ -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;
} }
/** /**

@ -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 {

@ -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);

@ -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);
} }
} }

@ -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