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.
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
were making code changes. This way the changes can be reviewed before the
source strings on Weblate are changed.
suggestions on how to improve them, open an issue or merge request like you
would if you were making code changes. This way the changes can be reviewed
before the source strings on Weblate are changed.
## Code Style
We follow the [Android Java style](https://source.android.com/source/code-style.html).
Some key points:
We follow the default Android Studio code formatter (e.g. `Ctrl-Alt-L`). This
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
* UTF-8 source files
@ -45,76 +47,35 @@ Some key points:
* Braces are always used after if, for and while
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
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
connected via `adb`, or an emulator running. Then, execute the following from the
command line:
./gradlew check
Note that the CI already runs the tests on an emulator, so you don't
necessarily have to do this yourself if you open a merge request as the tests
will get run.
Many important tests require a device or emulator, but do not work in GitLab CI.
That mean they need to be run locally, and that is usually easiest in Android
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"
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_.
./gradlew testFullDebugUnitTest --tests *LocaleSelectionTest*
## 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
stable has an important bug that needs immediate fixing - will follow the
`X.Y.Z` pattern.
./gradlew connectedFullDebugAndroidTest \
-Pandroid.testInstrumentationRunnerArguments.class=org.fdroid.fdroid.MainActivityExpressoTest
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`.
So for example, 1.3.1 would be `1003150` and 0.95-alpha13 would be `95013`
(leading zeros are omitted).
## Making releases
Note that we use a trailing `50` for actual stable releases, so alphas are
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.
See https://gitlab.com/fdroid/wiki/-/wikis/Internal/Release-Process#fdroidclient

View File

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

View File

@ -19,6 +19,8 @@ import android.text.TextUtils;
import android.util.Log;
import androidx.annotation.NonNull;
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.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
@ -41,8 +43,8 @@ import java.util.Arrays;
import java.util.Collections;
import java.util.Date;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
@ -75,6 +77,14 @@ public class App extends ValueObject implements Comparable<App>, Parcelable {
@JsonIgnore
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
/**
* 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;
@JsonIgnore
public boolean isApk;
/**
* Has this {@code App} been localized into one of the user's current locales.
*/
@JsonIgnore
boolean isLocalized = false;
boolean isLocalized;
/**
* This is primarily for the purpose of saving app metadata when parsing an index.xml file.
@ -484,214 +498,217 @@ public class App extends ValueObject implements Comparable<App>, Parcelable {
* {@code localized} block is included in the index. Also, null strings in
* the {@code localized} block should not overwrite Name/Summary/Description
* strings with empty/null if they were set directly by Jackson.
* <p>
* 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>
* <ol>
* <li>the country variant {@code de-AT} from the user locale list
* <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>the first available {@code en} locale
* </ol></li>
* <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}.
* </ol>
* <p>
* On {@code < android-24}, the user can only set a single
* locale with a country as an option, so here it makes sense to try to fallback
* on other country-specific locales, rather than English.
* The system-wide language preference list was added in {@code android-24}.
*
* @see <a href="https://developer.android.com/guide/topics/resources/multilingual-support">Android language and locale resolution overview</a>
*/
@JsonProperty("localized")
void setLocalized(Map<String, Map<String, Object>> localized) { // NOPMD
Locale defaultLocale = Locale.getDefault();
String languageTag = defaultLocale.getLanguage();
String countryTag = defaultLocale.getCountry();
String localeTag;
if (TextUtils.isEmpty(countryTag)) {
localeTag = languageTag;
} else {
localeTag = languageTag + "-" + countryTag;
if (systemLocaleList == null) {
systemLocaleList = ConfigurationCompat.getLocales(Resources.getSystem().getConfiguration());
}
Set<String> availableLocales = localized.keySet();
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");
Set<String> supportedLocales = localized.keySet();
setIsLocalized(supportedLocales);
String value = getLocalizedEntry(localized, supportedLocales, "whatsNew");
if (!TextUtils.isEmpty(value)) {
whatsNew = value;
}
value = getLocalizedEntry(localized, localesToUse, "video");
value = getLocalizedEntry(localized, supportedLocales, "video");
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)) {
name = value;
name = value.trim();
}
value = getLocalizedEntry(localized, localesToUse, "summary");
value = getLocalizedEntry(localized, supportedLocales, "summary");
if (!TextUtils.isEmpty(value)) {
summary = value;
summary = value.trim();
}
value = getLocalizedEntry(localized, localesToUse, "description");
value = getLocalizedEntry(localized, supportedLocales, "description");
if (!TextUtils.isEmpty(value)) {
description = formatDescription(value);
}
value = getLocalizedGraphicsEntry(localized, localesToUse, "icon");
value = getLocalizedGraphicsEntry(localized, supportedLocales, "icon");
if (!TextUtils.isEmpty(value)) {
iconUrl = value;
}
featureGraphic = getLocalizedGraphicsEntry(localized, localesToUse, "featureGraphic");
promoGraphic = getLocalizedGraphicsEntry(localized, localesToUse, "promoGraphic");
tvBanner = getLocalizedGraphicsEntry(localized, localesToUse, "tvBanner");
featureGraphic = getLocalizedGraphicsEntry(localized, supportedLocales, "featureGraphic");
promoGraphic = getLocalizedGraphicsEntry(localized, supportedLocales, "promoGraphic");
tvBanner = getLocalizedGraphicsEntry(localized, supportedLocales, "tvBanner");
wearScreenshots = getLocalizedListEntry(localized, localesToUse, "wearScreenshots");
phoneScreenshots = getLocalizedListEntry(localized, localesToUse, "phoneScreenshots");
sevenInchScreenshots = getLocalizedListEntry(localized, localesToUse, "sevenInchScreenshots");
tenInchScreenshots = getLocalizedListEntry(localized, localesToUse, "tenInchScreenshots");
tvScreenshots = getLocalizedListEntry(localized, localesToUse, "tvScreenshots");
wearScreenshots = getLocalizedListEntry(localized, supportedLocales, "wearScreenshots");
phoneScreenshots = getLocalizedListEntry(localized, supportedLocales, "phoneScreenshots");
sevenInchScreenshots = getLocalizedListEntry(localized, supportedLocales, "sevenInchScreenshots");
tenInchScreenshots = getLocalizedListEntry(localized, supportedLocales, "tenInchScreenshots");
tvScreenshots = getLocalizedListEntry(localized, supportedLocales, "tvScreenshots");
}
/**
* Returns the right localized version of this entry, based on an immitation of
* the logic that Android/Java uses. On Android >= 24, this can get the
* "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}).
* Sets the boolean flag {@link #isLocalized} if this app entry has an localized
* entry in one of the user's current locales.
*
* @see LocaleList
* @see Locale#getDefault()
* @see java.util.Locale.LanguageRange
* @see org.fdroid.fdroid.views.main.WhatsNewViewBinder#onCreateLoader(int, android.os.Bundle)
*/
private String getLocalizedEntry(Map<String, Map<String, Object>> localized,
Set<String> locales, String key) {
try {
for (String locale : locales) {
if (localized.containsKey(locale)) {
String value = (String) localized.get(locale).get(key);
if (value != null) {
return value;
}
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;
}
}
} 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> supportedLocales, @NonNull String key) {
Map<String, Object> localizedLocaleMap = getLocalizedLocaleMap(localized, supportedLocales, key);
if (localizedLocaleMap != null && !localizedLocaleMap.isEmpty()) {
for (Object entry : localizedLocaleMap.values()) {
return (String) entry; // NOPMD
}
}
return null;
}
private String getLocalizedGraphicsEntry(Map<String, Map<String, Object>> localized,
Set<String> locales, String key) {
try {
for (String locale : locales) {
Map<String, Object> entry = localized.get(locale);
if (entry != null) {
Object value = entry.get(key);
if (value != null && value.toString().length() > 0) {
return locale + "/" + value;
}
}
Set<String> supportedLocales, @NonNull String key) {
Map<String, Object> localizedLocaleMap = getLocalizedLocaleMap(localized, supportedLocales, key);
if (localizedLocaleMap != null && !localizedLocaleMap.isEmpty()) {
for (String locale : localizedLocaleMap.keySet()) {
return locale + "/" + localizedLocaleMap.get(locale); // NOPMD
}
} catch (ClassCastException e) {
Utils.debugLog(TAG, e.getMessage());
}
return null;
}
private String[] getLocalizedListEntry(Map<String, Map<String, Object>> localized,
Set<String> locales, String key) {
try {
for (String locale : locales) {
if (localized.containsKey(locale)) {
ArrayList<String> entry = (ArrayList<String>) localized.get(locale).get(key);
if (entry != null && entry.size() > 0) {
String[] result = new String[entry.size()];
int i = 0;
for (String e : entry) {
result[i] = locale + "/" + key + "/" + e;
i++;
Set<String> supportedLocales, @NonNull String key) {
Map<String, Object> localizedLocaleMap = getLocalizedLocaleMap(localized, supportedLocales, key);
if (localizedLocaleMap != null && !localizedLocaleMap.isEmpty()) {
for (String locale : localizedLocaleMap.keySet()) {
ArrayList<String> entry = (ArrayList<String>) localizedLocaleMap.get(locale);
if (entry != null && entry.size() > 0) {
String[] result = new String[entry.size()];
int i = 0;
for (String e : entry) {
result[i] = locale + "/" + key + "/" + e;
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> 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 result;
}
}
}
} catch (ClassCastException e) {
Utils.debugLog(TAG, e.getMessage());
}
return new String[0];
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;
}
/**

View File

@ -6,10 +6,10 @@ import android.content.Context;
import android.content.UriMatcher;
import android.database.Cursor;
import android.net.Uri;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import android.text.TextUtils;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.fdroid.fdroid.Preferences;
import org.fdroid.fdroid.Utils;
import org.fdroid.fdroid.data.Schema.ApkAntiFeatureJoinTable;
@ -262,17 +262,17 @@ public class AppProvider extends FDroidProvider {
@Override
protected String getRequiredTables() {
final String pkg = PackageTable.NAME;
final String app = getTableName();
final String pkg = PackageTable.NAME;
final String app = getTableName();
final String repo = RepoTable.NAME;
final String cat = CategoryTable.NAME;
final String cat = CategoryTable.NAME;
final String catJoin = getCatJoinTableName();
return pkg +
" JOIN " + app + " ON (" + app + "." + Cols.PACKAGE_ID + " = " + pkg + "." + PackageTable.Cols.ROW_ID + ") " +
" JOIN " + repo + " ON (" + app + "." + Cols.REPO_ID + " = " + repo + "." + RepoTable.Cols._ID + ") " +
" LEFT JOIN " + catJoin + " ON (" + app + "." + Cols.ROW_ID + " = " + catJoin + "." + CatJoinTable.Cols.APP_METADATA_ID + ") " +
" LEFT JOIN " + cat + " ON (" + cat + "." + CategoryTable.Cols.ROW_ID + " = " + catJoin + "." + CatJoinTable.Cols.CATEGORY_ID + ") ";
" JOIN " + app + " ON (" + app + "." + Cols.PACKAGE_ID + " = " + pkg + "." + PackageTable.Cols.ROW_ID + ") " +
" JOIN " + repo + " ON (" + app + "." + Cols.REPO_ID + " = " + repo + "." + RepoTable.Cols._ID + ") " +
" LEFT JOIN " + catJoin + " ON (" + app + "." + Cols.ROW_ID + " = " + catJoin + "." + CatJoinTable.Cols.APP_METADATA_ID + ") " +
" LEFT JOIN " + cat + " ON (" + cat + "." + CategoryTable.Cols.ROW_ID + " = " + catJoin + "." + CatJoinTable.Cols.CATEGORY_ID + ") ";
}
@Override
@ -446,7 +446,7 @@ public class AppProvider extends FDroidProvider {
private static final String PATH_SEARCH_REPO = "searchRepo";
protected static final String PATH_APPS = "apps";
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_REPO = "repo";
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 SEARCH_TEXT = INSTALLED + 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 CATEGORY = RECENTLY_UPDATED + 1;
private static final int LATEST_TAB = SEARCH_TEXT_AND_CATEGORIES + 1;
private static final int CATEGORY = LATEST_TAB + 1;
private static final int CALC_SUGGESTED_APKS = CATEGORY + 1;
private static final int REPO = CALC_SUGGESTED_APKS + 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(), 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_SEARCH + "/*/*", SEARCH_TEXT_AND_CATEGORIES);
MATCHER.addURI(getAuthority(), PATH_SEARCH + "/*", SEARCH_TEXT);
@ -492,8 +492,14 @@ public class AppProvider extends FDroidProvider {
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() {
@ -531,9 +537,9 @@ public class AppProvider extends FDroidProvider {
public static Uri getRepoUri(Repo repo) {
return getContentUri().buildUpon()
.appendPath(PATH_REPO)
.appendPath(String.valueOf(repo.id))
.build();
.appendPath(PATH_REPO)
.appendPath(String.valueOf(repo.id))
.build();
}
/**
@ -578,10 +584,10 @@ public class AppProvider extends FDroidProvider {
public static Uri getSearchUri(Repo repo, String query) {
return getContentUri().buildUpon()
.appendPath(PATH_SEARCH_REPO)
.appendPath(String.valueOf(repo.id))
.appendPath(query)
.build();
.appendPath(PATH_SEARCH_REPO)
.appendPath(String.valueOf(repo.id))
.appendPath(query)
.build();
}
@Override
@ -845,12 +851,37 @@ public class AppProvider extends FDroidProvider {
includeSwap = false;
break;
case RECENTLY_UPDATED:
String table = getTableName();
String isNew = table + "." + Cols.LAST_UPDATED + " <= " + table + "." + Cols.ADDED + " DESC";
String hasFeatureGraphic = table + "." + Cols.FEATURE_GRAPHIC + " IS NULL ASC ";
String lastUpdated = table + "." + Cols.LAST_UPDATED + " DESC";
sortOrder = lastUpdated + ", " + isNew + ", " + hasFeatureGraphic;
case LATEST_TAB:
/* Sort by localized first so users see entries in their language,
* then sort by highlighted fields, then sort by whether the app is new,
* then if it has WhatsNew/Changelog entries, then by when it was last
* updated. Last, it sorts by the date the app was added, putting older
* 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
// 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();
String query = "DELETE FROM " + catJoin + " WHERE " + CatJoinTable.Cols.APP_METADATA_ID + " IN " +
"(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));
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) {
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) {
Set<String> categoriesSet = new HashSet<>();
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
* (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
* 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.
*
* + 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,
* but that effort should instead go into implementing an intent service.
* <li>Compatible flags. These were calculated earlier, whether or not an app was suggested or not.</li>
*
* <li>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,
* 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
* {@link android.app.IntentService} as described in https://gitlab.com/fdroid/fdroidclient/issues/520.
*/
@ -1042,19 +1073,19 @@ public class AppProvider extends FDroidProvider {
final String highestPriority =
"SELECT MAX(r." + RepoTable.Cols.PRIORITY + ") " +
"FROM " + RepoTable.NAME + " AS r " +
"JOIN " + getTableName() + " AS m ON (m." + Cols.REPO_ID + " = r." + RepoTable.Cols._ID + ") " +
"WHERE m." + Cols.PACKAGE_ID + " = " + "metadata." + Cols.PACKAGE_ID;
"FROM " + RepoTable.NAME + " AS r " +
"JOIN " + getTableName() + " AS m ON (m." + Cols.REPO_ID + " = r." + RepoTable.Cols._ID + ") " +
"WHERE m." + Cols.PACKAGE_ID + " = " + "metadata." + Cols.PACKAGE_ID;
String updateSql =
"UPDATE " + PackageTable.NAME + " " +
"SET " + PackageTable.Cols.PREFERRED_METADATA + " = ( " +
" SELECT metadata." + Cols.ROW_ID +
" FROM " + app + " AS metadata " +
" JOIN " + RepoTable.NAME + " AS repo ON (metadata." + Cols.REPO_ID + " = repo." + RepoTable.Cols._ID + ") " +
" WHERE metadata." + Cols.PACKAGE_ID + " = " + PackageTable.NAME + "." + PackageTable.Cols.ROW_ID +
" AND repo." + RepoTable.Cols.PRIORITY + " = (" + highestPriority + ")" +
");";
"SET " + PackageTable.Cols.PREFERRED_METADATA + " = ( " +
" SELECT metadata." + Cols.ROW_ID +
" FROM " + app + " AS metadata " +
" JOIN " + RepoTable.NAME + " AS repo ON (metadata." + Cols.REPO_ID + " = repo." + RepoTable.Cols._ID + ") " +
" WHERE metadata." + Cols.PACKAGE_ID + " = " + PackageTable.NAME + "." + PackageTable.Cols.ROW_ID +
" AND repo." + RepoTable.Cols.PRIORITY + " = (" + highestPriority + ")" +
");";
db().execSQL(updateSql);
}
@ -1071,9 +1102,9 @@ public class AppProvider extends FDroidProvider {
String updateSql =
"UPDATE " + app + " SET " + Cols.IS_COMPATIBLE + " = ( " +
" SELECT TOTAL( " + apk + "." + ApkTable.Cols.IS_COMPATIBLE + ") > 0 " +
" FROM " + apk +
" WHERE " + apk + "." + ApkTable.Cols.APP_ID + " = " + app + "." + Cols.ROW_ID + " );";
" SELECT TOTAL( " + apk + "." + ApkTable.Cols.IS_COMPATIBLE + ") > 0 " +
" FROM " + apk +
" WHERE " + apk + "." + ApkTable.Cols.APP_ID + " = " + app + "." + Cols.ROW_ID + " );";
db().execSQL(updateSql);
}
@ -1122,16 +1153,16 @@ public class AppProvider extends FDroidProvider {
// zero rows.
String updateSql =
"UPDATE " + app + " SET " + Cols.AUTO_INSTALL_VERSION_CODE + " = ( " +
" SELECT MAX( " + apk + "." + ApkTable.Cols.VERSION_CODE + " ) " +
" FROM " + apk +
" JOIN " + app + " AS appForThisApk ON (appForThisApk." + Cols.ROW_ID + " = " + apk + "." + ApkTable.Cols.APP_ID + ") " +
" SELECT MAX( " + apk + "." + ApkTable.Cols.VERSION_CODE + " ) " +
" FROM " + apk +
" JOIN " + app + " AS appForThisApk ON (appForThisApk." + Cols.ROW_ID + " = " + apk + "." + ApkTable.Cols.APP_ID + ") " +
" LEFT JOIN " + installed + " ON (" + installed + "." + InstalledAppTable.Cols.PACKAGE_ID + " = " + app + "." + Cols.PACKAGE_ID + ") " +
" WHERE " +
app + "." + Cols.PACKAGE_ID + " = appForThisApk." + Cols.PACKAGE_ID + " AND " +
apk + "." + ApkTable.Cols.SIGNATURE + " IS COALESCE(" + installed + "." + InstalledAppTable.Cols.SIGNATURE + ", " + apk + "." + ApkTable.Cols.SIGNATURE + ") AND " +
restrictToStable +
" ( " + app + "." + Cols.IS_COMPATIBLE + " = 0 OR " + apk + "." + Cols.IS_COMPATIBLE + " = 1 ) ) " +
" WHERE " + Cols.SUGGESTED_VERSION_CODE + " > 0 " + restrictToApp;
" WHERE " +
app + "." + Cols.PACKAGE_ID + " = appForThisApk." + Cols.PACKAGE_ID + " AND " +
apk + "." + ApkTable.Cols.SIGNATURE + " IS COALESCE(" + installed + "." + InstalledAppTable.Cols.SIGNATURE + ", " + apk + "." + ApkTable.Cols.SIGNATURE + ") AND " +
restrictToStable +
" ( " + app + "." + Cols.IS_COMPATIBLE + " = 0 OR " + apk + "." + Cols.IS_COMPATIBLE + " = 1 ) ) " +
" WHERE " + Cols.SUGGESTED_VERSION_CODE + " > 0 " + restrictToApp;
LoggingQuery.execSQL(db(), updateSql, args);
}
@ -1139,7 +1170,7 @@ public class AppProvider extends FDroidProvider {
/**
* We set each app's suggested version to the latest available that is
* compatible, or the latest available if none are compatible.
*
* <p>
* 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
* algorithm as if upstreamVercode was 0.
@ -1168,15 +1199,15 @@ public class AppProvider extends FDroidProvider {
String updateSql =
"UPDATE " + app + " SET " + Cols.AUTO_INSTALL_VERSION_CODE + " = ( " +
" SELECT MAX( " + apk + "." + ApkTable.Cols.VERSION_CODE + " ) " +
" FROM " + apk +
" JOIN " + app + " AS appForThisApk ON (appForThisApk." + Cols.ROW_ID + " = " + apk + "." + ApkTable.Cols.APP_ID + ") " +
" LEFT JOIN " + installed + " ON (" + installed + "." + InstalledAppTable.Cols.PACKAGE_ID + " = " + app + "." + Cols.PACKAGE_ID + ") " +
" WHERE " +
app + "." + Cols.PACKAGE_ID + " = appForThisApk." + Cols.PACKAGE_ID + " AND " +
apk + "." + ApkTable.Cols.SIGNATURE + " IS COALESCE(" + installed + "." + InstalledAppTable.Cols.SIGNATURE + ", " + apk + "." + ApkTable.Cols.SIGNATURE + ") AND " +
" ( " + app + "." + Cols.IS_COMPATIBLE + " = 0 OR " + apk + "." + ApkTable.Cols.IS_COMPATIBLE + " = 1 ) ) " +
" WHERE " + restrictToApps;
" SELECT MAX( " + apk + "." + ApkTable.Cols.VERSION_CODE + " ) " +
" FROM " + apk +
" JOIN " + app + " AS appForThisApk ON (appForThisApk." + Cols.ROW_ID + " = " + apk + "." + ApkTable.Cols.APP_ID + ") " +
" LEFT JOIN " + installed + " ON (" + installed + "." + InstalledAppTable.Cols.PACKAGE_ID + " = " + app + "." + Cols.PACKAGE_ID + ") " +
" WHERE " +
app + "." + Cols.PACKAGE_ID + " = appForThisApk." + Cols.PACKAGE_ID + " AND " +
apk + "." + ApkTable.Cols.SIGNATURE + " IS COALESCE(" + installed + "." + InstalledAppTable.Cols.SIGNATURE + ", " + apk + "." + ApkTable.Cols.SIGNATURE + ") AND " +
" ( " + app + "." + Cols.IS_COMPATIBLE + " = 0 OR " + apk + "." + ApkTable.Cols.IS_COMPATIBLE + " = 1 ) ) " +
" WHERE " + restrictToApps;
LoggingQuery.execSQL(db(), updateSql, args);
}

View File

@ -209,6 +209,13 @@ public interface Schema {
String TV_SCREENSHOTS = "tvScreenshots";
String WEAR_SCREENSHOTS = "wearScreenshots";
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";
interface AutoInstallApk {

View File

@ -3,20 +3,20 @@ package org.fdroid.fdroid.views.main;
import android.content.Intent;
import android.database.Cursor;
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.widget.FrameLayout;
import android.widget.LinearLayout;
import android.widget.ProgressBar;
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.R;
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.RepoProvider;
import org.fdroid.fdroid.data.Schema.AppMetadataTable;
import org.fdroid.fdroid.views.apps.AppListActivity;
import org.fdroid.fdroid.panic.HidingManager;
import org.fdroid.fdroid.views.apps.AppListActivity;
import org.fdroid.fdroid.views.whatsnew.WhatsNewAdapter;
import java.util.Date;
import java.util.Locale;
/**
* 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);
}
/**
* @see AppProvider#getLatestTabUri()
*/
@NonNull
@Override
public Loader<Cursor> onCreateLoader(int id, Bundle args) {
if (id != LOADER_ID) {
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(
activity,
AppProvider.getRecentlyUpdatedUri(),
AppProvider.getLatestTabUri(),
AppMetadataTable.Cols.ALL,
selection,
null,
null,
null
);

View File

@ -18,10 +18,8 @@ import org.robolectric.RobolectricTestRunner;
import org.robolectric.annotation.Config;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.TimeZone;
import static org.fdroid.fdroid.Assert.assertContainsOnly;
@ -280,58 +278,6 @@ public class AppProviderTest extends FDroidProviderTest {
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
public void testInsertTrimsNamesAndSummary() {
// Insert a new record with unwanted newlines...

View File

@ -1,23 +1,27 @@
package org.fdroid.fdroid.data;
import android.content.res.Configuration;
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.junit.Assume;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.shadows.ShadowLog;
import java.io.File;
import java.io.IOException;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
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)
@SuppressWarnings("LocalVariableName")
@ -25,83 +29,25 @@ public class LocaleSelectionTest {
private static final String KEY = "summary";
@Test
public void correctLocaleSelectionBeforeSDK24() throws Exception {
TestUtils.setFinalStatic(Build.VERSION.class.getDeclaredField("SDK_INT"), 19);
assertTrue(Build.VERSION.SDK_INT < 24);
App app;
private static final String EN_US_NAME = "Checkey: info on local apps";
private static final String EN_US_FEATURE_GRAPHIC = "en-US/featureGraphic.png";
private static final String EN_US_PHONE_SCREENSHOT = "en-US/phoneScreenshots/First.png";
private static final String EN_US_SEVEN_INCH_SCREENSHOT = "en-US/sevenInchScreenshots/checkey-tablet.png";
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<>();
HashMap<String, Object> en_US = new HashMap<>();
en_US.put(KEY, "summary-en_US");
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);
@Before
public final void setUp() {
ShadowLog.stream = System.out;
}
@Test
public void correctLocaleSelectionFromSDK24() throws Exception {
public void localeSelection() throws Exception {
TestUtils.setFinalStatic(Build.VERSION.class.getDeclaredField("SDK_INT"), 29);
assertTrue(Build.VERSION.SDK_INT >= 24);
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");
App app = new App();
App.systemLocaleList = LocaleListCompat.forLanguageTags("en-US,de-DE");
//no metadata present
Map<String, Map<String, Object>> localized = new HashMap<>();
@ -112,10 +58,18 @@ public class LocaleSelectionTest {
en_US.put(KEY, "summary-en_US");
HashMap<String, Object> en_GB = new HashMap<>();
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<>();
de_AT.put(KEY, "summary-de_AT");
HashMap<String, Object> de_DE = new HashMap<>();
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";
localized.put("de-AT", de_AT);
@ -125,8 +79,7 @@ public class LocaleSelectionTest {
// just select the matching en-US locale, nothing special here
assertEquals(en_US.get(KEY), app.summary);
Locale.setDefault(new Locale("en", "SE"));
when(localeList.toLanguageTags()).thenReturn("en-SE,de-DE");
App.systemLocaleList = LocaleListCompat.forLanguageTags("en-SE,de-DE");
app.setLocalized(localized);
// Fall back to another en locale before de
assertEquals(en_US.get(KEY), app.summary);
@ -138,8 +91,7 @@ public class LocaleSelectionTest {
localized.put("en-GB", en_GB);
localized.put("en-US", en_US);
Locale.setDefault(new Locale("de", "AT"));
when(localeList.toLanguageTags()).thenReturn("de-AT,de-DE");
App.systemLocaleList = LocaleListCompat.forLanguageTags("de-AT,de-DE");
app.setLocalized(localized);
// full match against a non-default locale
assertEquals(de_AT.get(KEY), app.summary);
@ -147,14 +99,13 @@ public class LocaleSelectionTest {
app.summary = "reset";
localized.clear();
localized.put("de-AT", de_AT);
localized.put("de", de_DE);
localized.put("de", de);
localized.put("en-GB", en_GB);
localized.put("en-US", en_US);
Locale.setDefault(new Locale("de", "CH"));
when(localeList.toLanguageTags()).thenReturn("de-CH,en-US");
App.systemLocaleList = LocaleListCompat.forLanguageTags("de-CH,en-US");
app.setLocalized(localized);
assertEquals(de_DE.get(KEY), app.summary);
assertEquals(de.get(KEY), app.summary);
app.summary = "reset";
localized.clear();
@ -162,13 +113,12 @@ public class LocaleSelectionTest {
localized.put("en-US", en_US);
Locale.setDefault(new Locale("en", "AU"));
when(localeList.toLanguageTags()).thenReturn("en-AU");
App.systemLocaleList = LocaleListCompat.forLanguageTags("en-AU");
app.setLocalized(localized);
assertEquals(en_US.get(KEY), app.summary);
app.summary = "reset";
Locale.setDefault(new Locale("zh", "TW", "#Hant"));
when(localeList.toLanguageTags()).thenReturn("zh-Hant-TW,zh-Hans-CN");
App.systemLocaleList = LocaleListCompat.forLanguageTags("zh-Hant-TW,zh-Hans-CN");
localized.clear();
localized.put("en", en_GB);
localized.put("en-US", en_US);
@ -197,5 +147,159 @@ public class LocaleSelectionTest {
localized.put("zh-CN", zh_CN);
app.setLocalized(localized);
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",
"preferredSigner",
"prefs",
"systemLocaleList",
"TAG",
};
runJsonIgnoreTest(new App(), allowedInApp, ignoredInApp);

File diff suppressed because one or more lines are too long