Merge branch 'latest-tab-localized-overhaul' into 'master'

convert the Latest Tab SELECT logic to ORDER BY, with accurate IS_LOCALIZED

Closes #939, #2024, #1186, and #987

See merge request fdroid/fdroidclient!971
This commit is contained in:
Hans-Christoph Steiner 2021-02-08 11:02:17 +00:00
commit b9efb143be
10 changed files with 804 additions and 469 deletions

View File

@ -26,14 +26,16 @@ track of modifications and fuzzy translations. Applying translations manually
skips all of the fixes and checks, and overrides the fuzzy state of strings. skips all of the fixes and checks, and overrides the fuzzy state of strings.
Note that you cannot change the English strings on Weblate. If you have any Note that you cannot change the English strings on Weblate. If you have any
suggestions on how to improve them, open a merge request like you would if you suggestions on how to improve them, open an issue or merge request like you
were making code changes. This way the changes can be reviewed before the would if you were making code changes. This way the changes can be reviewed
source strings on Weblate are changed. before the source strings on Weblate are changed.
## Code Style ## Code Style
We follow the [Android Java style](https://source.android.com/source/code-style.html). We follow the default Android Studio code formatter (e.g. `Ctrl-Alt-L`). This
Some key points: should be more or less the same as [Android Java
style](https://source.android.com/source/code-style.html). Some key points:
* Four space indentation * Four space indentation
* UTF-8 source files * UTF-8 source files
@ -45,76 +47,35 @@ Some key points:
* Braces are always used after if, for and while * Braces are always used after if, for and while
The current code base doesn't follow it entirely, but new code should follow The current code base doesn't follow it entirely, but new code should follow
it. We enforce some of these, but not all, via checkstyle. it. We enforce some of these, but not all, via `./gradlew checkstyle`.
## Debugging
To get all the logcat messages by F-Droid, you can run:
adb logcat | grep `adb shell ps | grep org.fdroid.fdroid | cut -c10-15`
## Building tips
* Use gradle with `--daemon` if you are going to build F-Droid multiple times.
* If you get a message like `Could not find com.android.support:support-...`,
make sure that you have the latest Android support maven repository.
* When building as part of AOSP with `Android.mk`, make sure you have a
recent version of Gradle installed as `gradlew` will not be used.
## Running the test suite ## Running the test suite
Before pushing commits to a merge request, make sure this passes:
./gradlew checkstyle pmd lint
In order to run the F-Droid test suite, you will need to have either a real device In order to run the F-Droid test suite, you will need to have either a real device
connected via `adb`, or an emulator running. Then, execute the following from the connected via `adb`, or an emulator running. Then, execute the following from the
command line: command line:
./gradlew check ./gradlew check
Note that the CI already runs the tests on an emulator, so you don't Many important tests require a device or emulator, but do not work in GitLab CI.
necessarily have to do this yourself if you open a merge request as the tests That mean they need to be run locally, and that is usually easiest in Android
will get run. Studio rather than the command line.
### Running tests in Android Studio For a quick way to run a specific JUnit/Robolectric test:
Later versions of Android Studio require tests to be run with a "Working directory" ./gradlew testFullDebugUnitTest --tests *LocaleSelectionTest*
of `$MODULE_DIR$`.
[To make this the default behaviour](https://code.google.com/p/android/issues/detail?id=158015#c11),
close any projects to get the Welcome dialog. Then choose _Configure > Project Defaults >
Run Configurations > Defaults > Android JUnit_, and change "Working directory"
to `$MODULE_DIR$`. If you already have a project setup in Android Studio, you
may also need to change the default in _Run > Edit Configurations... > Defaults >
Android JUnit_.
## Versioning For a quick way to run a specific emulator test:
Each stable version follows the `X.Y` pattern. Hotfix releases - i.e. when a ./gradlew connectedFullDebugAndroidTest \
stable has an important bug that needs immediate fixing - will follow the -Pandroid.testInstrumentationRunnerArguments.class=org.fdroid.fdroid.MainActivityExpressoTest
`X.Y.Z` pattern.
Before each stable release, a number of alpha releases will be released. They
will follow the pattern `X.Y-alphaN`, where `N` is the current alpha number.
These will usually include changes and new features that have not been tested
enough for a stable release, so use at your own risk. Testers and reporters
are very welcome.
The version codes use a number of digits per each of these keys: `XXXYYYZNN`. ## Making releases
So for example, 1.3.1 would be `1003150` and 0.95-alpha13 would be `95013`
(leading zeros are omitted).
Note that we use a trailing `50` for actual stable releases, so alphas are See https://gitlab.com/fdroid/wiki/-/wikis/Internal/Release-Process#fdroidclient
limited to `-alpha49`.
This is an example of a release process for **0.95**:
* We are currently at stable **0.94**
* **0.95-alpha1** is released
* **0.95-alpha2** is released
* **0.95-alpha3** is released
* `stable-v0.95` is branched and frozen
* **0.95** is released
* A bug is reported on the stable release and fixed
* **0.95.1** is released with only that fix
As soon as a stable is tagged, master will move on to `-alpha0` on the next
version. This is a temporary measure - until `-alpha1` is released - so that
moving from stable to master doesn't require a downgrade. `-alpha0` versions
will not be tagged nor released.

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

@ -19,6 +19,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 +43,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 +77,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 +108,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,199 +498,115 @@ 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> supportedLocales = localized.keySet();
Set<String> availableLocales = localized.keySet(); setIsLocalized(supportedLocales);
Set<String> localesToUse = new LinkedHashSet<>(); String value = getLocalizedEntry(localized, supportedLocales, "whatsNew");
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");
if (!TextUtils.isEmpty(value)) { if (!TextUtils.isEmpty(value)) {
whatsNew = value; whatsNew = value;
} }
value = getLocalizedEntry(localized, localesToUse, "video");
value = getLocalizedEntry(localized, supportedLocales, "video");
if (!TextUtils.isEmpty(value)) { if (!TextUtils.isEmpty(value)) {
video = value.split("\n", 1)[0]; video = value.trim();
} }
value = getLocalizedEntry(localized, localesToUse, "name"); value = getLocalizedEntry(localized, supportedLocales, "name");
if (!TextUtils.isEmpty(value)) { if (!TextUtils.isEmpty(value)) {
name = value; name = value.trim();
} }
value = getLocalizedEntry(localized, localesToUse, "summary"); value = getLocalizedEntry(localized, supportedLocales, "summary");
if (!TextUtils.isEmpty(value)) { if (!TextUtils.isEmpty(value)) {
summary = value; summary = value.trim();
} }
value = getLocalizedEntry(localized, localesToUse, "description"); value = getLocalizedEntry(localized, supportedLocales, "description");
if (!TextUtils.isEmpty(value)) { if (!TextUtils.isEmpty(value)) {
description = formatDescription(value); description = formatDescription(value);
} }
value = getLocalizedGraphicsEntry(localized, localesToUse, "icon"); value = getLocalizedGraphicsEntry(localized, supportedLocales, "icon");
if (!TextUtils.isEmpty(value)) { if (!TextUtils.isEmpty(value)) {
iconUrl = value; iconUrl = value;
} }
featureGraphic = getLocalizedGraphicsEntry(localized, localesToUse, "featureGraphic"); featureGraphic = getLocalizedGraphicsEntry(localized, supportedLocales, "featureGraphic");
promoGraphic = getLocalizedGraphicsEntry(localized, localesToUse, "promoGraphic"); promoGraphic = getLocalizedGraphicsEntry(localized, supportedLocales, "promoGraphic");
tvBanner = getLocalizedGraphicsEntry(localized, localesToUse, "tvBanner"); tvBanner = getLocalizedGraphicsEntry(localized, supportedLocales, "tvBanner");
wearScreenshots = getLocalizedListEntry(localized, localesToUse, "wearScreenshots"); wearScreenshots = getLocalizedListEntry(localized, supportedLocales, "wearScreenshots");
phoneScreenshots = getLocalizedListEntry(localized, localesToUse, "phoneScreenshots"); phoneScreenshots = getLocalizedListEntry(localized, supportedLocales, "phoneScreenshots");
sevenInchScreenshots = getLocalizedListEntry(localized, localesToUse, "sevenInchScreenshots"); sevenInchScreenshots = getLocalizedListEntry(localized, supportedLocales, "sevenInchScreenshots");
tenInchScreenshots = getLocalizedListEntry(localized, localesToUse, "tenInchScreenshots"); tenInchScreenshots = getLocalizedListEntry(localized, supportedLocales, "tenInchScreenshots");
tvScreenshots = getLocalizedListEntry(localized, localesToUse, "tvScreenshots"); tvScreenshots = getLocalizedListEntry(localized, supportedLocales, "tvScreenshots");
} }
/** /**
* 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()} * @see org.fdroid.fdroid.views.main.WhatsNewViewBinder#onCreateLoader(int, android.os.Bundle)
* as the first entry, then sorting the rest based on length (e.g. {@code de-AT} */
* before {@code de}). private void setIsLocalized(Set<String> supportedLocales) {
isLocalized = false;
for (int i = 0; i < systemLocaleList.size(); i++) {
String language = systemLocaleList.get(i).getLanguage();
for (String supportedLocale : supportedLocales) {
if (language.equals(supportedLocale.split("-")[0])) {
isLocalized = true;
return;
}
}
}
}
/**
* Returns the right localized version of this entry, based on an imitation of
* the logic that Android uses.
* *
* @see LocaleList * @see LocaleList
* @see Locale#getDefault()
* @see java.util.Locale.LanguageRange
*/ */
private String getLocalizedEntry(Map<String, Map<String, Object>> localized, private String getLocalizedEntry(Map<String, Map<String, Object>> localized,
Set<String> locales, String key) { Set<String> supportedLocales, @NonNull String key) {
try { Map<String, Object> localizedLocaleMap = getLocalizedLocaleMap(localized, supportedLocales, key);
for (String locale : locales) { if (localizedLocaleMap != null && !localizedLocaleMap.isEmpty()) {
if (localized.containsKey(locale)) { for (Object entry : localizedLocaleMap.values()) {
String value = (String) localized.get(locale).get(key); return (String) entry; // NOPMD
if (value != null) {
return value;
} }
} }
}
} catch (ClassCastException e) {
Utils.debugLog(TAG, e.getMessage());
}
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> supportedLocales, @NonNull String key) {
try { Map<String, Object> localizedLocaleMap = getLocalizedLocaleMap(localized, supportedLocales, 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> supportedLocales, @NonNull String key) {
try { Map<String, Object> localizedLocaleMap = getLocalizedLocaleMap(localized, supportedLocales, 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;
@ -688,12 +618,99 @@ public class App extends ValueObject implements Comparable<App>, Parcelable {
} }
} }
} }
} catch (ClassCastException e) {
Utils.debugLog(TAG, e.getMessage());
}
return new String[0]; 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> supportedLocales, @NonNull String key) {
String[] localesToUse = getLocalesForKey(localized, supportedLocales, key);
if (localesToUse.length > 0) {
Locale firstMatch = systemLocaleList.getFirstMatch(localesToUse);
if (firstMatch != null) {
for (String languageTag : new String[]{toLanguageTag(firstMatch), 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 null;
}
/**
* Replace with {@link Locale#toLanguageTag()} once
* {@link android.os.Build.VERSION_CODES#LOLLIPOP} is {@code minSdkVersion}
*/
private String toLanguageTag(Locale firstMatch) {
if (Build.VERSION.SDK_INT < 21) {
return firstMatch.toString().replace("_", "-");
} else {
return firstMatch.toLanguageTag();
}
}
/**
* Get all locales that have an entry for {@code key}.
*/
private String[] getLocalesForKey(Map<String, Map<String, Object>> localized,
Set<String> supportedLocales, @NonNull String key) {
Set<String> localesToUse = new HashSet<>();
for (String locale : supportedLocales) {
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;
}
/** /**
* Returns the app description text with all newlines replaced by {@code <br>} * Returns the app description text with all newlines replaced by {@code <br>}
*/ */

View File

@ -6,10 +6,10 @@ import android.content.Context;
import android.content.UriMatcher; import android.content.UriMatcher;
import android.database.Cursor; import android.database.Cursor;
import android.net.Uri; import android.net.Uri;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import android.text.TextUtils; import android.text.TextUtils;
import android.util.Log; import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.fdroid.fdroid.Preferences; import org.fdroid.fdroid.Preferences;
import org.fdroid.fdroid.Utils; import org.fdroid.fdroid.Utils;
import org.fdroid.fdroid.data.Schema.ApkAntiFeatureJoinTable; import org.fdroid.fdroid.data.Schema.ApkAntiFeatureJoinTable;
@ -446,7 +446,7 @@ public class AppProvider extends FDroidProvider {
private static final String PATH_SEARCH_REPO = "searchRepo"; private static final String PATH_SEARCH_REPO = "searchRepo";
protected static final String PATH_APPS = "apps"; protected static final String PATH_APPS = "apps";
protected static final String PATH_SPECIFIC_APP = "app"; protected static final String PATH_SPECIFIC_APP = "app";
private static final String PATH_RECENTLY_UPDATED = "recentlyUpdated"; private static final String PATH_LATEST_TAB = "recentlyUpdated";
private static final String PATH_CATEGORY = "category"; private static final String PATH_CATEGORY = "category";
private static final String PATH_REPO = "repo"; private static final String PATH_REPO = "repo";
private static final String PATH_HIGHEST_PRIORITY = "highestPriority"; private static final String PATH_HIGHEST_PRIORITY = "highestPriority";
@ -459,8 +459,8 @@ public class AppProvider extends FDroidProvider {
private static final int INSTALLED = CAN_UPDATE + 1; private static final int INSTALLED = CAN_UPDATE + 1;
private static final int SEARCH_TEXT = INSTALLED + 1; private static final int SEARCH_TEXT = INSTALLED + 1;
private static final int SEARCH_TEXT_AND_CATEGORIES = SEARCH_TEXT + 1; private static final int SEARCH_TEXT_AND_CATEGORIES = SEARCH_TEXT + 1;
private static final int RECENTLY_UPDATED = SEARCH_TEXT_AND_CATEGORIES + 1; private static final int LATEST_TAB = SEARCH_TEXT_AND_CATEGORIES + 1;
private static final int CATEGORY = RECENTLY_UPDATED + 1; private static final int CATEGORY = LATEST_TAB + 1;
private static final int CALC_SUGGESTED_APKS = CATEGORY + 1; private static final int CALC_SUGGESTED_APKS = CATEGORY + 1;
private static final int REPO = CALC_SUGGESTED_APKS + 1; private static final int REPO = CALC_SUGGESTED_APKS + 1;
private static final int SEARCH_REPO = REPO + 1; private static final int SEARCH_REPO = REPO + 1;
@ -473,7 +473,7 @@ public class AppProvider extends FDroidProvider {
MATCHER.addURI(getAuthority(), null, CODE_LIST); MATCHER.addURI(getAuthority(), null, CODE_LIST);
MATCHER.addURI(getAuthority(), PATH_CALC_SUGGESTED_APKS, CALC_SUGGESTED_APKS); MATCHER.addURI(getAuthority(), PATH_CALC_SUGGESTED_APKS, CALC_SUGGESTED_APKS);
MATCHER.addURI(getAuthority(), PATH_CALC_SUGGESTED_APKS + "/*", CALC_SUGGESTED_APKS); MATCHER.addURI(getAuthority(), PATH_CALC_SUGGESTED_APKS + "/*", CALC_SUGGESTED_APKS);
MATCHER.addURI(getAuthority(), PATH_RECENTLY_UPDATED, RECENTLY_UPDATED); MATCHER.addURI(getAuthority(), PATH_LATEST_TAB, LATEST_TAB);
MATCHER.addURI(getAuthority(), PATH_CATEGORY + "/*", CATEGORY); MATCHER.addURI(getAuthority(), PATH_CATEGORY + "/*", CATEGORY);
MATCHER.addURI(getAuthority(), PATH_SEARCH + "/*/*", SEARCH_TEXT_AND_CATEGORIES); MATCHER.addURI(getAuthority(), PATH_SEARCH + "/*/*", SEARCH_TEXT_AND_CATEGORIES);
MATCHER.addURI(getAuthority(), PATH_SEARCH + "/*", SEARCH_TEXT); MATCHER.addURI(getAuthority(), PATH_SEARCH + "/*", SEARCH_TEXT);
@ -492,8 +492,14 @@ public class AppProvider extends FDroidProvider {
return Uri.parse("content://" + getAuthority()); return Uri.parse("content://" + getAuthority());
} }
public static Uri getRecentlyUpdatedUri() { /**
return Uri.withAppendedPath(getContentUri(), PATH_RECENTLY_UPDATED); * Get entries that are sorted by the {@link Schema.AppMetadataTable.Cols#LAST_UPDATED}
* date.
*
* @see #LATEST_TAB
*/
public static Uri getLatestTabUri() {
return Uri.withAppendedPath(getContentUri(), PATH_LATEST_TAB);
} }
private static Uri calcSuggestedApksUri() { private static Uri calcSuggestedApksUri() {
@ -845,12 +851,37 @@ public class AppProvider extends FDroidProvider {
includeSwap = false; includeSwap = false;
break; break;
case RECENTLY_UPDATED: case LATEST_TAB:
String table = getTableName(); /* Sort by localized first so users see entries in their language,
String isNew = table + "." + Cols.LAST_UPDATED + " <= " + table + "." + Cols.ADDED + " DESC"; * then sort by highlighted fields, then sort by whether the app is new,
String hasFeatureGraphic = table + "." + Cols.FEATURE_GRAPHIC + " IS NULL ASC "; * then if it has WhatsNew/Changelog entries, then by when it was last
String lastUpdated = table + "." + Cols.LAST_UPDATED + " DESC"; * updated. Last, it sorts by the date the app was added, putting older
sortOrder = lastUpdated + ", " + isNew + ", " + hasFeatureGraphic; * ones first, to give preference to apps that have been maintained in
* F-Droid longer.
*/
final String table = getTableName();
final String added = table + "." + Cols.ADDED;
final String lastUpdated = table + "." + Cols.LAST_UPDATED;
sortOrder = table + "." + Cols.IS_LOCALIZED + " DESC"
+ ", " + table + "." + Cols.NAME + " IS NULL ASC"
+ ", " + table + "." + Cols.ICON + " IS NULL ASC"
+ ", " + table + "." + Cols.SUMMARY + " IS NULL ASC"
+ ", " + table + "." + Cols.DESCRIPTION + " IS NULL ASC"
+ ", CASE WHEN " + table + "." + Cols.PHONE_SCREENSHOTS + " IS NULL"
+ " AND " + table + "." + Cols.SEVEN_INCH_SCREENSHOTS + " IS NULL"
+ " AND " + table + "." + Cols.TEN_INCH_SCREENSHOTS + " IS NULL"
+ " AND " + table + "." + Cols.TV_SCREENSHOTS + " IS NULL"
+ " AND " + table + "." + Cols.WEAR_SCREENSHOTS + " IS NULL"
+ " AND " + table + "." + Cols.FEATURE_GRAPHIC + " IS NULL"
+ " AND " + table + "." + Cols.PROMO_GRAPHIC + " IS NULL"
+ " AND " + table + "." + Cols.TV_BANNER + " IS NULL"
+ " THEN 1 ELSE 0 END"
+ ", CASE WHEN date(" + added + ") >= date(" + lastUpdated + ")"
+ " AND date('now','-7 days') < date(" + lastUpdated + ")"
+ " THEN 0 ELSE 1 END"
+ ", " + table + "." + Cols.WHATSNEW + " IS NULL ASC"
+ ", " + lastUpdated + " DESC"
+ ", " + added + " ASC";
// There seems no reason to limit the number of apps on the front page, but it helps // There seems no reason to limit the number of apps on the front page, but it helps
// if it loads quickly, as it is the default view shown every time F-Droid is opened. // if it loads quickly, as it is the default view shown every time F-Droid is opened.
@ -914,7 +945,7 @@ public class AppProvider extends FDroidProvider {
final String app = getTableName(); final String app = getTableName();
String query = "DELETE FROM " + catJoin + " WHERE " + CatJoinTable.Cols.APP_METADATA_ID + " IN " + String query = "DELETE FROM " + catJoin + " WHERE " + CatJoinTable.Cols.APP_METADATA_ID + " IN " +
"(SELECT " + Cols.ROW_ID + " FROM " + app + " WHERE " + app + "." + Cols.REPO_ID + " = ?)"; "(SELECT " + Cols.ROW_ID + " FROM " + app + " WHERE " + app + "." + Cols.REPO_ID + " = ?)";
db().execSQL(query, new String[] {String.valueOf(repoId)}); db().execSQL(query, new String[]{String.valueOf(repoId)});
AppQuerySelection selection = new AppQuerySelection(where, whereArgs).add(queryRepo(repoId)); AppQuerySelection selection = new AppQuerySelection(where, whereArgs).add(queryRepo(repoId));
int result = db().delete(getTableName(), selection.getSelection(), selection.getArgs()); int result = db().delete(getTableName(), selection.getSelection(), selection.getArgs());
@ -964,7 +995,7 @@ public class AppProvider extends FDroidProvider {
} }
protected void ensureCategories(String[] categories, long appMetadataId) { protected void ensureCategories(String[] categories, long appMetadataId) {
db().delete(getCatJoinTableName(), CatJoinTable.Cols.APP_METADATA_ID + " = ?", new String[] {Long.toString(appMetadataId)}); db().delete(getCatJoinTableName(), CatJoinTable.Cols.APP_METADATA_ID + " = ?", new String[]{Long.toString(appMetadataId)});
if (categories != null) { if (categories != null) {
Set<String> categoriesSet = new HashSet<>(); Set<String> categoriesSet = new HashSet<>();
for (String categoryName : categories) { for (String categoryName : categories) {
@ -1012,16 +1043,16 @@ public class AppProvider extends FDroidProvider {
/** /**
* If the repo hasn't changed, then there are many things which we shouldn't waste time updating * If the repo hasn't changed, then there are many things which we shouldn't waste time updating
* (compared to {@link AppProvider#updateAllAppDetails()}: * (compared to {@link AppProvider#updateAllAppDetails()}:
* <ul>
* <li>The "preferred metadata", as that is calculated based on repo with highest priority, and
* only takes into account the package name, not specific versions, when figuring this out.</li>
* *
* + The "preferred metadata", as that is calculated based on repo with highest priority, and * <li>Compatible flags. These were calculated earlier, whether or not an app was suggested or not.</li>
* only takes into account the package name, not specific versions, when figuring this out.
* *
* + Compatible flags. These were calculated earlier, whether or not an app was suggested or not. * <li>Icon URLs. While technically these do change when the suggested version changes, it is not
*
* + Icon URLs. While technically these do change when the suggested version changes, it is not
* important enough to spend a significant amount of time to calculate. In the future maybe, * important enough to spend a significant amount of time to calculate. In the future maybe,
* but that effort should instead go into implementing an intent service. * but that effort should instead go into implementing an intent service.</li>
* * </ul>
* In the future, this problem of taking a long time should be fixed by implementing an * In the future, this problem of taking a long time should be fixed by implementing an
* {@link android.app.IntentService} as described in https://gitlab.com/fdroid/fdroidclient/issues/520. * {@link android.app.IntentService} as described in https://gitlab.com/fdroid/fdroidclient/issues/520.
*/ */
@ -1139,7 +1170,7 @@ public class AppProvider extends FDroidProvider {
/** /**
* We set each app's suggested version to the latest available that is * We set each app's suggested version to the latest available that is
* compatible, or the latest available if none are compatible. * compatible, or the latest available if none are compatible.
* * <p>
* If the suggested version is null, it means that we could not figure it * If the suggested version is null, it means that we could not figure it
* out from the upstream vercode. In such a case, fall back to the simpler * out from the upstream vercode. In such a case, fall back to the simpler
* algorithm as if upstreamVercode was 0. * algorithm as if upstreamVercode was 0.

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

@ -3,20 +3,20 @@ package org.fdroid.fdroid.views.main;
import android.content.Intent; import android.content.Intent;
import android.database.Cursor; import android.database.Cursor;
import android.os.Bundle; import android.os.Bundle;
import androidx.annotation.NonNull;
import com.google.android.material.floatingactionbutton.FloatingActionButton;
import androidx.loader.app.LoaderManager;
import androidx.loader.content.CursorLoader;
import androidx.loader.content.Loader;
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
import androidx.appcompat.app.AppCompatActivity;
import androidx.recyclerview.widget.GridLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import android.view.View; import android.view.View;
import android.widget.FrameLayout; import android.widget.FrameLayout;
import android.widget.LinearLayout; import android.widget.LinearLayout;
import android.widget.ProgressBar; import android.widget.ProgressBar;
import android.widget.TextView; import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity;
import androidx.loader.app.LoaderManager;
import androidx.loader.content.CursorLoader;
import androidx.loader.content.Loader;
import androidx.recyclerview.widget.GridLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
import com.google.android.material.floatingactionbutton.FloatingActionButton;
import org.fdroid.fdroid.Preferences; import org.fdroid.fdroid.Preferences;
import org.fdroid.fdroid.R; import org.fdroid.fdroid.R;
import org.fdroid.fdroid.UpdateService; import org.fdroid.fdroid.UpdateService;
@ -24,12 +24,11 @@ import org.fdroid.fdroid.Utils;
import org.fdroid.fdroid.data.AppProvider; import org.fdroid.fdroid.data.AppProvider;
import org.fdroid.fdroid.data.RepoProvider; import org.fdroid.fdroid.data.RepoProvider;
import org.fdroid.fdroid.data.Schema.AppMetadataTable; import org.fdroid.fdroid.data.Schema.AppMetadataTable;
import org.fdroid.fdroid.views.apps.AppListActivity;
import org.fdroid.fdroid.panic.HidingManager; import org.fdroid.fdroid.panic.HidingManager;
import org.fdroid.fdroid.views.apps.AppListActivity;
import org.fdroid.fdroid.views.whatsnew.WhatsNewAdapter; import org.fdroid.fdroid.views.whatsnew.WhatsNewAdapter;
import java.util.Date; import java.util.Date;
import java.util.Locale;
/** /**
* Loads a list of newly added or recently updated apps and displays them to the user. * Loads a list of newly added or recently updated apps and displays them to the user.
@ -95,38 +94,20 @@ class WhatsNewViewBinder implements LoaderManager.LoaderCallbacks<Cursor> {
activity.getSupportLoaderManager().initLoader(LOADER_ID, null, this); activity.getSupportLoaderManager().initLoader(LOADER_ID, null, this);
} }
/**
* @see AppProvider#getLatestTabUri()
*/
@NonNull @NonNull
@Override @Override
public Loader<Cursor> onCreateLoader(int id, Bundle args) { public Loader<Cursor> onCreateLoader(int id, Bundle args) {
if (id != LOADER_ID) { if (id != LOADER_ID) {
return null; return null;
} }
// select that have all required items:
String selection = "(" + AppMetadataTable.NAME + "." + AppMetadataTable.Cols.NAME + " != ''"
+ " AND " + AppMetadataTable.NAME + "." + AppMetadataTable.Cols.SUMMARY + " != ''"
+ " AND " + AppMetadataTable.NAME + "." + AppMetadataTable.Cols.DESCRIPTION + " != ''"
+ " AND " + AppMetadataTable.NAME + "." + AppMetadataTable.Cols.LICENSE + " != ''"
+ " AND " + AppMetadataTable.NAME + "." + AppMetadataTable.Cols.WHATSNEW + " != ''";
if (!"en".equals(Locale.getDefault().getLanguage())) {
// only require localization if using a non-English locale
selection += " AND " + AppMetadataTable.NAME + "." + AppMetadataTable.Cols.IS_LOCALIZED + " = 1";
}
// and at least one optional item:
selection += ") AND ("
+ AppMetadataTable.NAME + "." + AppMetadataTable.Cols.SEVEN_INCH_SCREENSHOTS + " IS NOT NULL "
+ " OR " + AppMetadataTable.NAME + "." + AppMetadataTable.Cols.PHONE_SCREENSHOTS + " IS NOT NULL "
+ " OR " + AppMetadataTable.NAME + "." + AppMetadataTable.Cols.TEN_INCH_SCREENSHOTS + " IS NOT NULL "
+ " OR " + AppMetadataTable.NAME + "." + AppMetadataTable.Cols.TV_SCREENSHOTS + " IS NOT NULL "
+ " OR " + AppMetadataTable.NAME + "." + AppMetadataTable.Cols.WEAR_SCREENSHOTS + " IS NOT NULL "
+ " OR " + AppMetadataTable.NAME + "." + AppMetadataTable.Cols.FEATURE_GRAPHIC + " IS NOT NULL "
+ ")";
return new CursorLoader( return new CursorLoader(
activity, activity,
AppProvider.getRecentlyUpdatedUri(), AppProvider.getLatestTabUri(),
AppMetadataTable.Cols.ALL, AppMetadataTable.Cols.ALL,
selection, null,
null, null,
null null
); );

View File

@ -18,10 +18,8 @@ import org.robolectric.RobolectricTestRunner;
import org.robolectric.annotation.Config; import org.robolectric.annotation.Config;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Map;
import java.util.TimeZone; import java.util.TimeZone;
import static org.fdroid.fdroid.Assert.assertContainsOnly; import static org.fdroid.fdroid.Assert.assertContainsOnly;
@ -280,58 +278,6 @@ public class AppProviderTest extends FDroidProviderTest {
assertEquals("F-Droid", otherApp.name); assertEquals("F-Droid", otherApp.name);
} }
@Test
public void testAppSetLocalized() {
final String enSummary = "utility for getting information about the APKs that are installed on your device";
HashMap<String, Object> en = new HashMap<>();
en.put("summary", enSummary);
final String esSummary = "utilidad para obtener información sobre los APKs instalados en su dispositivo";
HashMap<String, Object> es = new HashMap<>();
es.put("summary", esSummary);
final String frSummary = "utilitaire pour obtenir des informations sur les APKs qui sont installés sur vot";
HashMap<String, Object> fr = new HashMap<>();
fr.put("summary", frSummary);
final String nlSummary = "hulpprogramma voor het verkrijgen van informatie over de APK die zijn geïnstalle";
HashMap<String, Object> nl = new HashMap<>();
nl.put("summary", nlSummary);
App app = new App();
Map<String, Map<String, Object>> localized = new HashMap<>();
localized.put("es", es);
localized.put("fr", fr);
Locale.setDefault(new Locale("nl", "NL"));
app.setLocalized(localized);
assertFalse(app.isLocalized);
localized.put("nl", nl);
app.setLocalized(localized);
assertTrue(app.isLocalized);
assertEquals(nlSummary, app.summary);
app = new App();
localized.clear();
localized.put("nl", nl);
app.setLocalized(localized);
assertTrue(app.isLocalized);
app = new App();
localized.clear();
localized.put("en-US", en);
app.setLocalized(localized);
assertFalse(app.isLocalized);
Locale.setDefault(new Locale("en", "US"));
app = new App();
localized.clear();
localized.put("en-US", en);
app.setLocalized(localized);
assertTrue(app.isLocalized);
}
@Test @Test
public void testInsertTrimsNamesAndSummary() { public void testInsertTrimsNamesAndSummary() {
// Insert a new record with unwanted newlines... // Insert a new record with unwanted newlines...

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";
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,159 @@ 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);
}
@Test
public void testIsLocalized() {
final String enSummary = "utility for getting information about the APKs that are installed on your device";
HashMap<String, Object> en = new HashMap<>();
en.put("summary", enSummary);
final String esSummary = "utilidad para obtener información sobre los APKs instalados en su dispositivo";
HashMap<String, Object> es = new HashMap<>();
es.put("summary", esSummary);
final String frSummary = "utilitaire pour obtenir des informations sur les APKs qui sont installés sur vot";
HashMap<String, Object> fr = new HashMap<>();
fr.put("summary", frSummary);
final String nlSummary = "hulpprogramma voor het verkrijgen van informatie over de APK die zijn geïnstalle";
HashMap<String, Object> nl = new HashMap<>();
nl.put("summary", nlSummary);
App app = new App();
Map<String, Map<String, Object>> localized = new HashMap<>();
localized.put("es", es);
localized.put("fr", fr);
App.systemLocaleList = LocaleListCompat.forLanguageTags("nl-NL");
app.setLocalized(localized);
assertFalse(app.isLocalized);
localized.put("nl", nl);
app.setLocalized(localized);
assertTrue(app.isLocalized);
assertEquals(nlSummary, app.summary);
app = new App();
localized.clear();
localized.put("nl", nl);
app.setLocalized(localized);
assertTrue(app.isLocalized);
app = new App();
localized.clear();
localized.put("en-US", en);
app.setLocalized(localized);
assertFalse(app.isLocalized);
App.systemLocaleList = LocaleListCompat.forLanguageTags("en-US");
app = new App();
localized.clear();
localized.put("en-US", en);
app.setLocalized(localized);
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