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