Merge branch 'search-overhaul' into 'master'

overhaul the search based on two recent merge requests

See merge request fdroid/fdroidclient!972
This commit is contained in:
Hans-Christoph Steiner 2021-02-09 17:54:10 +00:00
commit 0e38288705
14 changed files with 293 additions and 109 deletions

View File

@ -3,28 +3,28 @@ 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 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.view.View;
import android.widget.FrameLayout; import android.widget.FrameLayout;
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.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.Preferences;
import org.fdroid.fdroid.R; import org.fdroid.fdroid.R;
import org.fdroid.fdroid.UpdateService; import org.fdroid.fdroid.UpdateService;
import org.fdroid.fdroid.Utils; import org.fdroid.fdroid.Utils;
import org.fdroid.fdroid.data.CategoryProvider; import org.fdroid.fdroid.data.CategoryProvider;
import org.fdroid.fdroid.data.Schema; import org.fdroid.fdroid.data.Schema;
import org.fdroid.fdroid.panic.HidingManager;
import org.fdroid.fdroid.views.apps.AppListActivity; import org.fdroid.fdroid.views.apps.AppListActivity;
import org.fdroid.fdroid.views.categories.CategoryAdapter; import org.fdroid.fdroid.views.categories.CategoryAdapter;
import org.fdroid.fdroid.views.categories.CategoryController; import org.fdroid.fdroid.views.categories.CategoryController;
import org.fdroid.fdroid.panic.HidingManager;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
@ -37,6 +37,7 @@ import java.util.List;
* view with relevant info about each. * view with relevant info about each.
*/ */
class CategoriesViewBinder implements LoaderManager.LoaderCallbacks<Cursor> { class CategoriesViewBinder implements LoaderManager.LoaderCallbacks<Cursor> {
public static final String TAG = "CategoriesViewBinder";
private static final int LOADER_ID = 429820532; private static final int LOADER_ID = 429820532;
@ -92,10 +93,11 @@ class CategoriesViewBinder implements LoaderManager.LoaderCallbacks<Cursor> {
activity.getSupportLoaderManager().restartLoader(LOADER_ID, null, this); activity.getSupportLoaderManager().restartLoader(LOADER_ID, null, this);
} }
@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; throw new IllegalArgumentException("id != LOADER_ID");
} }
return new CursorLoader( return new CursorLoader(
@ -110,7 +112,7 @@ class CategoriesViewBinder implements LoaderManager.LoaderCallbacks<Cursor> {
/** /**
* Reads all categories from the cursor and stores them in memory to provide to the {@link CategoryAdapter}. * Reads all categories from the cursor and stores them in memory to provide to the {@link CategoryAdapter}.
* * <p>
* It does this so it is easier to deal with localized/unlocalized categories without having * 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 * 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 * will grow so large as to make this a performance concern. If it does in the future, the

View File

@ -101,7 +101,7 @@ public final class Utils {
// The date format used for storing dates (e.g. lastupdated, added) in the // The date format used for storing dates (e.g. lastupdated, added) in the
// database. // database.
private static final SimpleDateFormat DATE_FORMAT = public static final SimpleDateFormat DATE_FORMAT =
new SimpleDateFormat("yyyy-MM-dd", Locale.ENGLISH); new SimpleDateFormat("yyyy-MM-dd", Locale.ENGLISH);
private static final SimpleDateFormat TIME_FORMAT = private static final SimpleDateFormat TIME_FORMAT =

View File

@ -506,6 +506,9 @@ public class AppProvider extends FDroidProvider {
return Uri.withAppendedPath(getContentUri(), PATH_CALC_SUGGESTED_APKS); return Uri.withAppendedPath(getContentUri(), PATH_CALC_SUGGESTED_APKS);
} }
/**
* Get all {@link App} entries in the given {@code category}
*/
public static Uri getCategoryUri(String category) { public static Uri getCategoryUri(String category) {
return getContentUri().buildUpon() return getContentUri().buildUpon()
.appendPath(PATH_CATEGORY) .appendPath(PATH_CATEGORY)
@ -519,6 +522,13 @@ public class AppProvider extends FDroidProvider {
.build(); .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) { public static Uri getTopFromCategoryUri(String category, int limit) {
return getContentUri().buildUpon() return getContentUri().buildUpon()
.appendPath(PATH_TOP_FROM_CATEGORY) .appendPath(PATH_TOP_FROM_CATEGORY)
@ -842,7 +852,6 @@ public class AppProvider extends FDroidProvider {
case TOP_FROM_CATEGORY: case TOP_FROM_CATEGORY:
selection = selection.add(queryCategory(pathSegments.get(2))); selection = selection.add(queryCategory(pathSegments.get(2)));
limit = Integer.parseInt(pathSegments.get(1)); limit = Integer.parseInt(pathSegments.get(1));
sortOrder = getTableName() + "." + Cols.LAST_UPDATED + " DESC";
includeSwap = false; includeSwap = false;
break; break;
@ -852,37 +861,6 @@ public class AppProvider extends FDroidProvider {
break; break;
case LATEST_TAB: 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 // 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.
// 200 is an arbitrary number which hopefully gives the user enough to scroll through // 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 * Helper method used by both the genuine {@link AppProvider} and the temporary version used
* by the repo updater ({@link TempAppProvider}). * by the repo updater ({@link TempAppProvider}).
* <p>
* Query the database table specified by {@code uri}, which is usually (always?)
* {@link AppMetadataTable} with specified {@code selection} and {@code sortOrder}.
* <b>WARNING:</b> 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) { protected Cursor runQuery(Uri uri, AppQuerySelection selection, String[] projection, boolean includeSwap, String sortOrder, int limit) {
if (!includeSwap) { if (!includeSwap) {
@ -1014,6 +998,7 @@ public class AppProvider extends FDroidProvider {
categoryValues.put(CatJoinTable.Cols.CATEGORY_ID, categoryId); categoryValues.put(CatJoinTable.Cols.CATEGORY_ID, categoryId);
db().insert(getCatJoinTableName(), null, categoryValues); db().insert(getCatJoinTableName(), null, categoryValues);
} }
getContext().getContentResolver().notifyChange(CategoryProvider.getContentUri(), null);
} }
} }

View File

@ -17,6 +17,7 @@ import java.util.Locale;
import java.util.Map; import java.util.Map;
public class CategoryProvider extends FDroidProvider { public class CategoryProvider extends FDroidProvider {
public static final String TAG = "CategoryProvider";
public static final class Helper { public static final class Helper {
private 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. * 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. * 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. * To improve performance, this caches the association between categories and their database IDs.
* * <p>
* It can stay around for the entire F-Droid process, even across multiple repo updates, as we * 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. * don't actually remove data from the categories table.
*/ */

View File

@ -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."); Utils.debugLog(TAG, "Removing all index tables, they will be recreated next time F-Droid updates.");
Preferences.get().resetLastUpdateCheck(); Preferences.get().resetLastUpdateCheck();
CategoryProvider.Helper.clearCategoryIdCache();
db.beginTransaction(); db.beginTransaction();
try { try {

View File

@ -20,11 +20,14 @@
package org.fdroid.fdroid.views.apps; package org.fdroid.fdroid.views.apps;
import android.content.Context;
import android.content.Intent; import android.content.Intent;
import android.content.SharedPreferences;
import android.database.Cursor; import android.database.Cursor;
import android.graphics.Color; import android.graphics.Color;
import android.graphics.drawable.Drawable; import android.graphics.drawable.Drawable;
import android.os.Bundle; import android.os.Bundle;
import android.text.TextUtils;
import android.view.KeyEvent; import android.view.KeyEvent;
import android.view.View; import android.view.View;
import android.view.inputmethod.EditorInfo; import android.view.inputmethod.EditorInfo;
@ -32,7 +35,6 @@ import android.view.inputmethod.InputMethodManager;
import android.widget.EditText; import android.widget.EditText;
import android.widget.ImageView; import android.widget.ImageView;
import android.widget.TextView; import android.widget.TextView;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.app.AppCompatActivity;
@ -43,14 +45,13 @@ import androidx.loader.content.CursorLoader;
import androidx.loader.content.Loader; import androidx.loader.content.Loader;
import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView; import androidx.recyclerview.widget.RecyclerView;
import com.nostra13.universalimageloader.core.ImageLoader; import com.nostra13.universalimageloader.core.ImageLoader;
import org.fdroid.fdroid.FDroidApp; import org.fdroid.fdroid.FDroidApp;
import org.fdroid.fdroid.R; import org.fdroid.fdroid.R;
import org.fdroid.fdroid.Utils; import org.fdroid.fdroid.Utils;
import org.fdroid.fdroid.data.AppProvider; 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. * 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<Cursor>, public class AppListActivity extends AppCompatActivity implements LoaderManager.LoaderCallbacks<Cursor>,
CategoryTextWatcher.SearchTermsChangedListener { CategoryTextWatcher.SearchTermsChangedListener {
public static final String TAG = "AppListActivity";
public static final String EXTRA_CATEGORY public static final String EXTRA_CATEGORY
= "org.fdroid.fdroid.views.apps.AppListActivity.EXTRA_CATEGORY"; = "org.fdroid.fdroid.views.apps.AppListActivity.EXTRA_CATEGORY";
public static final String EXTRA_SEARCH_TERMS public static final String EXTRA_SEARCH_TERMS
= "org.fdroid.fdroid.views.apps.AppListActivity.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 RecyclerView appView;
private AppListAdapter appAdapter; private AppListAdapter appAdapter;
private String category; private String category;
private String searchTerms; private String searchTerms;
private String sortClauseSelected = SortClause.LAST_UPDATED; private String sortClauseSelected;
private TextView emptyState; private TextView emptyState;
private EditText searchInput; private EditText searchInput;
private ImageView sortImage; private ImageView sortImage;
private Utils.KeyboardStateMonitor keyboardStateMonitor; private Utils.KeyboardStateMonitor keyboardStateMonitor;
private interface SortClause { private interface SortClause {
String NAME = Schema.AppMetadataTable.NAME + "." + Schema.AppMetadataTable.Cols.NAME + " asc"; String WORDS = Cols.NAME;
String LAST_UPDATED = Schema.AppMetadataTable.NAME + "." String LAST_UPDATED = Cols.LAST_UPDATED;
+ Schema.AppMetadataTable.Cols.LAST_UPDATED + " desc";
} }
@Override @Override
@ -88,7 +94,12 @@ public class AppListActivity extends AppCompatActivity implements LoaderManager.
keyboardStateMonitor = new Utils.KeyboardStateMonitor(findViewById(R.id.app_list_root)); 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 = (EditText) findViewById(R.id.search);
searchInput.setText(searchTerms);
searchInput.addTextChangedListener(new CategoryTextWatcher(this, searchInput, this)); searchInput.addTextChangedListener(new CategoryTextWatcher(this, searchInput, this));
searchInput.setOnEditorActionListener(new TextView.OnEditorActionListener() { searchInput.setOnEditorActionListener(new TextView.OnEditorActionListener() {
@Override @Override
@ -111,20 +122,26 @@ public class AppListActivity extends AppCompatActivity implements LoaderManager.
final Drawable lastUpdated = DrawableCompat.wrap(ContextCompat.getDrawable(this, final Drawable lastUpdated = DrawableCompat.wrap(ContextCompat.getDrawable(this,
R.drawable.ic_access_time)).mutate(); R.drawable.ic_access_time)).mutate();
DrawableCompat.setTint(lastUpdated, FDroidApp.isAppThemeLight() ? Color.BLACK : Color.WHITE); 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() { sortImage.setOnClickListener(new View.OnClickListener() {
@Override @Override
public void onClick(View view) { public void onClick(View view) {
if (sortClauseSelected.equalsIgnoreCase(SortClause.LAST_UPDATED)) { switch (sortClauseSelected) {
sortClauseSelected = SortClause.NAME; case SortClause.WORDS:
final Drawable alphabetical = DrawableCompat.wrap( sortClauseSelected = SortClause.LAST_UPDATED;
ContextCompat.getDrawable(AppListActivity.this, R.drawable.ic_sort_by_alpha)).mutate(); DrawableCompat.setTint(lastUpdated, FDroidApp.isAppThemeLight() ? Color.BLACK : Color.WHITE);
DrawableCompat.setTint(alphabetical, FDroidApp.isAppThemeLight() ? Color.BLACK : Color.WHITE); sortImage.setImageDrawable(lastUpdated);
sortImage.setImageDrawable(alphabetical); break;
} else { case SortClause.LAST_UPDATED:
sortClauseSelected = SortClause.LAST_UPDATED; sortClauseSelected = SortClause.WORDS;
sortImage.setImageDrawable(lastUpdated); 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); getSupportLoaderManager().restartLoader(0, null, AppListActivity.this);
appView.scrollToPosition(0); appView.scrollToPosition(0);
} }
@ -218,10 +235,10 @@ public class AppListActivity extends AppCompatActivity implements LoaderManager.
return new CursorLoader( return new CursorLoader(
this, this,
AppProvider.getSearchUri(searchTerms, category), AppProvider.getSearchUri(searchTerms, category),
Schema.AppMetadataTable.Cols.ALL, AppMetadataTable.Cols.ALL,
null, null,
null, null,
sortClauseSelected getSortOrder()
); );
} }
@ -247,5 +264,105 @@ public class AppListActivity extends AppCompatActivity implements LoaderManager.
this.category = category; this.category = category;
this.searchTerms = searchTerms; this.searchTerms = searchTerms;
getSupportLoaderManager().restartLoader(0, null, this); 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);
} }
} }

View File

@ -1,20 +1,18 @@
package org.fdroid.fdroid.views.categories; package org.fdroid.fdroid.views.categories;
import androidx.appcompat.app.AppCompatActivity;
import android.content.Intent; import android.content.Intent;
import android.os.Bundle; import android.os.Bundle;
import android.view.View; import android.view.View;
import android.widget.ImageView; import android.widget.ImageView;
import android.widget.TextView; import android.widget.TextView;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.app.ActivityOptionsCompat; import androidx.core.app.ActivityOptionsCompat;
import androidx.core.content.ContextCompat; import androidx.core.content.ContextCompat;
import androidx.core.util.Pair; import androidx.core.util.Pair;
import androidx.core.view.ViewCompat; import androidx.core.view.ViewCompat;
import androidx.recyclerview.widget.RecyclerView; import androidx.recyclerview.widget.RecyclerView;
import org.fdroid.fdroid.R; import org.fdroid.fdroid.R;
import org.fdroid.fdroid.Utils; import org.fdroid.fdroid.Utils;
import org.fdroid.fdroid.data.App; 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. * 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 @NonNull
private final ImageView icon; private final ImageView icon;

View File

@ -1,6 +1,5 @@
package org.fdroid.fdroid.views.categories; package org.fdroid.fdroid.views.categories;
import androidx.appcompat.app.AppCompatActivity;
import android.content.Context; import android.content.Context;
import android.content.Intent; import android.content.Intent;
import android.content.res.Resources; import android.content.res.Resources;
@ -8,18 +7,19 @@ import android.database.Cursor;
import android.graphics.Color; import android.graphics.Color;
import android.graphics.Rect; import android.graphics.Rect;
import android.os.Bundle; 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.view.View;
import android.widget.Button; import android.widget.Button;
import android.widget.FrameLayout; import android.widget.FrameLayout;
import android.widget.TextView; 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.DisplayImageOptions;
import com.nostra13.universalimageloader.core.ImageLoader; import com.nostra13.universalimageloader.core.ImageLoader;
import com.nostra13.universalimageloader.core.display.FadeInBitmapDisplayer; 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.Utils;
import org.fdroid.fdroid.data.AppProvider; import org.fdroid.fdroid.data.AppProvider;
import org.fdroid.fdroid.data.Schema; 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.AppListActivity;
import org.fdroid.fdroid.views.apps.FeatureImage; 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 AppCompatActivity activity;
private final LoaderManager loaderManager; private final LoaderManager loaderManager;
private final DisplayImageOptions displayImageOptions; private final DisplayImageOptions displayImageOptions;
private static int categoryItemCount = 20; private static final int NUM_OF_APPS_PER_CATEGORY_ON_OVERVIEW = 20;
private String currentCategory; private String currentCategory;
@ -133,9 +134,24 @@ public class CategoryController extends RecyclerView.ViewHolder implements Loade
return Color.HSVToColor(hsv); 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 @NonNull
@Override @Override
public Loader<Cursor> onCreateLoader(int id, Bundle args) { public Loader<Cursor> 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) { if (id == currentCategory.hashCode() + 1) {
return new CursorLoader( return new CursorLoader(
activity, activity,
@ -148,7 +164,7 @@ public class CategoryController extends RecyclerView.ViewHolder implements Loade
} else { } else {
return new CursorLoader( return new CursorLoader(
activity, activity,
AppProvider.getTopFromCategoryUri(currentCategory, categoryItemCount), AppProvider.getTopFromCategoryUri(currentCategory, NUM_OF_APPS_PER_CATEGORY_ON_OVERVIEW),
new String[]{ new String[]{
Schema.AppMetadataTable.Cols.NAME, Schema.AppMetadataTable.Cols.NAME,
Schema.AppMetadataTable.Cols.Package.PACKAGE_NAME, Schema.AppMetadataTable.Cols.Package.PACKAGE_NAME,
@ -159,7 +175,22 @@ public class CategoryController extends RecyclerView.ViewHolder implements Loade
}, },
null, null,
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; boolean isLtr = ViewCompat.getLayoutDirection(parent) == ViewCompat.LAYOUT_DIRECTION_LTR;
int itemPosition = parent.getChildLayoutPosition(view); int itemPosition = parent.getChildLayoutPosition(view);
boolean first = itemPosition == 0; 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 // Leave this "paddingEnd" local variable here for clarity when converting from
// left/right to start/end for RTL friendly layout. // left/right to start/end for RTL friendly layout.

View File

@ -24,8 +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.data.Schema.AppMetadataTable.Cols;
import org.fdroid.fdroid.data.Schema.RepoTable;
import org.fdroid.fdroid.panic.HidingManager; import org.fdroid.fdroid.panic.HidingManager;
import org.fdroid.fdroid.views.apps.AppListActivity; import org.fdroid.fdroid.views.apps.AppListActivity;
import org.fdroid.fdroid.views.categories.AppCardController;
import java.util.Date; import java.util.Date;
@ -94,6 +97,13 @@ class LatestViewBinder implements LoaderManager.LoaderCallbacks<Cursor> {
} }
/** /**
* 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() * @see AppProvider#getLatestTabUri()
*/ */
@NonNull @NonNull
@ -102,14 +112,38 @@ class LatestViewBinder implements LoaderManager.LoaderCallbacks<Cursor> {
if (id != LOADER_ID) { if (id != LOADER_ID) {
return null; return null;
} }
final String table = AppMetadataTable.NAME;
final String added = table + "." + Cols.ADDED;
final String lastUpdated = table + "." + Cols.LAST_UPDATED;
return new CursorLoader( return new CursorLoader(
activity, activity,
AppProvider.getLatestTabUri(), AppProvider.getLatestTabUri(),
AppMetadataTable.Cols.ALL, AppMetadataTable.Cols.ALL,
null, null,
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 @Override

View File

@ -1,9 +1,15 @@
<vector xmlns:tools="http://schemas.android.com/tools" <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:height="24dp" xmlns:tools="http://schemas.android.com/tools"
android:tint="?attr/colorControlNormal" android:width="36dp"
android:viewportHeight="24" android:viewportWidth="24" android:height="36dp"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android" android:tint="?attr/colorControlNormal"
tools:ignore="VectorRaster"> android:viewportWidth="24"
<path android:fillColor="#FFFFFF" android:pathData="M11.99,2C6.47,2 2,6.48 2,12s4.47,10 9.99,10C17.52,22 22,17.52 22,12S17.52,2 11.99,2zM12,20c-4.42,0 -8,-3.58 -8,-8s3.58,-8 8,-8 8,3.58 8,8 -3.58,8 -8,8z"/> android:viewportHeight="24"
<path android:fillColor="#FFFFFF" android:pathData="M12.5,7H11v6l5.25,3.15 0.75,-1.23 -4.5,-2.67z"/> tools:ignore="VectorRaster">
<path
android:pathData="M11.99,2C6.47,2 2,6.48 2,12s4.47,10 9.99,10C17.52,22 22,17.52 22,12S17.52,2 11.99,2zM12,20c-4.42,0 -8,-3.58 -8,-8s3.58,-8 8,-8 8,3.58 8,8 -3.58,8 -8,8z"
android:fillColor="#FFFFFF" />
<path
android:pathData="M12.5,7H11v6l5.25,3.15 0.75,-1.23 -4.5,-2.67z"
android:fillColor="#FFFFFF" />
</vector> </vector>

View File

@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:autoMirrored="true"
android:height="36dp"
android:width="36dp"
android:tint="?attr/colorControlNormal"
android:viewportHeight="24"
android:viewportWidth="24">
<path
android:fillColor="#FFFFFF"
android:pathData="M3,18h6v-2L3,16v2zM3,6v2h18L21,6L3,6zM3,13h12v-2L3,11v2z" />
</vector>

View File

@ -1,10 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFF"
android:pathData="M14.94,4.66h-4.72l2.36,-2.36zM10.25,19.37h4.66l-2.33,2.33zM6.1,6.27L1.6,17.73h1.84l0.92,-2.45h5.11l0.92,2.45h1.84L7.74,6.27L6.1,6.27zM4.97,13.64l1.94,-5.18 1.94,5.18L4.97,13.64zM15.73,16.14h6.12v1.59h-8.53v-1.29l5.92,-8.56h-5.88v-1.6h8.3v1.26l-5.93,8.6z" />
</vector>

View File

@ -73,6 +73,8 @@
android:id="@+id/sort" android:id="@+id/sort"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:layout_marginLeft="4dp"
android:layout_marginEnd="4dp" android:layout_marginEnd="4dp"
android:layout_marginRight="4dp" android:layout_marginRight="4dp"
android:contentDescription="@string/sort_search" android:contentDescription="@string/sort_search"

View File

@ -6,6 +6,7 @@ import android.content.ContentValues;
import android.database.Cursor; import android.database.Cursor;
import android.net.Uri; import android.net.Uri;
import org.fdroid.fdroid.TestUtils; import org.fdroid.fdroid.TestUtils;
import org.fdroid.fdroid.Utils;
import org.fdroid.fdroid.data.Schema.AppMetadataTable.Cols; import org.fdroid.fdroid.data.Schema.AppMetadataTable.Cols;
import org.fdroid.fdroid.mock.MockRepo; import org.fdroid.fdroid.mock.MockRepo;
import org.junit.Before; import org.junit.Before;
@ -163,6 +164,11 @@ public class CategoryProviderTest extends FDroidProviderTest {
AppProviderTest.assertContainsOnlyIds(apps, expectedPackages); 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 @Test
public void topAppsFromCategory() { public void topAppsFromCategory() {
insertAppWithCategory("com.dog", "Dog", "Animal", new Date(2017, 2, 6)); 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.banana", "Banana", "Vegetable", new Date(2015, 1, 1));
insertAppWithCategory("com.tomato", "Tomato", "Vegetable", new Date(2017, 4, 4)); insertAppWithCategory("com.tomato", "Tomato", "Vegetable", new Date(2017, 4, 4));
assertArrayEquals(getTopAppsFromCategory("Animal", 3), new String[]{"com.dog", "com.cat", "com.bird"}); assertArrayEquals(new String[]{"com.bird", "com.cat", "com.dog"}, getTopAppsFromCategory("Animal", 3));
assertArrayEquals(getTopAppsFromCategory("Animal", 2), new String[]{"com.dog", "com.cat"}); assertArrayEquals(new String[]{"com.bird", "com.cat"}, getTopAppsFromCategory("Animal", 2));
assertArrayEquals(getTopAppsFromCategory("Animal", 1), new String[]{"com.dog"}); 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) { 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) { private void insertAppWithCategory(String id, String name, String categories, Date lastUpdated, long repoId) {
ContentValues values = new ContentValues(2); ContentValues values = new ContentValues(2);
values.put(Cols.ForWriting.Categories.CATEGORIES, categories); 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); AppProviderTest.insertApp(contentResolver, context, id, name, values, repoId);
} }