diff --git a/app/src/full/java/org/fdroid/fdroid/views/main/CategoriesViewBinder.java b/app/src/full/java/org/fdroid/fdroid/views/main/CategoriesViewBinder.java index cde674d31..4d636053c 100644 --- a/app/src/full/java/org/fdroid/fdroid/views/main/CategoriesViewBinder.java +++ b/app/src/full/java/org/fdroid/fdroid/views/main/CategoriesViewBinder.java @@ -3,28 +3,28 @@ package org.fdroid.fdroid.views.main; import android.content.Intent; import android.database.Cursor; import android.os.Bundle; -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.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; import android.view.View; import android.widget.FrameLayout; 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.LinearLayoutManager; +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; import org.fdroid.fdroid.Utils; import org.fdroid.fdroid.data.CategoryProvider; import org.fdroid.fdroid.data.Schema; +import org.fdroid.fdroid.panic.HidingManager; import org.fdroid.fdroid.views.apps.AppListActivity; import org.fdroid.fdroid.views.categories.CategoryAdapter; import org.fdroid.fdroid.views.categories.CategoryController; -import org.fdroid.fdroid.panic.HidingManager; import java.util.ArrayList; import java.util.Collections; @@ -37,6 +37,7 @@ import java.util.List; * view with relevant info about each. */ class CategoriesViewBinder implements LoaderManager.LoaderCallbacks { + public static final String TAG = "CategoriesViewBinder"; private static final int LOADER_ID = 429820532; @@ -92,10 +93,11 @@ class CategoriesViewBinder implements LoaderManager.LoaderCallbacks { activity.getSupportLoaderManager().restartLoader(LOADER_ID, null, this); } + @NonNull @Override public Loader onCreateLoader(int id, Bundle args) { if (id != LOADER_ID) { - return null; + throw new IllegalArgumentException("id != LOADER_ID"); } return new CursorLoader( @@ -110,7 +112,7 @@ class CategoriesViewBinder implements LoaderManager.LoaderCallbacks { /** * Reads all categories from the cursor and stores them in memory to provide to the {@link CategoryAdapter}. - * + *

* It does this so it is easier to deal with localized/unlocalized categories without having * to store the localized version in the database. It is not expected that the list of categories * will grow so large as to make this a performance concern. If it does in the future, the diff --git a/app/src/main/java/org/fdroid/fdroid/Utils.java b/app/src/main/java/org/fdroid/fdroid/Utils.java index 3d146d645..957746579 100644 --- a/app/src/main/java/org/fdroid/fdroid/Utils.java +++ b/app/src/main/java/org/fdroid/fdroid/Utils.java @@ -101,7 +101,7 @@ public final class Utils { // The date format used for storing dates (e.g. lastupdated, added) in the // database. - private static final SimpleDateFormat DATE_FORMAT = + public static final SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd", Locale.ENGLISH); private static final SimpleDateFormat TIME_FORMAT = diff --git a/app/src/main/java/org/fdroid/fdroid/data/AppProvider.java b/app/src/main/java/org/fdroid/fdroid/data/AppProvider.java index 2751788c7..04f450ea5 100644 --- a/app/src/main/java/org/fdroid/fdroid/data/AppProvider.java +++ b/app/src/main/java/org/fdroid/fdroid/data/AppProvider.java @@ -506,6 +506,9 @@ public class AppProvider extends FDroidProvider { return Uri.withAppendedPath(getContentUri(), PATH_CALC_SUGGESTED_APKS); } + /** + * Get all {@link App} entries in the given {@code category} + */ public static Uri getCategoryUri(String category) { return getContentUri().buildUpon() .appendPath(PATH_CATEGORY) @@ -519,6 +522,13 @@ public class AppProvider extends FDroidProvider { .build(); } + /** + * Get the top {@link App} entries in the given {@code category} to display + * in the overview screen in {@link org.fdroid.fdroid.views.categories.CategoryController}. + * The number of entries is defined by {@code limit}. + * + * @see org.fdroid.fdroid.views.categories.CategoryController#onCreateLoader(int, android.os.Bundle) + */ public static Uri getTopFromCategoryUri(String category, int limit) { return getContentUri().buildUpon() .appendPath(PATH_TOP_FROM_CATEGORY) @@ -842,7 +852,6 @@ public class AppProvider extends FDroidProvider { case TOP_FROM_CATEGORY: selection = selection.add(queryCategory(pathSegments.get(2))); limit = Integer.parseInt(pathSegments.get(1)); - sortOrder = getTableName() + "." + Cols.LAST_UPDATED + " DESC"; includeSwap = false; break; @@ -852,37 +861,6 @@ public class AppProvider extends FDroidProvider { break; 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. // 200 is an arbitrary number which hopefully gives the user enough to scroll through @@ -912,6 +890,12 @@ public class AppProvider extends FDroidProvider { /** * Helper method used by both the genuine {@link AppProvider} and the temporary version used * by the repo updater ({@link TempAppProvider}). + *

+ * Query the database table specified by {@code uri}, which is usually (always?) + * {@link AppMetadataTable} with specified {@code selection} and {@code sortOrder}. + * WARNING: This contains a hack if {@code sortOrder} is equal to {@link Cols#NAME}, + * i.e. not a complete table.column name, but just that single column name. In that case, + * a {@code sortOrder} is built out into a {@code sortOrder} that includes localized sorting. */ protected Cursor runQuery(Uri uri, AppQuerySelection selection, String[] projection, boolean includeSwap, String sortOrder, int limit) { if (!includeSwap) { @@ -1014,6 +998,7 @@ public class AppProvider extends FDroidProvider { categoryValues.put(CatJoinTable.Cols.CATEGORY_ID, categoryId); db().insert(getCatJoinTableName(), null, categoryValues); } + getContext().getContentResolver().notifyChange(CategoryProvider.getContentUri(), null); } } diff --git a/app/src/main/java/org/fdroid/fdroid/data/CategoryProvider.java b/app/src/main/java/org/fdroid/fdroid/data/CategoryProvider.java index 8186fda0a..7da97e339 100644 --- a/app/src/main/java/org/fdroid/fdroid/data/CategoryProvider.java +++ b/app/src/main/java/org/fdroid/fdroid/data/CategoryProvider.java @@ -17,6 +17,7 @@ import java.util.Locale; import java.util.Map; public class CategoryProvider extends FDroidProvider { + public static final String TAG = "CategoryProvider"; public static final class Helper { private Helper() { @@ -26,7 +27,7 @@ public class CategoryProvider extends FDroidProvider { * During repo updates, each app needs to know the ID of each category it belongs to. * This results in lots of database lookups, usually at least one for each app, sometimes more. * To improve performance, this caches the association between categories and their database IDs. - * + *

* It can stay around for the entire F-Droid process, even across multiple repo updates, as we * don't actually remove data from the categories table. */ diff --git a/app/src/main/java/org/fdroid/fdroid/data/DBHelper.java b/app/src/main/java/org/fdroid/fdroid/data/DBHelper.java index ca30208d2..20f19860a 100644 --- a/app/src/main/java/org/fdroid/fdroid/data/DBHelper.java +++ b/app/src/main/java/org/fdroid/fdroid/data/DBHelper.java @@ -1318,6 +1318,7 @@ public class DBHelper extends SQLiteOpenHelper { Utils.debugLog(TAG, "Removing all index tables, they will be recreated next time F-Droid updates."); Preferences.get().resetLastUpdateCheck(); + CategoryProvider.Helper.clearCategoryIdCache(); db.beginTransaction(); try { diff --git a/app/src/main/java/org/fdroid/fdroid/views/apps/AppListActivity.java b/app/src/main/java/org/fdroid/fdroid/views/apps/AppListActivity.java index 78664ff87..d96da97f2 100644 --- a/app/src/main/java/org/fdroid/fdroid/views/apps/AppListActivity.java +++ b/app/src/main/java/org/fdroid/fdroid/views/apps/AppListActivity.java @@ -20,11 +20,14 @@ package org.fdroid.fdroid.views.apps; +import android.content.Context; import android.content.Intent; +import android.content.SharedPreferences; import android.database.Cursor; import android.graphics.Color; import android.graphics.drawable.Drawable; import android.os.Bundle; +import android.text.TextUtils; import android.view.KeyEvent; import android.view.View; import android.view.inputmethod.EditorInfo; @@ -32,7 +35,6 @@ import android.view.inputmethod.InputMethodManager; import android.widget.EditText; import android.widget.ImageView; import android.widget.TextView; - import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.AppCompatActivity; @@ -43,14 +45,13 @@ import androidx.loader.content.CursorLoader; import androidx.loader.content.Loader; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; - import com.nostra13.universalimageloader.core.ImageLoader; - import org.fdroid.fdroid.FDroidApp; import org.fdroid.fdroid.R; import org.fdroid.fdroid.Utils; import org.fdroid.fdroid.data.AppProvider; -import org.fdroid.fdroid.data.Schema; +import org.fdroid.fdroid.data.Schema.AppMetadataTable; +import org.fdroid.fdroid.data.Schema.AppMetadataTable.Cols; /** * Provides scrollable listing of apps for search and category views. @@ -58,25 +59,30 @@ import org.fdroid.fdroid.data.Schema; public class AppListActivity extends AppCompatActivity implements LoaderManager.LoaderCallbacks, CategoryTextWatcher.SearchTermsChangedListener { + public static final String TAG = "AppListActivity"; + public static final String EXTRA_CATEGORY = "org.fdroid.fdroid.views.apps.AppListActivity.EXTRA_CATEGORY"; public static final String EXTRA_SEARCH_TERMS = "org.fdroid.fdroid.views.apps.AppListActivity.EXTRA_SEARCH_TERMS"; + private static final String SEARCH_TERMS_KEY = "searchTerms"; + private static final String SORT_CLAUSE_KEY = "sortClauseSelected"; + private static SharedPreferences savedSearchSettings; + private RecyclerView appView; private AppListAdapter appAdapter; private String category; private String searchTerms; - private String sortClauseSelected = SortClause.LAST_UPDATED; + private String sortClauseSelected; private TextView emptyState; private EditText searchInput; private ImageView sortImage; private Utils.KeyboardStateMonitor keyboardStateMonitor; private interface SortClause { - String NAME = Schema.AppMetadataTable.NAME + "." + Schema.AppMetadataTable.Cols.NAME + " asc"; - String LAST_UPDATED = Schema.AppMetadataTable.NAME + "." - + Schema.AppMetadataTable.Cols.LAST_UPDATED + " desc"; + String WORDS = Cols.NAME; + String LAST_UPDATED = Cols.LAST_UPDATED; } @Override @@ -88,7 +94,12 @@ public class AppListActivity extends AppCompatActivity implements LoaderManager. keyboardStateMonitor = new Utils.KeyboardStateMonitor(findViewById(R.id.app_list_root)); + savedSearchSettings = getSavedSearchSettings(this); + searchTerms = savedSearchSettings.getString(SEARCH_TERMS_KEY, null); + sortClauseSelected = savedSearchSettings.getString(SORT_CLAUSE_KEY, SortClause.LAST_UPDATED); + searchInput = (EditText) findViewById(R.id.search); + searchInput.setText(searchTerms); searchInput.addTextChangedListener(new CategoryTextWatcher(this, searchInput, this)); searchInput.setOnEditorActionListener(new TextView.OnEditorActionListener() { @Override @@ -111,20 +122,26 @@ public class AppListActivity extends AppCompatActivity implements LoaderManager. final Drawable lastUpdated = DrawableCompat.wrap(ContextCompat.getDrawable(this, R.drawable.ic_access_time)).mutate(); DrawableCompat.setTint(lastUpdated, FDroidApp.isAppThemeLight() ? Color.BLACK : Color.WHITE); - sortImage.setImageDrawable(lastUpdated); + final Drawable words = DrawableCompat.wrap(ContextCompat.getDrawable(AppListActivity.this, + R.drawable.ic_sort)).mutate(); + DrawableCompat.setTint(words, FDroidApp.isAppThemeLight() ? Color.BLACK : Color.WHITE); + sortImage.setImageDrawable(SortClause.WORDS.equals(sortClauseSelected) ? words : lastUpdated); sortImage.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { - if (sortClauseSelected.equalsIgnoreCase(SortClause.LAST_UPDATED)) { - sortClauseSelected = SortClause.NAME; - final Drawable alphabetical = DrawableCompat.wrap( - ContextCompat.getDrawable(AppListActivity.this, R.drawable.ic_sort_by_alpha)).mutate(); - DrawableCompat.setTint(alphabetical, FDroidApp.isAppThemeLight() ? Color.BLACK : Color.WHITE); - sortImage.setImageDrawable(alphabetical); - } else { - sortClauseSelected = SortClause.LAST_UPDATED; - sortImage.setImageDrawable(lastUpdated); + switch (sortClauseSelected) { + case SortClause.WORDS: + sortClauseSelected = SortClause.LAST_UPDATED; + DrawableCompat.setTint(lastUpdated, FDroidApp.isAppThemeLight() ? Color.BLACK : Color.WHITE); + sortImage.setImageDrawable(lastUpdated); + break; + case SortClause.LAST_UPDATED: + sortClauseSelected = SortClause.WORDS; + DrawableCompat.setTint(words, FDroidApp.isAppThemeLight() ? Color.BLACK : Color.WHITE); + sortImage.setImageDrawable(words); + break; } + putSavedSearchSettings(getApplicationContext(), SORT_CLAUSE_KEY, sortClauseSelected); getSupportLoaderManager().restartLoader(0, null, AppListActivity.this); appView.scrollToPosition(0); } @@ -218,10 +235,10 @@ public class AppListActivity extends AppCompatActivity implements LoaderManager. return new CursorLoader( this, AppProvider.getSearchUri(searchTerms, category), - Schema.AppMetadataTable.Cols.ALL, + AppMetadataTable.Cols.ALL, null, null, - sortClauseSelected + getSortOrder() ); } @@ -247,5 +264,105 @@ public class AppListActivity extends AppCompatActivity implements LoaderManager. this.category = category; this.searchTerms = searchTerms; getSupportLoaderManager().restartLoader(0, null, this); + if (TextUtils.isEmpty(searchTerms)) { + removeSavedSearchSettings(this, SEARCH_TERMS_KEY); + } else { + putSavedSearchSettings(this, SEARCH_TERMS_KEY, searchTerms); + } + } + + private String getSortOrder() { + final String table = AppMetadataTable.NAME; + final String nameCol = table + "." + AppMetadataTable.Cols.NAME; + final String summaryCol = table + "." + AppMetadataTable.Cols.SUMMARY; + final String packageCol = Cols.Package.PACKAGE_NAME; + + if (sortClauseSelected.equals(SortClause.LAST_UPDATED)) { + return table + "." + Cols.LAST_UPDATED + " DESC" + + ", " + table + "." + Cols.IS_LOCALIZED + " DESC" + + ", " + table + "." + Cols.ADDED + " ASC" + + ", " + table + "." + Cols.NAME + " IS NULL ASC" + + ", " + table + "." + Cols.ICON + " IS NULL ASC" + + ", " + table + "." + Cols.SUMMARY + " IS NULL ASC" + + ", " + table + "." + Cols.DESCRIPTION + " IS NULL ASC" + + ", " + table + "." + Cols.WHATSNEW + " 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"; + } + + // prevent SQL injection https://en.wikipedia.org/wiki/SQL_injection#Escaping + final String[] terms = searchTerms.trim().replaceAll("[\\x1a\0\n\r\"';\\\\]+", " ").split("\\s+"); + if (terms.length == 0 || terms[0].equals("")) { + return table + "." + Cols.NAME + " COLLATE LOCALIZED "; + } + + boolean potentialPackageName = false; + StringBuilder packageNameFirstCase = new StringBuilder(); + if (terms[0].length() > 2 && terms[0].substring(1, terms[0].length() - 1).contains(".")) { + potentialPackageName = true; + packageNameFirstCase.append(String.format("%s LIKE '%%%s%%' ", + packageCol, terms[0])); + } + StringBuilder titleCase = new StringBuilder(String.format("%s like '%%%s%%'", nameCol, terms[0])); + StringBuilder summaryCase = new StringBuilder(String.format("%s like '%%%s%%'", summaryCol, terms[0])); + StringBuilder packageNameCase = new StringBuilder(String.format("%s like '%%%s%%'", packageCol, terms[0])); + for (int i = 1; i < terms.length; i++) { + if (potentialPackageName) { + packageNameCase.append(String.format(" and %s like '%%%s%%'", summaryCol, terms[i])); + } + titleCase.append(String.format(" and %s like '%%%s%%'", nameCol, terms[i])); + summaryCase.append(String.format(" and %s like '%%%s%%'", summaryCol, terms[i])); + } + String sortOrder; + if (packageNameCase.length() > 0) { + sortOrder = String.format("CASE WHEN %s THEN 0 WHEN %s THEN 1 WHEN %s THEN 2 ELSE 3 END", + packageNameCase.toString(), titleCase.toString(), summaryCase.toString()); + } else { + sortOrder = String.format("CASE WHEN %s THEN 1 WHEN %s THEN 2 ELSE 3 END", + titleCase.toString(), summaryCase.toString()); + } + return sortOrder + + ", " + table + "." + Cols.IS_LOCALIZED + " DESC" + + ", " + table + "." + Cols.ADDED + " ASC" + + ", " + table + "." + Cols.NAME + " IS NULL ASC" + + ", " + table + "." + Cols.ICON + " IS NULL ASC" + + ", " + table + "." + Cols.SUMMARY + " IS NULL ASC" + + ", " + table + "." + Cols.DESCRIPTION + " IS NULL ASC" + + ", " + table + "." + Cols.WHATSNEW + " 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" + + ", " + table + "." + Cols.LAST_UPDATED + " DESC"; + } + + public static void putSavedSearchSettings(Context context, String key, String searchTerms) { + if (savedSearchSettings == null) { + savedSearchSettings = getSavedSearchSettings(context); + } + savedSearchSettings.edit().putString(key, searchTerms).apply(); + } + + public static void removeSavedSearchSettings(Context context, String key) { + if (savedSearchSettings == null) { + savedSearchSettings = getSavedSearchSettings(context); + } + savedSearchSettings.edit().remove(key).apply(); + } + + private static SharedPreferences getSavedSearchSettings(Context context) { + return context.getSharedPreferences("saved-search-settings", Context.MODE_PRIVATE); } } diff --git a/app/src/main/java/org/fdroid/fdroid/views/categories/AppCardController.java b/app/src/main/java/org/fdroid/fdroid/views/categories/AppCardController.java index 2d7a09bb2..138427e6f 100644 --- a/app/src/main/java/org/fdroid/fdroid/views/categories/AppCardController.java +++ b/app/src/main/java/org/fdroid/fdroid/views/categories/AppCardController.java @@ -1,20 +1,18 @@ package org.fdroid.fdroid.views.categories; -import androidx.appcompat.app.AppCompatActivity; import android.content.Intent; import android.os.Bundle; import android.view.View; import android.widget.ImageView; import android.widget.TextView; - import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.appcompat.app.AppCompatActivity; import androidx.core.app.ActivityOptionsCompat; import androidx.core.content.ContextCompat; import androidx.core.util.Pair; import androidx.core.view.ViewCompat; import androidx.recyclerview.widget.RecyclerView; - import org.fdroid.fdroid.R; import org.fdroid.fdroid.Utils; import org.fdroid.fdroid.data.App; @@ -33,7 +31,7 @@ public class AppCardController extends RecyclerView.ViewHolder /** * After this many days, don't consider showing the "New" tag next to an app. */ - private static final int DAYS_TO_CONSIDER_NEW = 14; + public static final int DAYS_TO_CONSIDER_NEW = 14; @NonNull private final ImageView icon; diff --git a/app/src/main/java/org/fdroid/fdroid/views/categories/CategoryController.java b/app/src/main/java/org/fdroid/fdroid/views/categories/CategoryController.java index 98391dead..a0c2ffe94 100644 --- a/app/src/main/java/org/fdroid/fdroid/views/categories/CategoryController.java +++ b/app/src/main/java/org/fdroid/fdroid/views/categories/CategoryController.java @@ -1,6 +1,5 @@ package org.fdroid.fdroid.views.categories; -import androidx.appcompat.app.AppCompatActivity; import android.content.Context; import android.content.Intent; import android.content.res.Resources; @@ -8,18 +7,19 @@ import android.database.Cursor; import android.graphics.Color; import android.graphics.Rect; import android.os.Bundle; -import androidx.annotation.ColorInt; -import androidx.annotation.NonNull; -import androidx.loader.app.LoaderManager; -import androidx.core.content.ContextCompat; -import androidx.loader.content.CursorLoader; -import androidx.loader.content.Loader; -import androidx.core.view.ViewCompat; -import androidx.recyclerview.widget.RecyclerView; import android.view.View; import android.widget.Button; import android.widget.FrameLayout; import android.widget.TextView; +import androidx.annotation.ColorInt; +import androidx.annotation.NonNull; +import androidx.appcompat.app.AppCompatActivity; +import androidx.core.content.ContextCompat; +import androidx.core.view.ViewCompat; +import androidx.loader.app.LoaderManager; +import androidx.loader.content.CursorLoader; +import androidx.loader.content.Loader; +import androidx.recyclerview.widget.RecyclerView; import com.nostra13.universalimageloader.core.DisplayImageOptions; import com.nostra13.universalimageloader.core.ImageLoader; import com.nostra13.universalimageloader.core.display.FadeInBitmapDisplayer; @@ -27,6 +27,7 @@ import org.fdroid.fdroid.R; import org.fdroid.fdroid.Utils; import org.fdroid.fdroid.data.AppProvider; import org.fdroid.fdroid.data.Schema; +import org.fdroid.fdroid.data.Schema.AppMetadataTable.Cols; import org.fdroid.fdroid.views.apps.AppListActivity; import org.fdroid.fdroid.views.apps.FeatureImage; @@ -43,7 +44,7 @@ public class CategoryController extends RecyclerView.ViewHolder implements Loade private final AppCompatActivity activity; private final LoaderManager loaderManager; private final DisplayImageOptions displayImageOptions; - private static int categoryItemCount = 20; + private static final int NUM_OF_APPS_PER_CATEGORY_ON_OVERVIEW = 20; private String currentCategory; @@ -133,9 +134,24 @@ public class CategoryController extends RecyclerView.ViewHolder implements Loade return Color.HSVToColor(hsv); } + /** + * Return either the total apps in the category, or the entries to display + * for a category, depending on the value of {@code id}. This uses a sort + * similar to the one in {@link org.fdroid.fdroid.views.main.LatestViewBinder#onCreateLoader(int, Bundle)}. + * The difference is that this does not treat "new" app any differently. + * + * @see AppProvider#getCategoryUri(String) + * @see AppProvider#getTopFromCategoryUri(String, int) + * @see AppProvider#query(android.net.Uri, String[], String, String[], String) + * @see AppProvider#TOP_FROM_CATEGORY + * @see org.fdroid.fdroid.views.main.LatestViewBinder#onCreateLoader(int, Bundle) + */ @NonNull @Override public Loader onCreateLoader(int id, Bundle args) { + final String table = Schema.AppMetadataTable.NAME; + final String added = table + "." + Cols.ADDED; + final String lastUpdated = table + "." + Cols.LAST_UPDATED; if (id == currentCategory.hashCode() + 1) { return new CursorLoader( activity, @@ -148,7 +164,7 @@ public class CategoryController extends RecyclerView.ViewHolder implements Loade } else { return new CursorLoader( activity, - AppProvider.getTopFromCategoryUri(currentCategory, categoryItemCount), + AppProvider.getTopFromCategoryUri(currentCategory, NUM_OF_APPS_PER_CATEGORY_ON_OVERVIEW), new String[]{ Schema.AppMetadataTable.Cols.NAME, Schema.AppMetadataTable.Cols.Package.PACKAGE_NAME, @@ -159,7 +175,22 @@ public class CategoryController extends RecyclerView.ViewHolder implements Loade }, null, null, - Schema.AppMetadataTable.Cols.NAME + 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" + + ", " + lastUpdated + " DESC" + + ", " + added + " ASC" ); } } @@ -231,7 +262,7 @@ public class CategoryController extends RecyclerView.ViewHolder implements Loade boolean isLtr = ViewCompat.getLayoutDirection(parent) == ViewCompat.LAYOUT_DIRECTION_LTR; int itemPosition = parent.getChildLayoutPosition(view); boolean first = itemPosition == 0; - boolean end = itemPosition == categoryItemCount - 1; + boolean end = itemPosition == NUM_OF_APPS_PER_CATEGORY_ON_OVERVIEW - 1; // Leave this "paddingEnd" local variable here for clarity when converting from // left/right to start/end for RTL friendly layout. diff --git a/app/src/main/java/org/fdroid/fdroid/views/main/LatestViewBinder.java b/app/src/main/java/org/fdroid/fdroid/views/main/LatestViewBinder.java index dee85b698..685136b84 100644 --- a/app/src/main/java/org/fdroid/fdroid/views/main/LatestViewBinder.java +++ b/app/src/main/java/org/fdroid/fdroid/views/main/LatestViewBinder.java @@ -24,8 +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.data.Schema.AppMetadataTable.Cols; +import org.fdroid.fdroid.data.Schema.RepoTable; import org.fdroid.fdroid.panic.HidingManager; import org.fdroid.fdroid.views.apps.AppListActivity; +import org.fdroid.fdroid.views.categories.AppCardController; import java.util.Date; @@ -94,6 +97,13 @@ class LatestViewBinder implements LoaderManager.LoaderCallbacks { } /** + * 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. + * * @see AppProvider#getLatestTabUri() */ @NonNull @@ -102,14 +112,38 @@ class LatestViewBinder implements LoaderManager.LoaderCallbacks { if (id != LOADER_ID) { return null; } + final String table = AppMetadataTable.NAME; + final String added = table + "." + Cols.ADDED; + final String lastUpdated = table + "." + Cols.LAST_UPDATED; return new CursorLoader( activity, AppProvider.getLatestTabUri(), AppMetadataTable.Cols.ALL, null, null, - null - ); + 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((SELECT " + RepoTable.Cols.LAST_UPDATED + " FROM " + RepoTable.NAME + + " WHERE _id=" + table + "." + Cols.REPO_ID + + " ),'-" + AppCardController.DAYS_TO_CONSIDER_NEW + " days') " + + " < date(" + lastUpdated + ")" + + " THEN 0 ELSE 1 END" + + ", " + table + "." + Cols.WHATSNEW + " IS NULL ASC" + + ", " + lastUpdated + " DESC" + + ", " + added + " ASC"); } @Override diff --git a/app/src/main/res/drawable/ic_access_time.xml b/app/src/main/res/drawable/ic_access_time.xml index 4f999321e..9cd21dd87 100644 --- a/app/src/main/res/drawable/ic_access_time.xml +++ b/app/src/main/res/drawable/ic_access_time.xml @@ -1,9 +1,15 @@ - - - + + + diff --git a/app/src/main/res/drawable/ic_sort.xml b/app/src/main/res/drawable/ic_sort.xml new file mode 100644 index 000000000..ce3583a0e --- /dev/null +++ b/app/src/main/res/drawable/ic_sort.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_sort_by_alpha.xml b/app/src/main/res/drawable/ic_sort_by_alpha.xml deleted file mode 100644 index cecad4568..000000000 --- a/app/src/main/res/drawable/ic_sort_by_alpha.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/layout/activity_app_list.xml b/app/src/main/res/layout/activity_app_list.xml index d30c8595a..3484e90d3 100644 --- a/app/src/main/res/layout/activity_app_list.xml +++ b/app/src/main/res/layout/activity_app_list.xml @@ -73,6 +73,8 @@ android:id="@+id/sort" android:layout_width="wrap_content" android:layout_height="wrap_content" + android:layout_marginStart="4dp" + android:layout_marginLeft="4dp" android:layout_marginEnd="4dp" android:layout_marginRight="4dp" android:contentDescription="@string/sort_search" diff --git a/app/src/test/java/org/fdroid/fdroid/data/CategoryProviderTest.java b/app/src/test/java/org/fdroid/fdroid/data/CategoryProviderTest.java index 8841f0fa7..abb625e4b 100644 --- a/app/src/test/java/org/fdroid/fdroid/data/CategoryProviderTest.java +++ b/app/src/test/java/org/fdroid/fdroid/data/CategoryProviderTest.java @@ -6,6 +6,7 @@ import android.content.ContentValues; import android.database.Cursor; import android.net.Uri; import org.fdroid.fdroid.TestUtils; +import org.fdroid.fdroid.Utils; import org.fdroid.fdroid.data.Schema.AppMetadataTable.Cols; import org.fdroid.fdroid.mock.MockRepo; import org.junit.Before; @@ -163,6 +164,11 @@ public class CategoryProviderTest extends FDroidProviderTest { AppProviderTest.assertContainsOnlyIds(apps, expectedPackages); } + /** + * This does not include {@code sortOrder} since that is defined in + * {@link org.fdroid.fdroid.views.categories.CategoryController#onCreateLoader(int, android.os.Bundle)} + * so these results are sorted by the default sort. + */ @Test public void topAppsFromCategory() { insertAppWithCategory("com.dog", "Dog", "Animal", new Date(2017, 2, 6)); @@ -178,13 +184,13 @@ public class CategoryProviderTest extends FDroidProviderTest { insertAppWithCategory("com.banana", "Banana", "Vegetable", new Date(2015, 1, 1)); insertAppWithCategory("com.tomato", "Tomato", "Vegetable", new Date(2017, 4, 4)); - assertArrayEquals(getTopAppsFromCategory("Animal", 3), new String[]{"com.dog", "com.cat", "com.bird"}); - assertArrayEquals(getTopAppsFromCategory("Animal", 2), new String[]{"com.dog", "com.cat"}); - assertArrayEquals(getTopAppsFromCategory("Animal", 1), new String[]{"com.dog"}); + assertArrayEquals(new String[]{"com.bird", "com.cat", "com.dog"}, getTopAppsFromCategory("Animal", 3)); + assertArrayEquals(new String[]{"com.bird", "com.cat"}, getTopAppsFromCategory("Animal", 2)); + assertArrayEquals(new String[]{"com.bird"}, getTopAppsFromCategory("Animal", 1)); - assertArrayEquals(getTopAppsFromCategory("Mineral", 2), new String[]{"com.rock", "com.stone"}); + assertArrayEquals(new String[]{"com.boulder", "com.rock"}, getTopAppsFromCategory("Mineral", 2)); - assertArrayEquals(getTopAppsFromCategory("Vegetable", 10), new String[]{"com.tomato", "com.banana"}); + assertArrayEquals(new String[]{"com.banana", "com.tomato"}, getTopAppsFromCategory("Vegetable", 10)); } public String[] getTopAppsFromCategory(String category, int numToGet) { @@ -275,7 +281,7 @@ public class CategoryProviderTest extends FDroidProviderTest { private void insertAppWithCategory(String id, String name, String categories, Date lastUpdated, long repoId) { ContentValues values = new ContentValues(2); values.put(Cols.ForWriting.Categories.CATEGORIES, categories); - values.put(Cols.LAST_UPDATED, lastUpdated.getTime() / 1000); + values.put(Cols.LAST_UPDATED, Utils.DATE_FORMAT.format(lastUpdated)); AppProviderTest.insertApp(contentResolver, context, id, name, values, repoId); }