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.text.TextUtils; import android.util.Log; import org.fdroid.fdroid.Preferences; 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.HashSet; import java.util.List; import java.util.Set; /** * Each app has a bunch of metadata that it associates with a package name (such as org.fdroid.fdroid). * Multiple repositories can host the same package, and provide different metadata for that app. * * As such, it is usually the case that you are interested in an {@link App} which has its metadata * provided by "the repo with the best priority", rather than "specific repo X". This is important * when asking for an apk, whereby the preferable way is likely using: * * * {@link AppProvider.Helper#findHighestPriorityMetadata(ContentResolver, String)} * * rather than: * * * {@link AppProvider.Helper#findSpecificApp(ContentResolver, String, long, String[])} * * The same can be said of retrieving a list of {@link App} objects, where the metadata for each app * in the result set should be populated from the repository with the best priority. */ public class AppProvider extends FDroidProvider { private static final String TAG = "AppProvider"; public static final class Helper { private Helper() { } public static int count(Context context, Uri uri) { final String[] projection = {Cols._COUNT}; Cursor cursor = context.getContentResolver().query(uri, projection, null, null, null); int count = 0; if (cursor != null) { if (cursor.getCount() == 1) { cursor.moveToFirst(); count = cursor.getInt(0); } cursor.close(); } return count; } public static List<App> all(ContentResolver resolver) { return all(resolver, Cols.ALL); } public static List<App> all(ContentResolver resolver, String[] projection) { final Uri uri = AppProvider.getContentUri(); Cursor cursor = resolver.query(uri, projection, null, null, null); return cursorToList(cursor); } static List<App> cursorToList(Cursor cursor) { int knownAppCount = cursor != null ? cursor.getCount() : 0; List<App> apps = new ArrayList<>(knownAppCount); if (cursor != null) { if (knownAppCount > 0) { cursor.moveToFirst(); while (!cursor.isAfterLast()) { apps.add(new App(cursor)); cursor.moveToNext(); } } cursor.close(); } return apps; } public static App findHighestPriorityMetadata(ContentResolver resolver, String packageName) { final Uri uri = getHighestPriorityMetadataUri(packageName); return cursorToApp(resolver.query(uri, Cols.ALL, null, null, null)); } /** * Returns an {@link App} with metadata provided by a specific {@code repoId}. Keep in mind * that most of the time we don't care which repo provides the metadata for a particular app, * as long as it is the repo with the best priority. In those cases, you should instead use * {@link AppProvider.Helper#findHighestPriorityMetadata(ContentResolver, String)}. */ public static App findSpecificApp(ContentResolver resolver, String packageName, long repoId, String[] projection) { final Uri uri = getSpecificAppUri(packageName, repoId); return cursorToApp(resolver.query(uri, projection, null, null, null)); } public static App findSpecificApp(ContentResolver resolver, String packageName, long repoId) { return findSpecificApp(resolver, packageName, repoId, Cols.ALL); } private static App cursorToApp(Cursor cursor) { App app = null; if (cursor != null) { if (cursor.getCount() > 0) { cursor.moveToFirst(); app = new App(cursor); } cursor.close(); } return app; } public static void calcSuggestedApks(Context context) { context.getContentResolver().update(calcSuggestedApksUri(), null, null, null); } public static List<App> findCanUpdate(Context context, String[] projection) { return cursorToList(context.getContentResolver().query(AppProvider.getCanUpdateUri(), projection, null, null, null)); } public static void recalculatePreferredMetadata(Context context) { Uri uri = Uri.withAppendedPath(AppProvider.getContentUri(), PATH_CALC_PREFERRED_METADATA); context.getContentResolver().query(uri, null, null, null, null); } } /** * A QuerySelection which is aware of the option/need to join onto the * installed apps table. Not that the base classes * {@link org.fdroid.fdroid.data.QuerySelection#add(QuerySelection)} and * {@link org.fdroid.fdroid.data.QuerySelection#add(String, String[])} methods * will only return the base class {@link org.fdroid.fdroid.data.QuerySelection} * which is not aware of the installed app table. * However, the * {@link org.fdroid.fdroid.data.AppProvider.AppQuerySelection#add(org.fdroid.fdroid.data.AppProvider.AppQuerySelection)} * method from this class will return an instance of this class, that is aware of * the install apps table. */ protected static class AppQuerySelection extends QuerySelection { private boolean naturalJoinToInstalled; private boolean leftJoinPrefs; AppQuerySelection() { // The same as no selection, because "1" will always resolve to true when executing the SQL query. // e.g. "WHERE 1 AND ..." is the same as "WHERE ..." super("1"); } AppQuerySelection(String selection) { super(selection); } AppQuerySelection(String selection, String[] args) { super(selection, args); } public boolean naturalJoinToInstalled() { return naturalJoinToInstalled; } /** * Tells the query selection that it will need to join onto the installed apps table * when used. This should be called when your query makes use of fields from that table * (for example, list all installed, or list those which can be updated). * @return A reference to this object, to allow method chaining, for example * <code>return new AppQuerySelection(selection).requiresInstalledTable())</code> */ public AppQuerySelection requireNaturalInstalledTable() { naturalJoinToInstalled = true; return this; } public boolean leftJoinToPrefs() { return leftJoinPrefs; } public AppQuerySelection requireLeftJoinPrefs() { leftJoinPrefs = true; return this; } public AppQuerySelection add(AppQuerySelection query) { QuerySelection both = super.add(query); AppQuerySelection bothWithJoin = new AppQuerySelection(both.getSelection(), both.getArgs()); if (this.naturalJoinToInstalled() || query.naturalJoinToInstalled()) { bothWithJoin.requireNaturalInstalledTable(); } if (this.leftJoinToPrefs() || query.leftJoinToPrefs()) { bothWithJoin.requireLeftJoinPrefs(); } return bothWithJoin; } } protected class Query extends QueryBuilder { private boolean isSuggestedApkTableAdded; private boolean requiresInstalledTable; private boolean requiresLeftJoinToPrefs; private boolean countFieldAppended; @Override protected String getRequiredTables() { final String pkg = PackageTable.NAME; 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 String groupBy() { // 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; } public void addSelection(AppQuerySelection selection) { super.addSelection(selection); if (selection.naturalJoinToInstalled()) { naturalJoinToInstalledTable(); } if (selection.leftJoinToPrefs()) { leftJoinToPrefs(); } } // TODO: What if the selection requires a natural join, but we first get a left join // because something causes leftJoin to be caused first? Maybe throw an exception? public void naturalJoinToInstalledTable() { if (!requiresInstalledTable) { join( InstalledAppTable.NAME, "installed", "installed." + InstalledAppTable.Cols.PACKAGE_NAME + " = " + PackageTable.NAME + "." + PackageTable.Cols.PACKAGE_NAME); requiresInstalledTable = true; } } public void leftJoinToPrefs() { if (!requiresLeftJoinToPrefs) { leftJoin( AppPrefsTable.NAME, "prefs", "prefs." + AppPrefsTable.Cols.PACKAGE_NAME + " = " + PackageTable.NAME + "." + PackageTable.Cols.PACKAGE_NAME); requiresLeftJoinToPrefs = true; } } public void leftJoinToInstalledTable() { if (!requiresInstalledTable) { leftJoin( InstalledAppTable.NAME, "installed", "installed." + InstalledAppTable.Cols.PACKAGE_NAME + " = " + PackageTable.NAME + "." + PackageTable.Cols.PACKAGE_NAME); requiresInstalledTable = true; } } @Override public void addField(String field) { switch (field) { case Cols.Package.PACKAGE_NAME: appendField(PackageTable.Cols.PACKAGE_NAME, PackageTable.NAME, Cols.Package.PACKAGE_NAME); break; case Cols.SuggestedApk.VERSION_NAME: addSuggestedApkVersionField(); break; case Cols.InstalledApp.VERSION_NAME: addInstalledAppVersionName(); break; case Cols.InstalledApp.VERSION_CODE: addInstalledAppVersionCode(); break; case Cols.InstalledApp.SIGNATURE: addInstalledSig(); break; case Cols._COUNT: appendCountField(); break; default: appendField(field, getTableName()); break; } } private void appendCountField() { countFieldAppended = true; appendField("COUNT( DISTINCT " + getTableName() + "." + Cols.ROW_ID + " ) AS " + Cols._COUNT); } private void addSuggestedApkVersionField() { addSuggestedApkField( ApkTable.Cols.VERSION_NAME, Cols.SuggestedApk.VERSION_NAME); } private void addSuggestedApkField(String fieldName, String alias) { if (!isSuggestedApkTableAdded) { isSuggestedApkTableAdded = true; leftJoin( getApkTableName(), "suggestedApk", getTableName() + "." + Cols.SUGGESTED_VERSION_CODE + " = suggestedApk." + ApkTable.Cols.VERSION_CODE + " AND " + getTableName() + "." + Cols.ROW_ID + " = suggestedApk." + ApkTable.Cols.APP_ID); } appendField(fieldName, "suggestedApk", alias); } private void addInstalledAppVersionName() { addInstalledAppField( InstalledAppTable.Cols.VERSION_NAME, Cols.InstalledApp.VERSION_NAME ); } private void addInstalledAppVersionCode() { addInstalledAppField( InstalledAppTable.Cols.VERSION_CODE, Cols.InstalledApp.VERSION_CODE ); } private void addInstalledSig() { addInstalledAppField( InstalledAppTable.Cols.SIGNATURE, Cols.InstalledApp.SIGNATURE ); } private void addInstalledAppField(String fieldName, String alias) { leftJoinToInstalledTable(); appendField(fieldName, "installed", alias); } } private static final String PROVIDER_NAME = "AppProvider"; private static final UriMatcher MATCHER = new UriMatcher(-1); private static final String PATH_INSTALLED = "installed"; private static final String PATH_CAN_UPDATE = "canUpdate"; private static final String PATH_SEARCH = "search"; private static final String PATH_SEARCH_INSTALLED = "searchInstalled"; private static final String PATH_SEARCH_CAN_UPDATE = "searchCanUpdate"; private static final String PATH_SEARCH_REPO = "searchRepo"; protected static final String PATH_APPS = "apps"; protected static final String PATH_SPECIFIC_APP = "app"; private static final String PATH_RECENTLY_UPDATED = "recentlyUpdated"; private static final String PATH_NEWLY_ADDED = "newlyAdded"; private static final String PATH_CATEGORY = "category"; private static final String PATH_REPO = "repo"; private static final String PATH_HIGHEST_PRIORITY = "highestPriority"; private static final String PATH_CALC_PREFERRED_METADATA = "calcPreferredMetadata"; private static final String PATH_CALC_SUGGESTED_APKS = "calcNonRepoDetailsFromIndex"; private static final int CAN_UPDATE = CODE_SINGLE + 1; private static final int INSTALLED = CAN_UPDATE + 1; private static final int SEARCH = INSTALLED + 1; private static final int RECENTLY_UPDATED = SEARCH + 1; private static final int NEWLY_ADDED = RECENTLY_UPDATED + 1; private static final int CATEGORY = NEWLY_ADDED + 1; private static final int CALC_SUGGESTED_APKS = CATEGORY + 1; private static final int REPO = CALC_SUGGESTED_APKS + 1; private static final int SEARCH_REPO = REPO + 1; private static final int SEARCH_INSTALLED = SEARCH_REPO + 1; private static final int SEARCH_CAN_UPDATE = SEARCH_INSTALLED + 1; private static final int HIGHEST_PRIORITY = SEARCH_CAN_UPDATE + 1; private static final int CALC_PREFERRED_METADATA = HIGHEST_PRIORITY + 1; static { MATCHER.addURI(getAuthority(), null, CODE_LIST); MATCHER.addURI(getAuthority(), PATH_CALC_SUGGESTED_APKS, CALC_SUGGESTED_APKS); MATCHER.addURI(getAuthority(), PATH_RECENTLY_UPDATED, RECENTLY_UPDATED); MATCHER.addURI(getAuthority(), PATH_NEWLY_ADDED, NEWLY_ADDED); MATCHER.addURI(getAuthority(), PATH_CATEGORY + "/*", CATEGORY); MATCHER.addURI(getAuthority(), PATH_SEARCH + "/*", SEARCH); MATCHER.addURI(getAuthority(), PATH_SEARCH_INSTALLED + "/*", SEARCH_INSTALLED); MATCHER.addURI(getAuthority(), PATH_SEARCH_CAN_UPDATE + "/*", SEARCH_CAN_UPDATE); MATCHER.addURI(getAuthority(), PATH_SEARCH_REPO + "/*/*", SEARCH_REPO); MATCHER.addURI(getAuthority(), PATH_REPO + "/#", REPO); MATCHER.addURI(getAuthority(), PATH_CAN_UPDATE, CAN_UPDATE); MATCHER.addURI(getAuthority(), PATH_INSTALLED, INSTALLED); MATCHER.addURI(getAuthority(), PATH_HIGHEST_PRIORITY + "/*", HIGHEST_PRIORITY); MATCHER.addURI(getAuthority(), PATH_SPECIFIC_APP + "/#/*", CODE_SINGLE); MATCHER.addURI(getAuthority(), PATH_CALC_PREFERRED_METADATA, CALC_PREFERRED_METADATA); } public static Uri getContentUri() { return Uri.parse("content://" + getAuthority()); } public static Uri getRecentlyUpdatedUri() { return Uri.withAppendedPath(getContentUri(), PATH_RECENTLY_UPDATED); } public static Uri getNewlyAddedUri() { return Uri.withAppendedPath(getContentUri(), PATH_NEWLY_ADDED); } private static Uri calcSuggestedApksUri() { return Uri.withAppendedPath(getContentUri(), PATH_CALC_SUGGESTED_APKS); } public static Uri getCategoryUri(String category) { return getContentUri().buildUpon() .appendPath(PATH_CATEGORY) .appendPath(category) .build(); } public static Uri getInstalledUri() { return Uri.withAppendedPath(getContentUri(), PATH_INSTALLED); } public static Uri getCanUpdateUri() { return Uri.withAppendedPath(getContentUri(), PATH_CAN_UPDATE); } public static Uri getRepoUri(Repo repo) { return getContentUri().buildUpon() .appendPath(PATH_REPO) .appendPath(String.valueOf(repo.id)) .build(); } public static Uri getContentUri(App app) { return getContentUri(app.packageName); } /** * @see AppProvider.Helper#findSpecificApp(ContentResolver, String, long, String[]) for details * of why you should usually prefer {@link AppProvider#getHighestPriorityMetadataUri(String)} to * this method. */ public static Uri getSpecificAppUri(String packageName, long repoId) { return getContentUri() .buildUpon() .appendPath(PATH_SPECIFIC_APP) .appendPath(Long.toString(repoId)) .appendPath(packageName) .build(); } public static Uri getHighestPriorityMetadataUri(String packageName) { return getContentUri().buildUpon() .appendPath(PATH_HIGHEST_PRIORITY) .appendPath(packageName) .build(); } public static Uri getContentUri(String packageName) { return Uri.withAppendedPath(getContentUri(), packageName); } public static Uri getSearchUri(String query) { if (TextUtils.isEmpty(query)) { // Return all the things for an empty search. return getContentUri(); } return getContentUri().buildUpon() .appendPath(PATH_SEARCH) .appendPath(query) .build(); } public static Uri getSearchInstalledUri(String query) { return getContentUri() .buildUpon() .appendPath(PATH_SEARCH_INSTALLED) .appendPath(query) .build(); } public static Uri getSearchCanUpdateUri(String query) { return getContentUri() .buildUpon() .appendPath(PATH_SEARCH_CAN_UPDATE) .appendPath(query) .build(); } public static Uri getSearchUri(Repo repo, String query) { return getContentUri().buildUpon() .appendPath(PATH_SEARCH_REPO) .appendPath(String.valueOf(repo.id)) .appendPath(query) .build(); } @Override protected String getTableName() { return AppMetadataTable.NAME; } protected String getCatJoinTableName() { return CatJoinTable.NAME; } protected String getApkTableName() { return ApkTable.NAME; } @Override protected String getProviderName() { return "AppProvider"; } public static String getAuthority() { return AUTHORITY + "." + PROVIDER_NAME; } @Override protected UriMatcher getMatcher() { return MATCHER; } private AppQuerySelection queryCanUpdate() { final String app = getTableName(); // Need to use COALESCE because the prefs join may not resolve any rows, which means the // ignore* fields will be NULL. In that case, we want to instead use a default value of 0. final String ignoreCurrent = " COALESCE(prefs." + AppPrefsTable.Cols.IGNORE_THIS_UPDATE + ", 0) != " + app + "." + Cols.SUGGESTED_VERSION_CODE; final String ignoreAll = "COALESCE(prefs." + AppPrefsTable.Cols.IGNORE_ALL_UPDATES + ", 0) != 1"; final String ignore = " (" + ignoreCurrent + " AND " + ignoreAll + ") "; final String where = ignore + " AND " + app + "." + Cols.SUGGESTED_VERSION_CODE + " > installed." + InstalledAppTable.Cols.VERSION_CODE; return new AppQuerySelection(where).requireNaturalInstalledTable().requireLeftJoinPrefs(); } private AppQuerySelection queryRepo(long repoId) { final String selection = getTableName() + "." + Cols.REPO_ID + " = ? "; final String[] args = {String.valueOf(repoId)}; return new AppQuerySelection(selection, args); } private AppQuerySelection queryInstalled() { return new AppQuerySelection().requireNaturalInstalledTable(); } private AppQuerySelection querySearch(String query) { // Put in a Set to remove duplicates final Set<String> keywordSet = new HashSet<>(Arrays.asList(query.split("\\s"))); if (keywordSet.size() == 0) { return new AppQuerySelection(); } // Surround each keyword in % for wildcard searching final String[] keywords = new String[keywordSet.size()]; int iKeyword = 0; for (final String keyword : keywordSet) { keywords[iKeyword] = "%" + keyword + "%"; iKeyword++; } 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, }; // Build selection string and fill out keyword arguments final StringBuilder selection = new StringBuilder(); final String[] selectionKeywords = new String[columns.length * keywords.length]; iKeyword = 0; boolean firstColumn = true; for (final String column : columns) { if (firstColumn) { firstColumn = false; } else { selection.append(" OR "); } selection.append('('); boolean firstKeyword = true; for (final String keyword : keywords) { if (firstKeyword) { firstKeyword = false; } else { selection.append(" AND "); } selection.append(column).append(" LIKE ?"); selectionKeywords[iKeyword] = keyword; iKeyword++; } selection.append(") "); } return new AppQuerySelection(selection.toString(), selectionKeywords); } protected AppQuerySelection querySingle(String packageName, long repoId) { final String selection = getTableName() + "." + Cols.REPO_ID + " = ? "; final String[] args = {Long.toString(repoId)}; return new AppQuerySelection(selection, args).add(queryPackageName(packageName)); } /** * Same as {@link AppProvider#querySingle(String, long)} except it is used for the purpose * of an UPDATE query rather than a SELECT query. This means that it must use a subquery to get * the {@link Cols.Package#PACKAGE_ID} rather than the join which is already in place for that * table. The reason is because UPDATE queries cannot include joins in SQLite. */ protected AppQuerySelection querySingleForUpdate(String packageName, long repoId) { final String selection = Cols.PACKAGE_ID + " = (" + getPackageIdFromPackageNameQuery() + ") AND " + Cols.REPO_ID + " = ? "; final String[] args = {packageName, Long.toString(repoId)}; return new AppQuerySelection(selection, args); } private AppQuerySelection queryExcludeSwap() { // fdroid_repo will have null fields if the LEFT JOIN didn't resolve, e.g. due to there // being no apks for the app in the result set. In that case, we can't tell if it is from // a swap repo or not. final String isSwap = RepoTable.NAME + "." + RepoTable.Cols.IS_SWAP; final String selection = "COALESCE(" + isSwap + ", 0) = 0"; return new AppQuerySelection(selection); } private AppQuerySelection queryNewlyAdded() { final String selection = getTableName() + "." + Cols.ADDED + " > ?"; final String[] args = {Utils.formatDate(Preferences.get().calcMaxHistory(), "")}; return new AppQuerySelection(selection, args); } /** * Ensures that for each app metadata row with the same package name, only the one from the repo * with the best priority is represented in the result set. While possible to calculate this * dynamically each time the query is run, we precalculate it during repo updates for performance. */ private AppQuerySelection queryHighestPriority() { final String selection = PackageTable.NAME + "." + PackageTable.Cols.PREFERRED_METADATA + " = " + getTableName() + "." + Cols.ROW_ID; return new AppQuerySelection(selection); } private AppQuerySelection queryPackageName(String packageName) { final String selection = PackageTable.NAME + "." + PackageTable.Cols.PACKAGE_NAME + " = ? "; final String[] args = {packageName}; return new AppQuerySelection(selection, args); } private AppQuerySelection queryRecentlyUpdated() { final String app = getTableName(); final String lastUpdated = app + "." + Cols.LAST_UPDATED; final String selection = app + "." + Cols.ADDED + " != " + lastUpdated + " AND " + lastUpdated + " > ?"; final String[] args = {Utils.formatDate(Preferences.get().calcMaxHistory(), "")}; return new AppQuerySelection(selection, args); } private AppQuerySelection queryCategory(String category) { final String selection = CategoryTable.NAME + "." + CategoryTable.Cols.NAME + " = ? "; final String[] args = {category}; return new AppQuerySelection(selection, args); } static AppQuerySelection queryPackageNames(String packageNames, String packageNameField) { String[] args = packageNames.split(","); String selection = packageNameField + " IN (" + generateQuestionMarksForInClause(args.length) + ")"; return new AppQuerySelection(selection, args); } @Override public Cursor query(Uri uri, String[] projection, String customSelection, String[] selectionArgs, String sortOrder) { AppQuerySelection selection = new AppQuerySelection(customSelection, selectionArgs); // Queries which are for the main list of apps should not include swap apps. boolean includeSwap = true; // It is usually the case that we ask for app(s) for which we don't care what repo is // responsible for providing them. In that case, we need to populate the metadata with // that form the repo with the highest priority. // Whenever we know which repo it is coming from, then it is important that we don't // delegate to the repo with the highest priority, but rather the specific repo we are // querying from. boolean repoIsKnown = false; switch (MATCHER.match(uri)) { case CALC_PREFERRED_METADATA: updatePreferredMetadata(); return null; case CODE_LIST: includeSwap = false; break; case CODE_SINGLE: List<String> pathParts = uri.getPathSegments(); long repoId = Long.parseLong(pathParts.get(1)); String packageName = pathParts.get(2); selection = selection.add(querySingle(packageName, repoId)); repoIsKnown = true; break; case CAN_UPDATE: selection = selection.add(queryCanUpdate()); includeSwap = false; break; case REPO: selection = selection.add(queryRepo(Long.parseLong(uri.getLastPathSegment()))); repoIsKnown = true; break; case INSTALLED: selection = selection.add(queryInstalled()); includeSwap = false; break; case SEARCH: selection = selection.add(querySearch(uri.getLastPathSegment())); includeSwap = false; break; case SEARCH_INSTALLED: selection = querySearch(uri.getLastPathSegment()).add(queryInstalled()); includeSwap = false; break; case SEARCH_CAN_UPDATE: selection = querySearch(uri.getLastPathSegment()).add(queryCanUpdate()); includeSwap = false; break; case SEARCH_REPO: selection = selection .add(querySearch(uri.getPathSegments().get(2))) .add(queryRepo(Long.parseLong(uri.getPathSegments().get(1)))); repoIsKnown = true; break; case CATEGORY: selection = selection.add(queryCategory(uri.getLastPathSegment())); includeSwap = false; break; case RECENTLY_UPDATED: sortOrder = getTableName() + "." + Cols.LAST_UPDATED + " DESC"; selection = selection.add(queryRecentlyUpdated()); includeSwap = false; break; case NEWLY_ADDED: sortOrder = getTableName() + "." + Cols.ADDED + " DESC"; selection = selection.add(queryNewlyAdded()); includeSwap = false; break; case HIGHEST_PRIORITY: selection = selection.add(queryPackageName(uri.getLastPathSegment())); includeSwap = false; break; default: Log.e(TAG, "Invalid URI for app content provider: " + uri); throw new UnsupportedOperationException("Invalid URI for app content provider: " + uri); } if (!repoIsKnown) { selection = selection.add(queryHighestPriority()); } return runQuery(uri, selection, projection, includeSwap, sortOrder); } /** * Helper method used by both the genuine {@link AppProvider} and the temporary version used * by the repo updater ({@link TempAppProvider}). */ protected Cursor runQuery(Uri uri, AppQuerySelection selection, String[] projection, boolean includeSwap, String sortOrder) { if (!includeSwap) { selection = selection.add(queryExcludeSwap()); } if (Cols.NAME.equals(sortOrder)) { sortOrder = getTableName() + "." + sortOrder + " COLLATE LOCALIZED "; } Query query = new Query(); query.addSelection(selection); query.addFields(projection); // TODO: Make the order of addFields/addSelection not dependent on each other... query.addOrderBy(sortOrder); Cursor cursor = LoggingQuery.query(db(), query.toString(), query.getArgs()); cursor.setNotificationUri(getContext().getContentResolver(), uri); return cursor; } @Override public int delete(Uri uri, String where, String[] whereArgs) { if (MATCHER.match(uri) != REPO) { throw new UnsupportedOperationException("Delete not supported for " + uri + "."); } long repoId = Long.parseLong(uri.getLastPathSegment()); final String catJoin = getCatJoinTableName(); final String app = getTableName(); String query = "DELETE FROM " + catJoin + " WHERE " + CatJoinTable.Cols.APP_METADATA_ID + " IN " + "(SELECT " + Cols.ROW_ID + " FROM " + app + " WHERE " + app + "." + Cols.REPO_ID + " = ?)"; db().execSQL(query, new String[] {String.valueOf(repoId)}); AppQuerySelection selection = new AppQuerySelection(where, whereArgs).add(queryRepo(repoId)); return db().delete(getTableName(), selection.getSelection(), selection.getArgs()); } @Override public Uri insert(Uri uri, ContentValues values) { long packageId = PackageProvider.Helper.ensureExists(getContext(), values.getAsString(Cols.Package.PACKAGE_NAME)); values.remove(Cols.Package.PACKAGE_NAME); values.put(Cols.PACKAGE_ID, packageId); 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<String> 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) { throw new UnsupportedOperationException("Update not supported for " + uri + "."); } updateSuggestedApks(); getContext().getContentResolver().notifyChange(getCanUpdateUri(), null); return 0; } protected void updateAllAppDetails() { updatePreferredMetadata(); updateCompatibleFlags(); updateSuggestedFromUpstream(); updateSuggestedFromLatest(); updateIconUrls(); } /** * If the repo hasn't changed, then there are many things which we shouldn't waste time updating * (compared to {@link AppProvider#updateAllAppDetails()}: * * + The "preferred metadata", as that is calculated based on repo with highest priority, and * only takes into account the package name, not specific versions, when figuring this out. * * + Compatible flags. These were calculated earlier, whether or not an app was suggested or not. * * + Icon URLs. While technically these do change when the suggested version changes, it is not * important enough to spend a significant amount of time to calculate. In the future maybe, * but that effort should instead go into implementing an intent service. * * In the future, this problem of taking a long time should be fixed by implementing an * {@link android.app.IntentService} as described in https://gitlab.com/fdroid/fdroidclient/issues/520. */ protected void updateSuggestedApks() { updateSuggestedFromUpstream(); updateSuggestedFromLatest(); } private void updatePreferredMetadata() { Utils.debugLog(TAG, "Deciding on which metadata should take priority for each package."); final String app = getTableName(); final String highestPriority = "SELECT MIN(r." + RepoTable.Cols.PRIORITY + ") " + "FROM " + RepoTable.NAME + " AS r " + "JOIN " + getTableName() + " AS m ON (m." + Cols.REPO_ID + " = r." + RepoTable.Cols._ID + ") " + "WHERE m." + Cols.PACKAGE_ID + " = " + "metadata." + Cols.PACKAGE_ID; String updateSql = "UPDATE " + PackageTable.NAME + " " + "SET " + PackageTable.Cols.PREFERRED_METADATA + " = ( " + " SELECT metadata." + Cols.ROW_ID + " FROM " + app + " AS metadata " + " JOIN " + RepoTable.NAME + " AS repo ON (metadata." + Cols.REPO_ID + " = repo." + RepoTable.Cols._ID + ") " + " WHERE metadata." + Cols.PACKAGE_ID + " = " + PackageTable.NAME + "." + PackageTable.Cols.ROW_ID + " AND repo." + RepoTable.Cols.PRIORITY + " = (" + highestPriority + ")" + ");"; db().execSQL(updateSql); } /** * For each app, we want to set the isCompatible flag to 1 if any of the apks we know * about are compatible, and 0 otherwise. */ private void updateCompatibleFlags() { Utils.debugLog(TAG, "Calculating whether apps are compatible, based on whether any of their apks are compatible"); final String apk = getApkTableName(); final String app = getTableName(); String updateSql = "UPDATE " + app + " SET " + Cols.IS_COMPATIBLE + " = ( " + " SELECT TOTAL( " + apk + "." + ApkTable.Cols.IS_COMPATIBLE + ") > 0 " + " FROM " + apk + " WHERE " + apk + "." + ApkTable.Cols.APP_ID + " = " + app + "." + Cols.ROW_ID + " );"; db().execSQL(updateSql); } /** * Look at the upstream version of each app, our goal is to find the apk * with the closest version code to that, without going over. * If the app is not compatible at all (i.e. no versions were compatible) * then we take the highest, otherwise we take the highest compatible version. */ private void updateSuggestedFromUpstream() { Utils.debugLog(TAG, "Calculating suggested versions for all apps which specify an upstream version code."); final String apk = getApkTableName(); final String app = getTableName(); final boolean unstableUpdates = Preferences.get().getUnstableUpdates(); String restrictToStable = unstableUpdates ? "" : (apk + "." + ApkTable.Cols.VERSION_CODE + " <= " + app + "." + Cols.UPSTREAM_VERSION_CODE + " AND "); String updateSql = "UPDATE " + app + " SET " + Cols.SUGGESTED_VERSION_CODE + " = ( " + " SELECT MAX( " + apk + "." + ApkTable.Cols.VERSION_CODE + " ) " + " FROM " + apk + " WHERE " + app + "." + Cols.ROW_ID + " = " + apk + "." + ApkTable.Cols.APP_ID + " AND " + restrictToStable + " ( " + app + "." + Cols.IS_COMPATIBLE + " = 0 OR " + apk + "." + Cols.IS_COMPATIBLE + " = 1 ) ) " + " WHERE " + Cols.UPSTREAM_VERSION_CODE + " > 0 "; db().execSQL(updateSql); } /** * We set each app's suggested version to the latest available that is * compatible, or the latest available if none are compatible. * * If the suggested version is null, it means that we could not figure it * out from the upstream vercode. In such a case, fall back to the simpler * algorithm as if upstreamVercode was 0. */ private void updateSuggestedFromLatest() { Utils.debugLog(TAG, "Calculating suggested versions for all apps which don't specify an upstream version code."); final String apk = getApkTableName(); final String app = getTableName(); String updateSql = "UPDATE " + app + " SET " + Cols.SUGGESTED_VERSION_CODE + " = ( " + " SELECT MAX( " + apk + "." + ApkTable.Cols.VERSION_CODE + " ) " + " FROM " + apk + " WHERE " + app + "." + Cols.ROW_ID + " = " + apk + "." + ApkTable.Cols.APP_ID + " AND " + " ( " + app + "." + Cols.IS_COMPATIBLE + " = 0 OR " + apk + "." + ApkTable.Cols.IS_COMPATIBLE + " = 1 ) ) " + " WHERE COALESCE(" + Cols.UPSTREAM_VERSION_CODE + ", 0) = 0 OR " + Cols.SUGGESTED_VERSION_CODE + " IS NULL "; db().execSQL(updateSql); } private void updateIconUrls() { final String appTable = getTableName(); final String apkTable = getApkTableName(); final String iconsDir = Utils.getIconsDir(getContext(), 1.0); final String iconsDirLarge = Utils.getIconsDir(getContext(), 1.5); String repoVersion = Integer.toString(Repo.VERSION_DENSITY_SPECIFIC_ICONS); Utils.debugLog(TAG, "Updating icon paths for apps belonging to repos with version >= " + repoVersion); Utils.debugLog(TAG, "Using icons dir '" + iconsDir + "'"); Utils.debugLog(TAG, "Using large icons dir '" + iconsDirLarge + "'"); String query = getIconUpdateQuery(appTable, apkTable); final String[] params = { repoVersion, iconsDir, Utils.FALLBACK_ICONS_DIR, repoVersion, iconsDirLarge, Utils.FALLBACK_ICONS_DIR, }; db().execSQL(query, params); } /** * Returns a query which requires two parameters to be bound. These are (in order): * 1) The repo version that introduced density specific icons * 2) The dir to density specific icons for the current device. */ private static String getIconUpdateQuery(String app, String apk) { final String repo = RepoTable.NAME; final String iconUrlQuery = "SELECT " + // Concatenate (using the "||" operator) the address, the // icons directory (bound to the ? as the second parameter // when executing the query) and the icon path. "( " + repo + "." + RepoTable.Cols.ADDRESS + " || " + // If the repo has the relevant version, then use a more // intelligent icons dir, otherwise revert to the default // one " CASE WHEN " + repo + "." + RepoTable.Cols.VERSION + " >= ? THEN ? ELSE ? END " + " || " + app + "." + Cols.ICON + ") " + " FROM " + apk + " JOIN " + repo + " ON (" + repo + "." + RepoTable.Cols._ID + " = " + apk + "." + ApkTable.Cols.REPO_ID + ") " + " WHERE " + app + "." + Cols.ROW_ID + " = " + apk + "." + ApkTable.Cols.APP_ID + " AND " + apk + "." + ApkTable.Cols.VERSION_CODE + " = " + app + "." + Cols.SUGGESTED_VERSION_CODE; return "UPDATE " + app + " SET " + Cols.ICON_URL + " = ( " + iconUrlQuery + " ), " + Cols.ICON_URL_LARGE + " = ( " + iconUrlQuery + " )"; } }