diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 39cd25d89..6d927ae32 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -104,6 +104,11 @@
android:name="org.fdroid.fdroid.data.PackageProvider"
android:exported="false"/>
+
+
, Parcelable {
/**
* List of categories (as defined in the metadata documentation) or null if there aren't any.
+ * This is only populated when parsing a repository. If you need to know about the categories
+ * an app is in any other part of F-Droid, use the {@link CategoryProvider}.
*/
public String[] categories;
@@ -230,9 +232,6 @@ public class App extends ValueObject implements Comparable, Parcelable {
case Cols.LAST_UPDATED:
lastUpdated = Utils.parseDate(cursor.getString(i), null);
break;
- case Cols.CATEGORIES:
- categories = Utils.parseCommaSeparatedString(cursor.getString(i));
- break;
case Cols.ANTI_FEATURES:
antiFeatures = Utils.parseCommaSeparatedString(cursor.getString(i));
break;
@@ -504,7 +503,7 @@ public class App extends ValueObject implements Comparable, Parcelable {
values.put(Cols.SUGGESTED_VERSION_CODE, suggestedVersionCode);
values.put(Cols.UPSTREAM_VERSION_NAME, upstreamVersionName);
values.put(Cols.UPSTREAM_VERSION_CODE, upstreamVersionCode);
- values.put(Cols.CATEGORIES, Utils.serializeCommaSeparatedString(categories));
+ values.put(Cols.ForWriting.Categories.CATEGORIES, Utils.serializeCommaSeparatedString(categories));
values.put(Cols.ANTI_FEATURES, Utils.serializeCommaSeparatedString(antiFeatures));
values.put(Cols.REQUIREMENTS, Utils.serializeCommaSeparatedString(requirements));
values.put(Cols.IS_COMPATIBLE, compatible ? 1 : 0);
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 97c08fdb2..2745d75c8 100644
--- a/app/src/main/java/org/fdroid/fdroid/data/AppProvider.java
+++ b/app/src/main/java/org/fdroid/fdroid/data/AppProvider.java
@@ -10,19 +10,19 @@ import android.text.TextUtils;
import android.util.Log;
import org.fdroid.fdroid.Preferences;
-import org.fdroid.fdroid.R;
import org.fdroid.fdroid.Utils;
import org.fdroid.fdroid.data.Schema.ApkTable;
import org.fdroid.fdroid.data.Schema.AppPrefsTable;
import org.fdroid.fdroid.data.Schema.AppMetadataTable;
import org.fdroid.fdroid.data.Schema.AppMetadataTable.Cols;
+import org.fdroid.fdroid.data.Schema.CatJoinTable;
+import org.fdroid.fdroid.data.Schema.CategoryTable;
import org.fdroid.fdroid.data.Schema.InstalledAppTable;
import org.fdroid.fdroid.data.Schema.PackageTable;
import org.fdroid.fdroid.data.Schema.RepoTable;
import java.util.ArrayList;
import java.util.Arrays;
-import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
@@ -92,51 +92,6 @@ public class AppProvider extends FDroidProvider {
return apps;
}
- public static String getCategoryAll(Context context) {
- return context.getString(R.string.category_All);
- }
-
- public static String getCategoryWhatsNew(Context context) {
- return context.getString(R.string.category_Whats_New);
- }
-
- public static String getCategoryRecentlyUpdated(Context context) {
- return context.getString(R.string.category_Recently_Updated);
- }
-
- public static List categories(Context context) {
- final ContentResolver resolver = context.getContentResolver();
- final Uri uri = getContentUri();
- final String[] projection = {Cols.CATEGORIES};
- final Cursor cursor = resolver.query(uri, projection, null, null, null);
- final Set categorySet = new HashSet<>();
- if (cursor != null) {
- if (cursor.getCount() > 0) {
- cursor.moveToFirst();
- while (!cursor.isAfterLast()) {
- final String categoriesString = cursor.getString(0);
- String[] categoriesList = Utils.parseCommaSeparatedString(categoriesString);
- if (categoriesList != null) {
- Collections.addAll(categorySet, categoriesList);
- }
- cursor.moveToNext();
- }
- }
- cursor.close();
- }
- final List categories = new ArrayList<>(categorySet);
- Collections.sort(categories);
-
- // Populate the category list with the real categories, and the
- // locally generated meta-categories for "What's New", "Recently
- // Updated" and "All"...
- categories.add(0, getCategoryAll(context));
- categories.add(0, getCategoryRecentlyUpdated(context));
- categories.add(0, getCategoryWhatsNew(context));
-
- return categories;
- }
-
public static App findHighestPriorityMetadata(ContentResolver resolver, String packageName) {
final Uri uri = getHighestPriorityMetadataUri(packageName);
return cursorToApp(resolver.query(uri, Cols.ALL, null, null, null));
@@ -255,12 +210,11 @@ public class AppProvider extends FDroidProvider {
}
- private class Query extends QueryBuilder {
+ protected class Query extends QueryBuilder {
private boolean isSuggestedApkTableAdded;
private boolean requiresInstalledTable;
private boolean requiresLeftJoinToPrefs;
- private boolean categoryFieldAdded;
private boolean countFieldAppended;
@Override
@@ -269,21 +223,21 @@ public class AppProvider extends FDroidProvider {
final String app = getTableName();
final String apk = getApkTableName();
final String repo = RepoTable.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 + ") " +
" LEFT JOIN " + apk + " ON (" + apk + "." + ApkTable.Cols.APP_ID + " = " + app + "." + Cols.ROW_ID + ") ";
}
- @Override
- protected boolean isDistinct() {
- return fieldCount() == 1 && categoryFieldAdded;
- }
-
@Override
protected String groupBy() {
- // If the count field has been requested, then we want to group all rows together.
+ // If the count field has been requested, then we want to group all rows together. Otherwise
+ // we will only group all the rows belonging to a single app together.
return countFieldAppended ? null : getTableName() + "." + Cols.ROW_ID;
}
@@ -351,9 +305,6 @@ public class AppProvider extends FDroidProvider {
appendCountField();
break;
default:
- if (field.equals(Cols.CATEGORIES)) {
- categoryFieldAdded = true;
- }
appendField(field, getTableName());
break;
}
@@ -574,6 +525,10 @@ public class AppProvider extends FDroidProvider {
return AppMetadataTable.NAME;
}
+ protected String getCatJoinTableName() {
+ return CatJoinTable.NAME;
+ }
+
protected String getApkTableName() {
return ApkTable.NAME;
}
@@ -635,6 +590,7 @@ public class AppProvider extends FDroidProvider {
final String app = getTableName();
final String[] columns = {
PackageTable.NAME + "." + PackageTable.Cols.PACKAGE_NAME,
+ CategoryTable.NAME + "." + CategoryTable.Cols.NAME,
app + "." + Cols.NAME,
app + "." + Cols.SUMMARY,
app + "." + Cols.DESCRIPTION,
@@ -727,20 +683,8 @@ public class AppProvider extends FDroidProvider {
}
private AppQuerySelection queryCategory(String category) {
- // TODO: In the future, add a new table for categories,
- // so we can join onto it.
- final String app = getTableName();
- final String selection =
- app + "." + Cols.CATEGORIES + " = ? OR " + // Only category e.g. "internet"
- app + "." + Cols.CATEGORIES + " LIKE ? OR " + // First category e.g. "internet,%"
- app + "." + Cols.CATEGORIES + " LIKE ? OR " + // Last category e.g. "%,internet"
- app + "." + Cols.CATEGORIES + " LIKE ? "; // One of many categories e.g. "%,internet,%"
- final String[] args = {
- category,
- category + ",%",
- "%," + category,
- "%," + category + ",%",
- };
+ final String selection = CategoryTable.NAME + "." + CategoryTable.Cols.NAME + " = ? ";
+ final String[] args = {category};
return new AppQuerySelection(selection, args);
}
@@ -903,13 +847,51 @@ public class AppProvider extends FDroidProvider {
values.remove(Cols.Package.PACKAGE_NAME);
values.put(Cols.PACKAGE_ID, packageId);
- db().insertOrThrow(getTableName(), null, values);
+ String[] categories = null;
+ boolean saveCategories = false;
+ if (values.containsKey(Cols.ForWriting.Categories.CATEGORIES)) {
+ // Hold onto these categories, so that after we have an ID to reference the newly inserted
+ // app metadata we can then specify its categories.
+ saveCategories = true;
+ categories = Utils.parseCommaSeparatedString(values.getAsString(Cols.ForWriting.Categories.CATEGORIES));
+ values.remove(Cols.ForWriting.Categories.CATEGORIES);
+ }
+
+ long appMetadataId = db().insertOrThrow(getTableName(), null, values);
if (!isApplyingBatch()) {
getContext().getContentResolver().notifyChange(uri, null);
}
+
+ if (saveCategories) {
+ ensureCategories(categories, appMetadataId);
+ }
+
return getSpecificAppUri(values.getAsString(PackageTable.Cols.PACKAGE_NAME), values.getAsLong(Cols.REPO_ID));
}
+ protected void ensureCategories(String[] categories, long appMetadataId) {
+ db().delete(getCatJoinTableName(), CatJoinTable.Cols.APP_METADATA_ID + " = ?", new String[] {Long.toString(appMetadataId)});
+ if (categories != null) {
+ Set categoriesSet = new HashSet<>();
+ for (String categoryName : categories) {
+
+ // There is nothing stopping a server repeating a category name in the metadata of
+ // an app. In order to prevent unique constraint violations, only insert once into
+ // the join table.
+ if (categoriesSet.contains(categoryName)) {
+ continue;
+ }
+
+ categoriesSet.add(categoryName);
+ long categoryId = CategoryProvider.Helper.ensureExists(getContext(), categoryName);
+ ContentValues categoryValues = new ContentValues(2);
+ categoryValues.put(CatJoinTable.Cols.APP_METADATA_ID, appMetadataId);
+ categoryValues.put(CatJoinTable.Cols.CATEGORY_ID, categoryId);
+ db().insert(getCatJoinTableName(), null, categoryValues);
+ }
+ }
+ }
+
@Override
public int update(Uri uri, ContentValues values, String where, String[] whereArgs) {
if (MATCHER.match(uri) != CALC_SUGGESTED_APKS) {
diff --git a/app/src/main/java/org/fdroid/fdroid/data/CategoryProvider.java b/app/src/main/java/org/fdroid/fdroid/data/CategoryProvider.java
new file mode 100644
index 000000000..f8aa3badd
--- /dev/null
+++ b/app/src/main/java/org/fdroid/fdroid/data/CategoryProvider.java
@@ -0,0 +1,254 @@
+package org.fdroid.fdroid.data;
+
+import android.content.ContentResolver;
+import android.content.ContentValues;
+import android.content.Context;
+import android.content.UriMatcher;
+import android.database.Cursor;
+import android.net.Uri;
+import android.support.annotation.NonNull;
+
+import org.fdroid.fdroid.R;
+import org.fdroid.fdroid.data.Schema.CatJoinTable;
+import org.fdroid.fdroid.data.Schema.CategoryTable;
+import org.fdroid.fdroid.data.Schema.CategoryTable.Cols;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+public class CategoryProvider extends FDroidProvider {
+
+ public static final class Helper {
+ private Helper() { }
+
+ public static long ensureExists(Context context, String category) {
+ long id = getCategoryId(context, category);
+ if (id <= 0) {
+ ContentValues values = new ContentValues(1);
+ values.put(Cols.NAME, category);
+ Uri uri = context.getContentResolver().insert(getContentUri(), values);
+ id = Long.parseLong(uri.getLastPathSegment());
+ }
+ return id;
+ }
+
+ public static long getCategoryId(Context context, String category) {
+ String[] projection = new String[] {Cols.ROW_ID};
+ Cursor cursor = context.getContentResolver().query(getCategoryUri(category), projection, null, null, null);
+ if (cursor == null) {
+ return 0;
+ }
+
+ try {
+ if (cursor.getCount() == 0) {
+ return 0;
+ } else {
+ cursor.moveToFirst();
+ return cursor.getLong(cursor.getColumnIndexOrThrow(Cols.ROW_ID));
+ }
+ } finally {
+ cursor.close();
+ }
+ }
+
+ public static String getCategoryAll(Context context) {
+ return context.getString(R.string.category_All);
+ }
+
+ public static String getCategoryWhatsNew(Context context) {
+ return context.getString(R.string.category_Whats_New);
+ }
+
+ public static String getCategoryRecentlyUpdated(Context context) {
+ return context.getString(R.string.category_Recently_Updated);
+ }
+
+ public static List categories(Context context) {
+ final ContentResolver resolver = context.getContentResolver();
+ final Uri uri = CategoryProvider.getAllCategories();
+ final String[] projection = {Cols.NAME};
+ final Cursor cursor = resolver.query(uri, projection, null, null, null);
+ List categories = new ArrayList<>(30);
+ if (cursor != null) {
+ if (cursor.getCount() > 0) {
+ cursor.moveToFirst();
+ while (!cursor.isAfterLast()) {
+ final String name = cursor.getString(0);
+ categories.add(name);
+ cursor.moveToNext();
+ }
+ }
+ cursor.close();
+ }
+ Collections.sort(categories);
+
+ // Populate the category list with the real categories, and the
+ // locally generated meta-categories for "What's New", "Recently
+ // Updated" and "All"...
+ categories.add(0, getCategoryAll(context));
+ categories.add(0, getCategoryRecentlyUpdated(context));
+ categories.add(0, getCategoryWhatsNew(context));
+
+ return categories;
+ }
+ }
+
+ private class Query extends QueryBuilder {
+
+ private boolean onlyCategoriesWithApps;
+
+ @Override
+ protected String getRequiredTables() {
+ String joinType = onlyCategoriesWithApps ? " JOIN " : " LEFT JOIN ";
+
+ return CategoryTable.NAME + joinType + CatJoinTable.NAME + " ON (" +
+ CatJoinTable.Cols.CATEGORY_ID + " = " + CategoryTable.NAME + "." + Cols.ROW_ID + ") ";
+ }
+
+ @Override
+ public void addField(String field) {
+ appendField(field, getTableName());
+ }
+
+ @Override
+ protected String groupBy() {
+ return CategoryTable.NAME + "." + Cols.ROW_ID;
+ }
+
+ public void setOnlyCategoriesWithApps(boolean onlyCategoriesWithApps) {
+ this.onlyCategoriesWithApps = onlyCategoriesWithApps;
+ }
+ }
+
+ private static final String PROVIDER_NAME = "CategoryProvider";
+
+ private static final UriMatcher MATCHER = new UriMatcher(-1);
+
+ private static final String PATH_CATEGORY_NAME = "categoryName";
+ private static final String PATH_ALL_CATEGORIES = "all";
+ private static final String PATH_CATEGORY_ID = "categoryId";
+
+ static {
+ MATCHER.addURI(getAuthority(), PATH_CATEGORY_NAME + "/*", CODE_SINGLE);
+ MATCHER.addURI(getAuthority(), PATH_ALL_CATEGORIES, CODE_LIST);
+ }
+
+ private static Uri getContentUri() {
+ return Uri.parse("content://" + getAuthority());
+ }
+
+ public static Uri getAllCategories() {
+ return Uri.withAppendedPath(getContentUri(), PATH_ALL_CATEGORIES);
+ }
+
+ public static Uri getCategoryUri(String categoryName) {
+ return getContentUri()
+ .buildUpon()
+ .appendPath(PATH_CATEGORY_NAME)
+ .appendPath(categoryName)
+ .build();
+ }
+
+ /**
+ * Not actually used as part of the external API to this content provider.
+ * Rather, used as a mechanism for returning the ID of a newly inserted row after calling
+ * {@link android.content.ContentProvider#insert(Uri, ContentValues)}, as that is only able
+ * to return a {@link Uri}. The {@link Uri#getLastPathSegment()} of this URI contains a
+ * {@link Long} which is the {@link Cols#ROW_ID} of the newly inserted row.
+ */
+ private static Uri getCategoryIdUri(long categoryId) {
+ return getContentUri()
+ .buildUpon()
+ .appendPath(PATH_CATEGORY_ID)
+ .appendPath(Long.toString(categoryId))
+ .build();
+ }
+
+ @Override
+ protected String getTableName() {
+ return CategoryTable.NAME;
+ }
+
+ @Override
+ protected String getProviderName() {
+ return "CategoryProvider";
+ }
+
+ public static String getAuthority() {
+ return AUTHORITY + "." + PROVIDER_NAME;
+ }
+
+ @Override
+ protected UriMatcher getMatcher() {
+ return MATCHER;
+ }
+
+ protected QuerySelection querySingle(String categoryName) {
+ final String selection = getTableName() + "." + Cols.NAME + " = ?";
+ final String[] args = {categoryName};
+ return new QuerySelection(selection, args);
+ }
+
+ protected QuerySelection queryAllInUse() {
+ final String selection = CatJoinTable.NAME + "." + CatJoinTable.Cols.APP_METADATA_ID + " IS NOT NULL ";
+ final String[] args = {};
+ return new QuerySelection(selection, args);
+ }
+
+ @Override
+ public Cursor query(@NonNull Uri uri, String[] projection, String customSelection, String[] selectionArgs, String sortOrder) {
+ QuerySelection selection = new QuerySelection(customSelection, selectionArgs);
+ boolean onlyCategoriesWithApps = false;
+ switch (MATCHER.match(uri)) {
+ case CODE_SINGLE:
+ selection = selection.add(querySingle(uri.getLastPathSegment()));
+ break;
+
+ case CODE_LIST:
+ selection = selection.add(queryAllInUse());
+ onlyCategoriesWithApps = true;
+ break;
+
+ default:
+ throw new UnsupportedOperationException("Invalid URI for content provider: " + uri);
+ }
+
+ Query query = new Query();
+ query.addSelection(selection);
+ query.addFields(projection);
+ query.addOrderBy(sortOrder);
+ query.setOnlyCategoriesWithApps(onlyCategoriesWithApps);
+
+ Cursor cursor = LoggingQuery.query(db(), query.toString(), query.getArgs());
+ cursor.setNotificationUri(getContext().getContentResolver(), uri);
+ return cursor;
+ }
+
+ /**
+ * Deleting of categories is not required.
+ * It doesn't matter if we have a category in the database when no apps are in that category.
+ * They wont take up much space, and it is the presence of rows in the
+ * {@link CatJoinTable} which decides whether a category is displayed in F-Droid or not.
+ */
+ @Override
+ public int delete(@NonNull Uri uri, String where, String[] whereArgs) {
+ throw new UnsupportedOperationException("Delete not supported for " + uri + ".");
+ }
+
+ @Override
+ public Uri insert(@NonNull Uri uri, ContentValues values) {
+ long rowId = db().insertOrThrow(getTableName(), null, values);
+ getContext().getContentResolver().notifyChange(AppProvider.getCanUpdateUri(), null);
+ return getCategoryIdUri(rowId);
+ }
+
+ /**
+ * Category names never change. If an app originally is in category "Games" and then in a
+ * future repo update is now in "Games & Stuff", then both categories can exist quite happily.
+ */
+ @Override
+ public int update(@NonNull Uri uri, ContentValues values, String where, String[] whereArgs) {
+ throw new UnsupportedOperationException("Update not supported for " + uri + ".");
+ }
+}
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 5de7f6aea..4803e78b3 100644
--- a/app/src/main/java/org/fdroid/fdroid/data/DBHelper.java
+++ b/app/src/main/java/org/fdroid/fdroid/data/DBHelper.java
@@ -34,6 +34,7 @@ import android.util.Log;
import org.fdroid.fdroid.R;
import org.fdroid.fdroid.Utils;
import org.fdroid.fdroid.data.Schema.ApkTable;
+import org.fdroid.fdroid.data.Schema.CatJoinTable;
import org.fdroid.fdroid.data.Schema.PackageTable;
import org.fdroid.fdroid.data.Schema.AppPrefsTable;
import org.fdroid.fdroid.data.Schema.AppMetadataTable;
@@ -130,7 +131,6 @@ class DBHelper extends SQLiteOpenHelper {
+ AppMetadataTable.Cols.LITECOIN_ADDR + " string,"
+ AppMetadataTable.Cols.FLATTR_ID + " string,"
+ AppMetadataTable.Cols.REQUIREMENTS + " string,"
- + AppMetadataTable.Cols.CATEGORIES + " string,"
+ AppMetadataTable.Cols.ADDED + " string,"
+ AppMetadataTable.Cols.LAST_UPDATED + " string,"
+ AppMetadataTable.Cols.IS_COMPATIBLE + " int not null,"
@@ -145,6 +145,27 @@ class DBHelper extends SQLiteOpenHelper {
+ AppPrefsTable.Cols.IGNORE_ALL_UPDATES + " INT NOT NULL "
+ " );";
+ private static final String CREATE_TABLE_CATEGORY = "CREATE TABLE " + Schema.CategoryTable.NAME
+ + " ( "
+ + Schema.CategoryTable.Cols.NAME + " TEXT NOT NULL "
+ + " );";
+
+ /**
+ * The order of the two columns in the primary key matters for this table. The index that is
+ * built for sqlite to quickly search the primary key will be sorted by app metadata id first,
+ * and category id second. This means that we don't need a separate individual index on the
+ * app metadata id, because it can instead look through the primary key index. This can be
+ * observed by flipping the order of the primary key columns, and noting the resulting sqlite
+ * logs along the lines of:
+ * E/SQLiteLog(14164): (284) automatic index on fdroid_categoryAppMetadataJoin(appMetadataId)
+ */
+ static final String CREATE_TABLE_CAT_JOIN = "CREATE TABLE " + CatJoinTable.NAME
+ + " ( "
+ + CatJoinTable.Cols.APP_METADATA_ID + " INT NOT NULL, "
+ + CatJoinTable.Cols.CATEGORY_ID + " INT NOT NULL, "
+ + "primary key(" + CatJoinTable.Cols.APP_METADATA_ID + ", " + CatJoinTable.Cols.CATEGORY_ID + ") "
+ + " );";
+
private static final String CREATE_TABLE_INSTALLED_APP = "CREATE TABLE " + InstalledAppTable.NAME
+ " ( "
+ InstalledAppTable.Cols.PACKAGE_NAME + " TEXT NOT NULL PRIMARY KEY, "
@@ -157,7 +178,7 @@ class DBHelper extends SQLiteOpenHelper {
+ InstalledAppTable.Cols.HASH + " TEXT NOT NULL"
+ " );";
- protected static final int DB_VERSION = 64;
+ protected static final int DB_VERSION = 65;
private final Context context;
@@ -172,6 +193,8 @@ class DBHelper extends SQLiteOpenHelper {
db.execSQL(CREATE_TABLE_PACKAGE);
db.execSQL(CREATE_TABLE_APP_METADATA);
db.execSQL(CREATE_TABLE_APK);
+ db.execSQL(CREATE_TABLE_CATEGORY);
+ db.execSQL(CREATE_TABLE_CAT_JOIN);
db.execSQL(CREATE_TABLE_INSTALLED_APP);
db.execSQL(CREATE_TABLE_REPO);
db.execSQL(CREATE_TABLE_APP_PREFS);
@@ -234,6 +257,23 @@ class DBHelper extends SQLiteOpenHelper {
supportRepoPushRequests(db, oldVersion);
migrateToPackageTable(db, oldVersion);
addObbFiles(db, oldVersion);
+ addCategoryTables(db, oldVersion);
+ }
+
+ /**
+ * It is possible to correctly migrate categories from the previous `categories` column in
+ * app metadata to the new join table without destroying any data and requiring a repo update.
+ * However, in practice other code since the previous stable has already reset the transient
+ * tables and forced a repo update, so it is much easier to do the same here. It wont have any
+ * negative impact on those upgrading from the previous stable. If there was a number of solid
+ * alpha releases before this, then a proper migration would've be in order.
+ */
+ private void addCategoryTables(SQLiteDatabase db, int oldVersion) {
+ if (oldVersion >= 65) {
+ return;
+ }
+
+ resetTransient(db);
}
private void addObbFiles(SQLiteDatabase db, int oldVersion) {
@@ -823,15 +863,26 @@ class DBHelper extends SQLiteOpenHelper {
db.beginTransaction();
try {
+ if (tableExists(db, Schema.CategoryTable.NAME)) {
+ db.execSQL("DROP TABLE " + Schema.CategoryTable.NAME);
+ }
+
+ if (tableExists(db, CatJoinTable.NAME)) {
+ db.execSQL("DROP TABLE " + CatJoinTable.NAME);
+ }
+
if (tableExists(db, PackageTable.NAME)) {
db.execSQL("DROP TABLE " + PackageTable.NAME);
}
db.execSQL("DROP TABLE " + AppMetadataTable.NAME);
db.execSQL("DROP TABLE " + ApkTable.NAME);
+
db.execSQL(CREATE_TABLE_PACKAGE);
db.execSQL(CREATE_TABLE_APP_METADATA);
db.execSQL(CREATE_TABLE_APK);
+ db.execSQL(CREATE_TABLE_CATEGORY);
+ db.execSQL(CREATE_TABLE_CAT_JOIN);
clearRepoEtags(db);
ensureIndexes(db);
db.setTransactionSuccessful();
diff --git a/app/src/main/java/org/fdroid/fdroid/data/Schema.java b/app/src/main/java/org/fdroid/fdroid/data/Schema.java
index 8f39d62ed..9d1a7a439 100644
--- a/app/src/main/java/org/fdroid/fdroid/data/Schema.java
+++ b/app/src/main/java/org/fdroid/fdroid/data/Schema.java
@@ -9,6 +9,15 @@ import android.provider.BaseColumns;
*/
public interface Schema {
+ /**
+ * A package is essentially the app that a developer builds and wants you to install on your
+ * device. It differs from entries in:
+ * * {@link ApkTable} because they are specific builds of a particular package. Many different
+ * builds of the same package can exist.
+ * * {@link AppMetadataTable} because this is metdata about a package which is specified by a
+ * given repo. Different repos can provide the same package with different descriptions,
+ * categories, etc.
+ */
interface PackageTable {
String NAME = "fdroid_package";
@@ -50,6 +59,51 @@ public interface Schema {
}
}
+ interface CategoryTable {
+
+ String NAME = "fdroid_category";
+
+ interface Cols {
+ String ROW_ID = "rowid";
+ String NAME = "name";
+
+ String[] ALL = {
+ ROW_ID, NAME,
+ };
+ }
+ }
+
+ /**
+ * An entry in this table signifies that an app is in a particular category. Each repo can
+ * classify its apps in separate categories, and so the same record in {@link PackageTable}
+ * can be in the same category multiple times, if multiple repos think that is the case.
+ * @see CategoryTable
+ * @see AppMetadataTable
+ */
+ interface CatJoinTable {
+
+ String NAME = "fdroid_categoryAppMetadataJoin";
+
+ interface Cols {
+ /**
+ * Foreign key to {@link AppMetadataTable}.
+ * @see AppMetadataTable
+ */
+ String APP_METADATA_ID = "appMetadataId";
+
+ /**
+ * Foreign key to {@link CategoryTable}.
+ * @see CategoryTable
+ */
+ String CATEGORY_ID = "categoryId";
+
+ /**
+ * @see AppMetadataTable.Cols#ALL_COLS
+ */
+ String[] ALL_COLS = {APP_METADATA_ID, CATEGORY_ID};
+ }
+ }
+
interface AppMetadataTable {
String NAME = "fdroid_app";
@@ -85,7 +139,6 @@ public interface Schema {
String UPSTREAM_VERSION_CODE = "upstreamVercode";
String ADDED = "added";
String LAST_UPDATED = "lastUpdated";
- String CATEGORIES = "categories";
String ANTI_FEATURES = "antiFeatures";
String REQUIREMENTS = "requirements";
String ICON_URL = "iconUrl";
@@ -105,6 +158,17 @@ public interface Schema {
String PACKAGE_NAME = "package_packageName";
}
+ /**
+ * This is to make it explicit that you cannot request the {@link Categories#CATEGORIES}
+ * field when selecting app metadata from the database. It is only here for the purpose
+ * of inserting/updating apps.
+ */
+ interface ForWriting {
+ interface Categories {
+ String CATEGORIES = "categories_commaSeparatedCateogryNames";
+ }
+ }
+
/**
* Each of the physical columns in the sqlite table. Differs from {@link Cols#ALL} in
* that it doesn't include fields which are aliases of other fields (e.g. {@link Cols#_ID}
@@ -115,7 +179,7 @@ public interface Schema {
LICENSE, AUTHOR, EMAIL, WEB_URL, TRACKER_URL, SOURCE_URL,
CHANGELOG_URL, DONATE_URL, BITCOIN_ADDR, LITECOIN_ADDR, FLATTR_ID,
UPSTREAM_VERSION_NAME, UPSTREAM_VERSION_CODE, ADDED, LAST_UPDATED,
- CATEGORIES, ANTI_FEATURES, REQUIREMENTS, ICON_URL, ICON_URL_LARGE,
+ ANTI_FEATURES, REQUIREMENTS, ICON_URL, ICON_URL_LARGE,
SUGGESTED_VERSION_CODE,
};
@@ -129,7 +193,7 @@ public interface Schema {
LICENSE, AUTHOR, EMAIL, WEB_URL, TRACKER_URL, SOURCE_URL,
CHANGELOG_URL, DONATE_URL, BITCOIN_ADDR, LITECOIN_ADDR, FLATTR_ID,
UPSTREAM_VERSION_NAME, UPSTREAM_VERSION_CODE, ADDED, LAST_UPDATED,
- CATEGORIES, ANTI_FEATURES, REQUIREMENTS, ICON_URL, ICON_URL_LARGE,
+ ANTI_FEATURES, REQUIREMENTS, ICON_URL, ICON_URL_LARGE,
SUGGESTED_VERSION_CODE, SuggestedApk.VERSION_NAME,
InstalledApp.VERSION_CODE, InstalledApp.VERSION_NAME,
InstalledApp.SIGNATURE, Package.PACKAGE_NAME,
diff --git a/app/src/main/java/org/fdroid/fdroid/data/TempAppProvider.java b/app/src/main/java/org/fdroid/fdroid/data/TempAppProvider.java
index 1d3021e38..910de6205 100644
--- a/app/src/main/java/org/fdroid/fdroid/data/TempAppProvider.java
+++ b/app/src/main/java/org/fdroid/fdroid/data/TempAppProvider.java
@@ -11,9 +11,11 @@ import android.text.TextUtils;
import java.util.List;
+import org.fdroid.fdroid.Utils;
import org.fdroid.fdroid.data.Schema.ApkTable;
import org.fdroid.fdroid.data.Schema.AppMetadataTable;
import org.fdroid.fdroid.data.Schema.AppMetadataTable.Cols;
+import org.fdroid.fdroid.data.Schema.CatJoinTable;
import org.fdroid.fdroid.data.Schema.PackageTable;
/**
@@ -29,6 +31,7 @@ public class TempAppProvider extends AppProvider {
private static final String PROVIDER_NAME = "TempAppProvider";
static final String TABLE_TEMP_APP = "temp_" + AppMetadataTable.NAME;
+ static final String TABLE_TEMP_CAT_JOIN = "temp_" + CatJoinTable.NAME;
private static final String PATH_INIT = "init";
private static final String PATH_COMMIT = "commit";
@@ -51,6 +54,11 @@ public class TempAppProvider extends AppProvider {
return TABLE_TEMP_APP;
}
+ @Override
+ protected String getCatJoinTableName() {
+ return TABLE_TEMP_CAT_JOIN;
+ }
+
public static String getAuthority() {
return AUTHORITY + "." + PROVIDER_NAME;
}
@@ -153,6 +161,12 @@ public class TempAppProvider extends AppProvider {
// Package names for apps cannot change...
values.remove(Cols.Package.PACKAGE_NAME);
+ if (values.containsKey(Cols.ForWriting.Categories.CATEGORIES)) {
+ String[] categories = Utils.parseCommaSeparatedString(values.getAsString(Cols.ForWriting.Categories.CATEGORIES));
+ ensureCategories(categories, packageName, repoId);
+ values.remove(Cols.ForWriting.Categories.CATEGORIES);
+ }
+
int count = db().update(getTableName(), values, query.getSelection(), query.getArgs());
if (!isApplyingBatch()) {
getContext().getContentResolver().notifyChange(getHighestPriorityMetadataUri(packageName), null);
@@ -160,6 +174,18 @@ public class TempAppProvider extends AppProvider {
return count;
}
+ private void ensureCategories(String[] categories, String packageName, long repoId) {
+ Query query = new AppProvider.Query();
+ query.addField(Cols.ROW_ID);
+ query.addSelection(querySingle(packageName, repoId));
+ Cursor cursor = db().rawQuery(query.toString(), query.getArgs());
+ cursor.moveToFirst();
+ long appMetadataId = cursor.getLong(0);
+ cursor.close();
+
+ ensureCategories(categories, appMetadataId);
+ }
+
@Override
public Cursor query(Uri uri, String[] projection, String customSelection, String[] selectionArgs, String sortOrder) {
AppQuerySelection selection = new AppQuerySelection(customSelection, selectionArgs);
@@ -188,7 +214,9 @@ public class TempAppProvider extends AppProvider {
ensureTempTableDetached(db);
db.execSQL("ATTACH DATABASE ':memory:' AS " + DB);
db.execSQL(DBHelper.CREATE_TABLE_APP_METADATA.replaceFirst(AppMetadataTable.NAME, DB + "." + getTableName()));
+ db.execSQL(DBHelper.CREATE_TABLE_CAT_JOIN.replaceFirst(CatJoinTable.NAME, DB + "." + getCatJoinTableName()));
db.execSQL(copyData(AppMetadataTable.Cols.ALL_COLS, AppMetadataTable.NAME, DB + "." + getTableName()));
+ db.execSQL(copyData(CatJoinTable.Cols.ALL_COLS, CatJoinTable.NAME, DB + "." + getCatJoinTableName()));
db.execSQL("CREATE INDEX IF NOT EXISTS " + DB + ".app_id ON " + getTableName() + " (" + AppMetadataTable.Cols.PACKAGE_ID + ");");
db.execSQL("CREATE INDEX IF NOT EXISTS " + DB + ".app_upstreamVercode ON " + getTableName() + " (" + AppMetadataTable.Cols.UPSTREAM_VERSION_CODE + ");");
db.execSQL("CREATE INDEX IF NOT EXISTS " + DB + ".app_compatible ON " + getTableName() + " (" + AppMetadataTable.Cols.IS_COMPATIBLE + ");");
@@ -208,8 +236,9 @@ public class TempAppProvider extends AppProvider {
try {
db.beginTransaction();
- final String tempApp = DB + "." + TempAppProvider.TABLE_TEMP_APP;
+ final String tempApp = DB + "." + TABLE_TEMP_APP;
final String tempApk = DB + "." + TempApkProvider.TABLE_TEMP_APK;
+ final String tempCatJoin = DB + "." + TABLE_TEMP_CAT_JOIN;
db.execSQL("DELETE FROM " + AppMetadataTable.NAME + " WHERE 1");
db.execSQL(copyData(AppMetadataTable.Cols.ALL_COLS, tempApp, AppMetadataTable.NAME));
@@ -217,6 +246,9 @@ public class TempAppProvider extends AppProvider {
db.execSQL("DELETE FROM " + ApkTable.NAME + " WHERE 1");
db.execSQL(copyData(ApkTable.Cols.ALL_COLS, tempApk, ApkTable.NAME));
+ db.execSQL("DELETE FROM " + CatJoinTable.NAME + " WHERE 1");
+ db.execSQL(copyData(CatJoinTable.Cols.ALL_COLS, tempCatJoin, CatJoinTable.NAME));
+
db.setTransactionSuccessful();
getContext().getContentResolver().notifyChange(AppProvider.getContentUri(), null);
diff --git a/app/src/main/java/org/fdroid/fdroid/views/fragments/AvailableAppsFragment.java b/app/src/main/java/org/fdroid/fdroid/views/fragments/AvailableAppsFragment.java
index fe770ea66..dfd4587aa 100644
--- a/app/src/main/java/org/fdroid/fdroid/views/fragments/AvailableAppsFragment.java
+++ b/app/src/main/java/org/fdroid/fdroid/views/fragments/AvailableAppsFragment.java
@@ -26,6 +26,7 @@ import org.fdroid.fdroid.Utils;
import org.fdroid.fdroid.compat.ArrayAdapterCompat;
import org.fdroid.fdroid.compat.CursorAdapterCompat;
import org.fdroid.fdroid.data.AppProvider;
+import org.fdroid.fdroid.data.CategoryProvider;
import org.fdroid.fdroid.views.AppListAdapter;
import org.fdroid.fdroid.views.AvailableAppListAdapter;
@@ -94,7 +95,7 @@ public class AvailableAppsFragment extends AppListFragment implements
new AsyncTask>() {
@Override
protected List doInBackground(Void... params) {
- return AppProvider.Helper.categories(activity);
+ return CategoryProvider.Helper.categories(activity);
}
@Override
@@ -132,7 +133,7 @@ public class AvailableAppsFragment extends AppListFragment implements
categorySpinner = spinner;
categorySpinner.setId(R.id.category_spinner);
- categories = AppProvider.Helper.categories(getActivity());
+ categories = CategoryProvider.Helper.categories(getActivity());
ArrayAdapter adapter = new ArrayAdapter<>(
getActivity(), android.R.layout.simple_spinner_item, translateCategories(getActivity(), categories));
@@ -163,20 +164,20 @@ public class AvailableAppsFragment extends AppListFragment implements
categoryWrapper = view.findViewById(R.id.category_wrapper);
setupCategorySpinner((Spinner) view.findViewById(R.id.category_spinner));
- defaultCategory = AppProvider.Helper.getCategoryWhatsNew(getActivity());
+ defaultCategory = CategoryProvider.Helper.getCategoryWhatsNew(getActivity());
return view;
}
@Override
protected Uri getDataUri() {
- if (currentCategory == null || currentCategory.equals(AppProvider.Helper.getCategoryAll(getActivity()))) {
+ if (currentCategory == null || currentCategory.equals(CategoryProvider.Helper.getCategoryAll(getActivity()))) {
return AppProvider.getContentUri();
}
- if (currentCategory.equals(AppProvider.Helper.getCategoryRecentlyUpdated(getActivity()))) {
+ if (currentCategory.equals(CategoryProvider.Helper.getCategoryRecentlyUpdated(getActivity()))) {
return AppProvider.getRecentlyUpdatedUri();
}
- if (currentCategory.equals(AppProvider.Helper.getCategoryWhatsNew(getActivity()))) {
+ if (currentCategory.equals(CategoryProvider.Helper.getCategoryWhatsNew(getActivity()))) {
return AppProvider.getNewlyAddedUri();
}
return AppProvider.getCategoryUri(currentCategory);
diff --git a/app/src/main/res/layout/app_details_summary.xml b/app/src/main/res/layout/app_details_summary.xml
index 212b813ab..7d4738445 100644
--- a/app/src/main/res/layout/app_details_summary.xml
+++ b/app/src/main/res/layout/app_details_summary.xml
@@ -48,15 +48,6 @@
android:layout_height="wrap_content"
android:textSize="12sp" />
-
-
-
categories = AppProvider.Helper.categories(context);
+ List categories = CategoryProvider.Helper.categories(context);
String[] expected = new String[] {
context.getResources().getString(R.string.category_Whats_New),
context.getResources().getString(R.string.category_Recently_Updated),
@@ -111,7 +111,7 @@ public class CategoryProviderTest extends FDroidProviderTest {
insertAppWithCategory("com.dog.rock.apple", "Dog-Rock-Apple", "Animal,Mineral,Vegetable");
insertAppWithCategory("com.banana.apple", "Banana", "Vegetable,Vegetable");
- List categories = AppProvider.Helper.categories(context);
+ List categories = CategoryProvider.Helper.categories(context);
String[] expected = new String[] {
context.getResources().getString(R.string.category_Whats_New),
context.getResources().getString(R.string.category_Recently_Updated),
@@ -127,7 +127,7 @@ public class CategoryProviderTest extends FDroidProviderTest {
"Running,Shooting,Jumping,Bleh,Sneh,Pleh,Blah,Test category," +
"The quick brown fox jumps over the lazy dog,With apostrophe's");
- List categoriesLonger = AppProvider.Helper.categories(context);
+ List categoriesLonger = CategoryProvider.Helper.categories(context);
String[] expectedLonger = new String[] {
context.getResources().getString(R.string.category_Whats_New),
context.getResources().getString(R.string.category_Recently_Updated),
@@ -154,7 +154,7 @@ public class CategoryProviderTest extends FDroidProviderTest {
private void insertAppWithCategory(String id, String name, String categories) {
ContentValues values = new ContentValues(1);
- values.put(Cols.CATEGORIES, categories);
+ values.put(Cols.ForWriting.Categories.CATEGORIES, categories);
AppProviderTest.insertApp(contentResolver, context, id, name, values);
}
}
diff --git a/app/src/test/java/org/fdroid/fdroid/updater/FDroidRepoUpdateTest.java b/app/src/test/java/org/fdroid/fdroid/updater/FDroidRepoUpdateTest.java
new file mode 100644
index 000000000..11d1586bb
--- /dev/null
+++ b/app/src/test/java/org/fdroid/fdroid/updater/FDroidRepoUpdateTest.java
@@ -0,0 +1,45 @@
+
+package org.fdroid.fdroid.updater;
+
+import org.fdroid.fdroid.BuildConfig;
+import org.fdroid.fdroid.RepoUpdater;
+import org.fdroid.fdroid.Utils;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricGradleTestRunner;
+import org.robolectric.annotation.Config;
+
+/**
+ * Tests two versions of the official main F-Droid metadata, from 10 days apart. This is here
+ * because there is so much metadata to parse in the main repo, covering many different aspects
+ * of the available metadata. Some apps will be added, others updated, and it should all just work.
+ */
+// TODO: Use sdk=24 when Robolectric supports this
+@Config(constants = BuildConfig.class, sdk = 23)
+@RunWith(RobolectricGradleTestRunner.class)
+public class FDroidRepoUpdateTest extends MultiRepoUpdaterTest {
+
+ private static final String TAG = "FDroidRepoUpdateTest";
+
+ private static final String REPO_FDROID = "F-Droid";
+ private static final String REPO_FDROID_URI = "https://f-droid.org/repo";
+ private static final String REPO_FDROID_PUB_KEY = "3082035e30820246a00302010202044c49cd00300d06092a864886f70d01010505003071310b300906035504061302554b3110300e06035504081307556e6b6e6f776e3111300f0603550407130857657468657262793110300e060355040a1307556e6b6e6f776e3110300e060355040b1307556e6b6e6f776e311930170603550403131043696172616e2047756c746e69656b73301e170d3130303732333137313032345a170d3337313230383137313032345a3071310b300906035504061302554b3110300e06035504081307556e6b6e6f776e3111300f0603550407130857657468657262793110300e060355040a1307556e6b6e6f776e3110300e060355040b1307556e6b6e6f776e311930170603550403131043696172616e2047756c746e69656b7330820122300d06092a864886f70d01010105000382010f003082010a028201010096d075e47c014e7822c89fd67f795d23203e2a8843f53ba4e6b1bf5f2fd0e225938267cfcae7fbf4fe596346afbaf4070fdb91f66fbcdf2348a3d92430502824f80517b156fab00809bdc8e631bfa9afd42d9045ab5fd6d28d9e140afc1300917b19b7c6c4df4a494cf1f7cb4a63c80d734265d735af9e4f09455f427aa65a53563f87b336ca2c19d244fcbba617ba0b19e56ed34afe0b253ab91e2fdb1271f1b9e3c3232027ed8862a112f0706e234cf236914b939bcf959821ecb2a6c18057e070de3428046d94b175e1d89bd795e535499a091f5bc65a79d539a8d43891ec504058acb28c08393b5718b57600a211e803f4a634e5c57f25b9b8c4422c6fd90203010001300d06092a864886f70d0101050500038201010008e4ef699e9807677ff56753da73efb2390d5ae2c17e4db691d5df7a7b60fc071ae509c5414be7d5da74df2811e83d3668c4a0b1abc84b9fa7d96b4cdf30bba68517ad2a93e233b042972ac0553a4801c9ebe07bf57ebe9a3b3d6d663965260e50f3b8f46db0531761e60340a2bddc3426098397fda54044a17e5244549f9869b460ca5e6e216b6f6a2db0580b480ca2afe6ec6b46eedacfa4aa45038809ece0c5978653d6c85f678e7f5a2156d1bedd8117751e64a4b0dcd140f3040b021821a8d93aed8d01ba36db6c82372211fed714d9a32607038cdfd565bd529ffc637212aaa2c224ef22b603eccefb5bf1e085c191d4b24fe742b17ab3f55d4e6f05ef";
+
+ @Test
+ public void doesntCrash() throws RepoUpdater.UpdateException {
+ assertEmpty();
+ updateEarlier();
+ updateLater();
+ }
+
+ protected void updateEarlier() throws RepoUpdater.UpdateException {
+ Utils.debugLog(TAG, "Updating earlier version of F-Droid repo");
+ updateRepo(createUpdater(REPO_FDROID, REPO_FDROID_URI, context, REPO_FDROID_PUB_KEY), "index.fdroid.2016-10-30.jar");
+ }
+
+ protected void updateLater() throws RepoUpdater.UpdateException {
+ Utils.debugLog(TAG, "Updating later version of F-Droid repo");
+ updateRepo(createUpdater(REPO_FDROID, REPO_FDROID_URI, context, REPO_FDROID_PUB_KEY), "index.fdroid.2016-11-10.jar");
+ }
+
+}
diff --git a/app/src/test/java/org/fdroid/fdroid/updater/MultiRepoUpdaterTest.java b/app/src/test/java/org/fdroid/fdroid/updater/MultiRepoUpdaterTest.java
index 6b761c15b..08aadb30d 100644
--- a/app/src/test/java/org/fdroid/fdroid/updater/MultiRepoUpdaterTest.java
+++ b/app/src/test/java/org/fdroid/fdroid/updater/MultiRepoUpdaterTest.java
@@ -181,6 +181,10 @@ public abstract class MultiRepoUpdaterTest extends FDroidProviderTest {
return new RepoUpdater(context, createRepo(name, uri, context));
}
+ protected RepoUpdater createUpdater(String name, String uri, Context context, String signingCert) {
+ return new RepoUpdater(context, createRepo(name, uri, context, signingCert));
+ }
+
protected void updateConflicting() throws UpdateException {
updateRepo(createUpdater(REPO_CONFLICTING, REPO_CONFLICTING_URI, context), "multiRepo.conflicting.jar");
}
diff --git a/app/src/test/resources/index.fdroid.2016-10-30.jar b/app/src/test/resources/index.fdroid.2016-10-30.jar
new file mode 100644
index 000000000..0a37dc7f8
Binary files /dev/null and b/app/src/test/resources/index.fdroid.2016-10-30.jar differ
diff --git a/app/src/test/resources/index.fdroid.2016-11-10.jar b/app/src/test/resources/index.fdroid.2016-11-10.jar
new file mode 100644
index 000000000..c3d55aaf7
Binary files /dev/null and b/app/src/test/resources/index.fdroid.2016-11-10.jar differ