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<String> 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<String> 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 + "."); } }