package org.fdroid.fdroid.data; import android.content.ContentResolver; import android.content.ContentUris; import android.content.ContentValues; import android.content.Context; import android.content.UriMatcher; import android.database.Cursor; import android.net.Uri; import android.support.annotation.Nullable; import android.text.TextUtils; import android.util.Log; import org.fdroid.fdroid.Utils; import org.fdroid.fdroid.data.Schema.RepoTable; import org.fdroid.fdroid.data.Schema.RepoTable.Cols; import java.util.ArrayList; import java.util.List; public class RepoProvider extends FDroidProvider { private static final String TAG = "RepoProvider"; public static final class Helper { private static final String TAG = "RepoProvider.Helper"; private Helper() { } /** * Find by the content URI of a repo ({@link RepoProvider#getContentUri(long)}). */ public static Repo get(Context context, Uri uri) { ContentResolver resolver = context.getContentResolver(); Cursor cursor = resolver.query(uri, Cols.ALL, null, null, null); return cursorToRepo(cursor); } public static Repo findById(Context context, long repoId) { return findById(context, repoId, Cols.ALL); } public static Repo findById(Context context, long repoId, String[] projection) { ContentResolver resolver = context.getContentResolver(); Uri uri = RepoProvider.getContentUri(repoId); Cursor cursor = resolver.query(uri, projection, null, null, null); return cursorToRepo(cursor); } /** * This method decides what repo a URL belongs to by iteratively removing path fragments and * checking if it belongs to a repo or not. It will match the most specific repository which * could serve the file at the given URL. * * For any given HTTP resource requested by F-Droid, it should belong to a repository. * Whether that resource is an index.jar, an icon, or a .apk file, they all belong to a * repository. Therefore, that repository must exist in the database. The way to find out * which repository a particular URL came from requires some consideration: * * Repositories can exist at particular paths on a server (e.g. /fdroid/repo) * * Individual files can exist at a more specific path on the repo (e.g. /fdroid/repo/icons/org.fdroid.fdroid.png) * * So for a given URL "/fdroid/repo/icons/org.fdroid.fdroid.png" we don't actually know * whether it is for the file "org.fdroid.fdroid.png" at repository "/fdroid/repo/icons" or * the file "icons/org.fdroid.fdroid.png" at the repository at "/fdroid/repo". */ @Nullable public static Repo findByUrl(Context context, Uri uri, String[] projection) { Uri withoutQuery = uri.buildUpon().query(null).build(); Repo repo = findByAddress(context, withoutQuery.toString(), projection); // Take a copy of this, because the result of getPathSegments() is an AbstractList // which doesn't support the remove() operation. List<String> pathSegments = new ArrayList<>(withoutQuery.getPathSegments()); boolean haveTriedWithoutPath = false; while (repo == null && !haveTriedWithoutPath) { if (pathSegments.size() == 0) { haveTriedWithoutPath = true; } else { pathSegments.remove(pathSegments.size() - 1); withoutQuery = withoutQuery.buildUpon().path(TextUtils.join("/", pathSegments)).build(); } repo = findByAddress(context, withoutQuery.toString(), projection); } return repo; } public static Repo findByAddress(Context context, String address) { return findByAddress(context, address, Cols.ALL); } public static Repo findByAddress(Context context, String address, String[] projection) { List<Repo> repos = findBy( context, Cols.ADDRESS, address, projection); return repos.size() > 0 ? repos.get(0) : null; } public static List<Repo> all(Context context) { return all(context, Cols.ALL); } public static List<Repo> all(Context context, String[] projection) { ContentResolver resolver = context.getContentResolver(); Uri uri = RepoProvider.getContentUri(); Cursor cursor = resolver.query(uri, projection, null, null, null); return cursorToList(cursor); } private static List<Repo> findBy(Context context, String fieldName, String fieldValue, String[] projection) { ContentResolver resolver = context.getContentResolver(); Uri uri = RepoProvider.getContentUri(); final String[] args = {fieldValue}; Cursor cursor = resolver.query( uri, projection, fieldName + " = ?", args, null); return cursorToList(cursor); } private static List<Repo> cursorToList(Cursor cursor) { int knownRepoCount = cursor != null ? cursor.getCount() : 0; List<Repo> repos = new ArrayList<>(knownRepoCount); if (cursor != null) { if (knownRepoCount > 0) { cursor.moveToFirst(); while (!cursor.isAfterLast()) { repos.add(new Repo(cursor)); cursor.moveToNext(); } } cursor.close(); } return repos; } private static Repo cursorToRepo(Cursor cursor) { Repo repo = null; if (cursor != null) { if (cursor.getCount() > 0) { cursor.moveToFirst(); repo = new Repo(cursor); } cursor.close(); } return repo; } public static void update(Context context, Repo repo, ContentValues values) { ContentResolver resolver = context.getContentResolver(); // Change the name to the new address. Next time we update the repo // index file, it will populate the name field with the proper // name, but the best we can do is guess right now. if (values.containsKey(Cols.ADDRESS) && !values.containsKey(Cols.NAME)) { String name = Repo.addressToName(values.getAsString(Cols.ADDRESS)); values.put(Cols.NAME, name); } /* * If the repo is signed and has a public key, then guarantee that * the fingerprint is also set. The stored fingerprint is checked * when a repo URI is received by FDroid to prevent bad actors from * overriding repo configs with other keys. So if the fingerprint is * not stored yet, calculate it and store it. If the fingerprint is * stored, then check it against the calculated fingerprint just to * make sure it is correct. If the fingerprint is empty, then store * the calculated one. */ if (values.containsKey(Cols.SIGNING_CERT)) { String publicKey = values.getAsString(Cols.SIGNING_CERT); String calcedFingerprint = Utils.calcFingerprint(publicKey); if (values.containsKey(Cols.FINGERPRINT)) { String fingerprint = values.getAsString(Cols.FINGERPRINT); if (!TextUtils.isEmpty(publicKey)) { if (TextUtils.isEmpty(fingerprint)) { values.put(Cols.FINGERPRINT, calcedFingerprint); } else if (!fingerprint.equals(calcedFingerprint)) { // TODO the UI should represent this error! Log.e(TAG, "The stored and calculated fingerprints do not match!"); Log.e(TAG, "Stored: " + fingerprint); Log.e(TAG, "Calculated: " + calcedFingerprint); } } } else if (!TextUtils.isEmpty(publicKey)) { // no fingerprint in 'values', so put one there values.put(Cols.FINGERPRINT, calcedFingerprint); } } if (values.containsKey(Cols.IN_USE)) { Integer inUse = values.getAsInteger(Cols.IN_USE); if (inUse != null && inUse == 0) { values.put(Cols.LAST_ETAG, (String) null); } } final Uri uri = getContentUri(repo.getId()); final String[] args = {Long.toString(repo.getId())}; resolver.update(uri, values, Cols._ID + " = ?", args); repo.setValues(values); } /** * This doesn't do anything other than call "insert" on the content * resolver, but I thought I'd put it here in the interests of having * each of the CRUD methods available in the helper class. */ public static Uri insert(Context context, ContentValues values) { ContentResolver resolver = context.getContentResolver(); Uri uri = RepoProvider.getContentUri(); return resolver.insert(uri, values); } public static void remove(Context context, long repoId) { ContentResolver resolver = context.getContentResolver(); Uri uri = RepoProvider.getContentUri(repoId); resolver.delete(uri, null, null); } public static void purgeApps(Context context, Repo repo) { Uri apkUri = ApkProvider.getRepoUri(repo.getId()); ContentResolver resolver = context.getContentResolver(); int apkCount = resolver.delete(apkUri, null, null); Utils.debugLog(TAG, "Removed " + apkCount + " apks from repo " + repo.name); Uri appUri = AppProvider.getRepoUri(repo); int appCount = resolver.delete(appUri, null, null); Utils.debugLog(TAG, "Removed " + appCount + " apps from repo " + repo.address + "."); AppProvider.Helper.recalculatePreferredMetadata(context); } public static int countAppsForRepo(Context context, long repoId) { ContentResolver resolver = context.getContentResolver(); final String[] projection = {Schema.ApkTable.Cols._COUNT_DISTINCT}; Uri apkUri = ApkProvider.getRepoUri(repoId); Cursor cursor = resolver.query(apkUri, projection, null, null, null); int count = 0; if (cursor != null) { if (cursor.getCount() > 0) { cursor.moveToFirst(); count = cursor.getInt(0); } cursor.close(); } return count; } } private static final String PROVIDER_NAME = "RepoProvider"; private static final String PATH_ALL_EXCEPT_SWAP = "allExceptSwap"; private static final int CODE_ALL_EXCEPT_SWAP = CODE_SINGLE + 1; private static final UriMatcher MATCHER = new UriMatcher(-1); static { MATCHER.addURI(AUTHORITY + "." + PROVIDER_NAME, null, CODE_LIST); MATCHER.addURI(AUTHORITY + "." + PROVIDER_NAME, PATH_ALL_EXCEPT_SWAP, CODE_ALL_EXCEPT_SWAP); MATCHER.addURI(AUTHORITY + "." + PROVIDER_NAME, "#", CODE_SINGLE); } public static String getAuthority() { return AUTHORITY + "." + PROVIDER_NAME; } public static Uri getContentUri() { return Uri.parse("content://" + AUTHORITY + "." + PROVIDER_NAME); } public static Uri getContentUri(long repoId) { return ContentUris.withAppendedId(getContentUri(), repoId); } public static Uri allExceptSwapUri() { return getContentUri().buildUpon() .appendPath(PATH_ALL_EXCEPT_SWAP) .build(); } @Override protected String getTableName() { return RepoTable.NAME; } @Override protected String getProviderName() { return "RepoProvider"; } @Override protected UriMatcher getMatcher() { return MATCHER; } @Override public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) { if (TextUtils.isEmpty(sortOrder)) { sortOrder = Cols.PRIORITY + " ASC"; } switch (MATCHER.match(uri)) { case CODE_LIST: // Do nothing (don't restrict query) break; case CODE_SINGLE: selection = (selection == null ? "" : selection + " AND ") + Cols._ID + " = " + uri.getLastPathSegment(); break; case CODE_ALL_EXCEPT_SWAP: selection = "COALESCE(" + Cols.IS_SWAP + ", 0) = 0 "; break; default: Log.e(TAG, "Invalid URI for repo content provider: " + uri); throw new UnsupportedOperationException("Invalid URI for repo content provider: " + uri); } Cursor cursor = db().query(getTableName(), projection, selection, selectionArgs, null, null, sortOrder); cursor.setNotificationUri(getContext().getContentResolver(), uri); return cursor; } @Override public Uri insert(Uri uri, ContentValues values) { // Don't let people specify arbitrary priorities. Instead, we are responsible // for making sure that newly created repositories by default have the highest priority. values.put(Cols.PRIORITY, getMaxPriority() + 1); if (!values.containsKey(Cols.ADDRESS)) { throw new UnsupportedOperationException("Cannot add repo without an address."); } // The following fields have NOT NULL constraints in the DB, so need // to be present. if (!values.containsKey(Cols.IN_USE)) { values.put(Cols.IN_USE, 1); } if (!values.containsKey(Cols.MAX_AGE)) { values.put(Cols.MAX_AGE, 0); } if (!values.containsKey(Cols.VERSION)) { values.put(Cols.VERSION, 0); } if (!values.containsKey(Cols.NAME) || values.get(Cols.NAME) == null) { final String address = values.getAsString(Cols.ADDRESS); values.put(Cols.NAME, Repo.addressToName(address)); } long id = db().insertOrThrow(getTableName(), null, values); Utils.debugLog(TAG, "Inserted repo. Notifying provider change: '" + uri + "'."); getContext().getContentResolver().notifyChange(uri, null); return getContentUri(id); } private int getMaxPriority() { Cursor cursor = db().query(RepoTable.NAME, new String[] {"MAX(" + Cols.PRIORITY + ")"}, "COALESCE(" + Cols.IS_SWAP + ", 0) = 0", null, null, null, null); cursor.moveToFirst(); int max = cursor.getInt(0); cursor.close(); return max; } @Override public int delete(Uri uri, String where, String[] whereArgs) { QuerySelection selection = new QuerySelection(where, whereArgs); switch (MATCHER.match(uri)) { case CODE_LIST: // Don't support deleting of multiple repos. return 0; case CODE_SINGLE: selection = selection.add(Cols._ID + " = ?", new String[] {uri.getLastPathSegment()}); break; default: Log.e(TAG, "Invalid URI for repo content provider: " + uri); throw new UnsupportedOperationException("Invalid URI for repo content provider: " + uri); } int rowsAffected = db().delete(getTableName(), selection.getSelection(), selection.getArgs()); Utils.debugLog(TAG, "Deleted repo. Notifying provider change: '" + uri + "'."); getContext().getContentResolver().notifyChange(uri, null); return rowsAffected; } @Override public int update(Uri uri, ContentValues values, String where, String[] whereArgs) { // When the priority of a repo changes, we need to update the "preferred metadata" foreign // key in the package table to point to the best possible record in the app metadata table. // The full list of times when we need to recalculate the preferred metadata includes: // * After the priority of a repo changes // * After a repo is disabled // * After a repo is enabled // * After an update is performed // This code only checks for the priority changing. All other occasions we can't do the // recalculation right now, because we likely haven't added/removed the relevant apps // from the metadata table yet. Usually the repo details are updated, then a request is // made to do the heavier work (e.g. a repo update to get new list of apps from server). // After the heavier work is complete, then that process can request the preferred metadata // to be recalculated. boolean priorityChanged = false; if (values.containsKey(Cols.PRIORITY)) { Cursor priorityCursor = db().query(getTableName(), new String[]{Cols.PRIORITY}, where, whereArgs, null, null, null); if (priorityCursor.getCount() > 0) { priorityCursor.moveToFirst(); int oldPriority = priorityCursor.getInt(priorityCursor.getColumnIndex(Cols.PRIORITY)); priorityChanged = oldPriority != values.getAsInteger(Cols.PRIORITY); } priorityCursor.close(); } int numRows = db().update(getTableName(), values, where, whereArgs); if (priorityChanged) { AppProvider.Helper.recalculatePreferredMetadata(getContext()); } Utils.debugLog(TAG, "Updated repo. Notifying provider change: '" + uri + "'."); getContext().getContentResolver().notifyChange(uri, null); return numRows; } }