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.util.Log;
import org.fdroid.fdroid.data.Schema.ApkTable;
import org.fdroid.fdroid.data.Schema.ApkTable.Cols;
import org.fdroid.fdroid.data.Schema.AppMetadataTable;
import org.fdroid.fdroid.data.Schema.PackageTable;
import org.fdroid.fdroid.data.Schema.RepoTable;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public class ApkProvider extends FDroidProvider {
private static final String TAG = "ApkProvider";
/**
* SQLite has a maximum of 999 parameters in a query. Each apk we add
* requires two (packageName and vercode) so we can only query half of that. Then,
* we may want to add additional constraints, so we give our self some
* room by saying only 450 apks can be queried at once.
*/
static final int MAX_APKS_TO_QUERY = 450;
public static final class Helper {
private Helper() { }
public static void update(Context context, Apk apk) {
ContentResolver resolver = context.getContentResolver();
Uri uri = getApkFromRepoUri(apk);
resolver.update(uri, apk.toContentValues(), null, null);
}
public static Uri getApkFromRepoUri(Apk apk) {
return getContentUri()
.buildUpon()
.appendPath(PATH_APK_FROM_REPO)
.appendPath(Long.toString(apk.appId))
.appendPath(Integer.toString(apk.versionCode))
.build();
}
public static List<Apk> cursorToList(Cursor cursor) {
int knownApkCount = cursor != null ? cursor.getCount() : 0;
List<Apk> apks = new ArrayList<>(knownApkCount);
if (cursor != null) {
if (knownApkCount > 0) {
cursor.moveToFirst();
while (!cursor.isAfterLast()) {
apks.add(new Apk(cursor));
cursor.moveToNext();
}
}
cursor.close();
}
return apks;
}
public static int deleteApksByRepo(Context context, Repo repo) {
ContentResolver resolver = context.getContentResolver();
final Uri uri = getRepoUri(repo.getId());
return resolver.delete(uri, null, null);
}
public static Apk findApkFromAnyRepo(Context context, String packageName, int versionCode) {
return findApkFromAnyRepo(context, packageName, versionCode, Cols.ALL);
}
/**
* Find all apks for a particular app, but limit it to those originating from the
* specified repo.
*/
public static List<Apk> findByUri(Context context, Repo repo, List<App> apps, String[] projection) {
ContentResolver resolver = context.getContentResolver();
final Uri uri = getContentUriForApps(repo, apps);
Cursor cursor = resolver.query(uri, projection, null, null, null);
return cursorToList(cursor);
}
public static Apk findApkFromAnyRepo(Context context, String packageName, int versionCode, String[] projection) {
final Uri uri = getApkFromAnyRepoUri(packageName, versionCode);
return findByUri(context, uri, projection);
}
public static Apk findByUri(Context context, Uri uri, String[] projection) {
ContentResolver resolver = context.getContentResolver();
Cursor cursor = resolver.query(uri, projection, null, null, null);
Apk apk = null;
if (cursor != null) {
if (cursor.getCount() > 0) {
cursor.moveToFirst();
apk = new Apk(cursor);
}
cursor.close();
}
return apk;
}
public static List<Apk> findByPackageName(Context context, String packageName) {
return findByPackageName(context, packageName, Cols.ALL);
}
public static List<Apk> findByPackageName(Context context,
String packageName, String[] projection) {
ContentResolver resolver = context.getContentResolver();
final Uri uri = getAppUri(packageName);
final String sort = "apk." + Cols.VERSION_CODE + " DESC";
Cursor cursor = resolver.query(uri, projection, null, null, sort);
return cursorToList(cursor);
}
/**
* Returns apks in the database, which have the same packageName and version as
* one of the apks in the "apks" argument.
*/
public static List<Apk> knownApks(Context context, List<Apk> apks, String[] fields) {
if (apks.isEmpty()) {
return new ArrayList<>();
}
List<Apk> knownApks = new ArrayList<>();
if (apks.size() > ApkProvider.MAX_APKS_TO_QUERY) {
int middle = apks.size() / 2;
List<Apk> apks1 = apks.subList(0, middle);
List<Apk> apks2 = apks.subList(middle, apks.size());
knownApks.addAll(knownApks(context, apks1, fields));
knownApks.addAll(knownApks(context, apks2, fields));
} else {
knownApks.addAll(knownApksSafe(context, apks, fields));
}
return knownApks;
}
private static List<Apk> knownApksSafe(final Context context, final List<Apk> apks, final String[] fields) {
ContentResolver resolver = context.getContentResolver();
final Uri uri = getContentUri(apks);
Cursor cursor = resolver.query(uri, fields, null, null, null);
return cursorToList(cursor);
}
public static List<Apk> findByRepo(Context context, Repo repo, String[] fields) {
ContentResolver resolver = context.getContentResolver();
final Uri uri = getRepoUri(repo.getId());
Cursor cursor = resolver.query(uri, fields, null, null, null);
return cursorToList(cursor);
}
public static Apk get(Context context, Uri uri) {
return get(context, uri, Cols.ALL);
}
public static Apk get(Context context, Uri uri, String[] fields) {
ContentResolver resolver = context.getContentResolver();
Cursor cursor = resolver.query(uri, fields, null, null, null);
Apk apk = null;
if (cursor != null) {
if (cursor.getCount() > 0) {
cursor.moveToFirst();
apk = new Apk(cursor);
}
cursor.close();
}
return apk;
}
}
private static final int CODE_PACKAGE = CODE_SINGLE + 1;
private static final int CODE_REPO = CODE_PACKAGE + 1;
private static final int CODE_APKS = CODE_REPO + 1;
private static final int CODE_REPO_APPS = CODE_APKS + 1;
protected static final int CODE_REPO_APK = CODE_REPO_APPS + 1;
private static final int CODE_APK_ROW_ID = CODE_REPO_APK + 1;
static final int CODE_APK_FROM_ANY_REPO = CODE_APK_ROW_ID + 1;
static final int CODE_APK_FROM_REPO = CODE_APK_FROM_ANY_REPO + 1;
private static final String PROVIDER_NAME = "ApkProvider";
protected static final String PATH_APK_FROM_ANY_REPO = "apk-any-repo";
protected static final String PATH_APK_FROM_REPO = "apk-from-repo";
private static final String PATH_APKS = "apks";
private static final String PATH_APP = "app";
private static final String PATH_REPO = "repo";
private static final String PATH_REPO_APPS = "repo-apps";
protected static final String PATH_REPO_APK = "repo-apk";
private static final String PATH_APK_ROW_ID = "apk-rowId";
private static final UriMatcher MATCHER = new UriMatcher(-1);
private static final Map<String, String> REPO_FIELDS = new HashMap<>();
private static final Map<String, String> PACKAGE_FIELDS = new HashMap<>();
static {
REPO_FIELDS.put(Cols.Repo.VERSION, RepoTable.Cols.VERSION);
REPO_FIELDS.put(Cols.Repo.ADDRESS, RepoTable.Cols.ADDRESS);
PACKAGE_FIELDS.put(Cols.Package.PACKAGE_NAME, PackageTable.Cols.PACKAGE_NAME);
MATCHER.addURI(getAuthority(), PATH_REPO + "/#", CODE_REPO);
MATCHER.addURI(getAuthority(), PATH_APK_FROM_ANY_REPO + "/#/*", CODE_APK_FROM_ANY_REPO);
MATCHER.addURI(getAuthority(), PATH_APK_FROM_REPO + "/#/#", CODE_APK_FROM_REPO);
MATCHER.addURI(getAuthority(), PATH_APKS + "/*", CODE_APKS);
MATCHER.addURI(getAuthority(), PATH_APP + "/*", CODE_PACKAGE);
MATCHER.addURI(getAuthority(), PATH_REPO_APPS + "/#/*", CODE_REPO_APPS);
MATCHER.addURI(getAuthority(), PATH_REPO_APK + "/#/*", CODE_REPO_APK);
MATCHER.addURI(getAuthority(), PATH_APK_ROW_ID + "/#", CODE_APK_ROW_ID);
MATCHER.addURI(getAuthority(), null, CODE_LIST);
}
public static String getAuthority() {
return AUTHORITY + "." + PROVIDER_NAME;
}
public static Uri getContentUri() {
return Uri.parse("content://" + getAuthority());
}
private Uri getApkUri(long apkRowId) {
return getContentUri().buildUpon()
.appendPath(PATH_APK_ROW_ID)
.appendPath(Long.toString(apkRowId))
.build();
}
public static Uri getAppUri(String packageName) {
return getContentUri()
.buildUpon()
.appendPath(PATH_APP)
.appendPath(packageName)
.build();
}
public static Uri getRepoUri(long repoId) {
return getContentUri()
.buildUpon()
.appendPath(PATH_REPO)
.appendPath(Long.toString(repoId))
.build();
}
public static Uri getApkFromAnyRepoUri(Apk apk) {
return getApkFromAnyRepoUri(apk.packageName, apk.versionCode);
}
public static Uri getApkFromAnyRepoUri(String packageName, int versionCode) {
return getContentUri()
.buildUpon()
.appendPath(PATH_APK_FROM_ANY_REPO)
.appendPath(Integer.toString(versionCode))
.appendPath(packageName)
.build();
}
public static Uri getContentUriForApps(Repo repo, List<App> apps) {
return getContentUri()
.buildUpon()
.appendPath(PATH_REPO_APPS)
.appendPath(Long.toString(repo.id))
.appendPath(buildAppString(apps))
.build();
}
/**
* Intentionally left protected because it will break if apks is larger than
* {@link org.fdroid.fdroid.data.ApkProvider#MAX_APKS_TO_QUERY}. Instead of using
* this directly, think about using
* {@link org.fdroid.fdroid.data.ApkProvider.Helper#knownApks(android.content.Context, java.util.List, String[])}
*/
static Uri getContentUri(List<Apk> apks) {
return getContentUri().buildUpon()
.appendPath(PATH_APKS)
.appendPath(buildApkString(apks))
.build();
}
protected static String buildApkString(List<Apk> apks) {
StringBuilder builder = new StringBuilder();
for (int i = 0; i < apks.size(); i++) {
if (i != 0) {
builder.append(',');
}
final Apk apk = apks.get(i);
builder.append(apk.appId).append(':').append(apk.versionCode);
}
return builder.toString();
}
private static String buildAppString(List<App> apks) {
StringBuilder builder = new StringBuilder();
for (int i = 0; i < apks.size(); i++) {
if (i != 0) {
builder.append(',');
}
builder.append(apks.get(i).packageName);
}
return builder.toString();
}
@Override
protected String getTableName() {
return ApkTable.NAME;
}
protected String getAppTableName() {
return AppMetadataTable.NAME;
}
@Override
protected String getProviderName() {
return PROVIDER_NAME;
}
@Override
protected UriMatcher getMatcher() {
return MATCHER;
}
private class Query extends QueryBuilder {
private boolean repoTableRequired;
@Override
protected String getRequiredTables() {
final String apk = getTableName();
final String app = getAppTableName();
final String pkg = PackageTable.NAME;
return apk + " AS apk " +
" LEFT JOIN " + app + " AS app ON (app." + AppMetadataTable.Cols.ROW_ID + " = apk." + Cols.APP_ID + ")" +
" LEFT JOIN " + pkg + " AS pkg ON (pkg." + PackageTable.Cols.ROW_ID + " = app." + AppMetadataTable.Cols.PACKAGE_ID + ")";
}
@Override
public void addField(String field) {
if (PACKAGE_FIELDS.containsKey(field)) {
addPackageField(PACKAGE_FIELDS.get(field), field);
} else if (REPO_FIELDS.containsKey(field)) {
addRepoField(REPO_FIELDS.get(field), field);
} else if (field.equals(Cols._ID)) {
appendField("rowid", "apk", "_id");
} else if (field.equals(Cols._COUNT)) {
appendField("COUNT(*) AS " + Cols._COUNT);
} else if (field.equals(Cols._COUNT_DISTINCT)) {
appendField("COUNT(DISTINCT apk." + Cols.APP_ID + ") AS " + Cols._COUNT_DISTINCT);
} else {
appendField(field, "apk");
}
}
private void addPackageField(String field, String alias) {
appendField(field, "pkg", alias);
}
private void addRepoField(String field, String alias) {
if (!repoTableRequired) {
repoTableRequired = true;
leftJoin(RepoTable.NAME, "repo", "apk." + Cols.REPO_ID + " = repo." + RepoTable.Cols._ID);
}
appendField(field, "repo", alias);
}
}
private QuerySelection queryPackage(String packageName) {
final String selection = "pkg." + PackageTable.Cols.PACKAGE_NAME + " = ?";
final String[] args = {packageName};
return new QuerySelection(selection, args);
}
private QuerySelection querySingleFromAnyRepo(Uri uri) {
return querySingleFromAnyRepo(uri, true);
}
private QuerySelection querySingleFromAnyRepo(Uri uri, boolean includeAlias) {
String alias = includeAlias ? "apk." : "";
// TODO: Technically multiple repositories can provide the apk with this version code.
// Therefore, in the very near future we'll need to change from calculating a
// "suggested version code" to a "suggested apk" and join directly onto the apk table.
// This way, we can take into account both repo priorities and signing keys of any
// already installed apks to ensure that the best version is suggested to the user.
// At this point, we may pull back the "wrong" apk in weird edge cases, but the user
// wont be tricked into installing it, as it will (likely) have a different signing key.
final String selection = alias + Cols.VERSION_CODE + " = ? and " + alias + Cols.APP_ID + " IN (" + getMetadataIdFromPackageNameQuery() + ")";
final String[] args = {
// First (0th) path segment is the word "apk",
// and we are not interested in it.
uri.getPathSegments().get(1),
uri.getPathSegments().get(2),
};
return new QuerySelection(selection, args);
}
private QuerySelection querySingle(long apkRowId) {
return querySingle(apkRowId, true);
}
private QuerySelection querySingle(long apkRowId, boolean includeAlias) {
String alias = includeAlias ? "apk." : "";
final String selection = alias + Cols.ROW_ID + " = ?";
final String[] args = {Long.toString(apkRowId)};
return new QuerySelection(selection, args);
}
/**
* Doesn't prefix column names with table alias. This is so that it can be used in UPDATE
* queries. Note that this lack of table alias prefixes means this can't be used for general
* constraints in a regular select query within {@link ApkProvider} as the queries specify
* aliases for the apk table.
*/
private QuerySelection querySingleWithAppId(Uri uri) {
List<String> path = uri.getPathSegments();
String appId = path.get(1);
String versionCode = path.get(2);
final String selection = Cols.APP_ID + " = ? AND " + Cols.VERSION_CODE + " = ? ";
final String[] args = {appId, versionCode};
return new QuerySelection(selection, args);
}
protected QuerySelection queryRepo(long repoId) {
return queryRepo(repoId, true);
}
protected QuerySelection queryRepo(long repoId, boolean includeAlias) {
String alias = includeAlias ? "apk." : "";
final String selection = alias + Cols.REPO_ID + " = ? ";
final String[] args = {Long.toString(repoId)};
return new QuerySelection(selection, args);
}
private QuerySelection queryRepoApps(long repoId, String packageNames) {
return queryRepo(repoId).add(AppProvider.queryPackageNames(packageNames, "pkg." + PackageTable.Cols.PACKAGE_NAME));
}
protected QuerySelection queryApks(String apkKeys) {
return queryApks(apkKeys, true);
}
protected QuerySelection queryApks(String apkKeys, boolean includeAlias) {
final String[] apkDetails = apkKeys.split(",");
if (apkDetails.length > MAX_APKS_TO_QUERY) {
throw new IllegalArgumentException(
"Cannot query more than " + MAX_APKS_TO_QUERY + ". " +
"You tried to query " + apkDetails.length);
}
String alias = includeAlias ? "apk." : "";
final String[] args = new String[apkDetails.length * 2];
StringBuilder sb = new StringBuilder();
for (int i = 0; i < apkDetails.length; i++) {
String[] parts = apkDetails[i].split(":");
String appId = parts[0];
String versionCode = parts[1];
args[i * 2] = appId;
args[i * 2 + 1] = versionCode;
if (i != 0) {
sb.append(" OR ");
}
sb.append(" ( ")
.append(Cols.APP_ID)
.append(" = ? ")
.append(" AND ")
.append(alias)
.append(Cols.VERSION_CODE)
.append(" = ? ) ");
}
return new QuerySelection(sb.toString(), args);
}
private String getMetadataIdFromPackageNameQuery() {
return "SELECT m." + AppMetadataTable.Cols.ROW_ID + " " +
"FROM " + AppMetadataTable.NAME + " AS m " +
"JOIN " + PackageTable.NAME + " AS p ON ( " +
" m." + AppMetadataTable.Cols.PACKAGE_ID + " = p." + PackageTable.Cols.ROW_ID + " ) " +
"WHERE p." + PackageTable.Cols.PACKAGE_NAME + " = ?";
}
@Override
public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) {
QuerySelection query = new QuerySelection(selection, selectionArgs);
switch (MATCHER.match(uri)) {
case CODE_LIST:
break;
case CODE_APK_FROM_ANY_REPO:
query = query.add(querySingleFromAnyRepo(uri));
break;
case CODE_APK_ROW_ID:
query = query.add(querySingle(Long.parseLong(uri.getLastPathSegment())));
break;
case CODE_PACKAGE:
query = query.add(queryPackage(uri.getLastPathSegment()));
break;
case CODE_APKS:
query = query.add(queryApks(uri.getLastPathSegment()));
break;
case CODE_REPO:
query = query.add(queryRepo(Long.parseLong(uri.getLastPathSegment())));
break;
case CODE_REPO_APPS:
List<String> pathSegments = uri.getPathSegments();
query = query.add(queryRepoApps(Long.parseLong(pathSegments.get(1)), pathSegments.get(2)));
break;
default:
Log.e(TAG, "Invalid URI for apk content provider: " + uri);
throw new UnsupportedOperationException("Invalid URI for apk content provider: " + uri);
}
Query queryBuilder = new Query();
for (final String field : projection) {
queryBuilder.addField(field);
}
queryBuilder.addSelection(query);
queryBuilder.addOrderBy(sortOrder);
Cursor cursor = LoggingQuery.query(db(), queryBuilder.toString(), queryBuilder.getArgs());
cursor.setNotificationUri(getContext().getContentResolver(), uri);
return cursor;
}
private static void removeFieldsFromOtherTables(ContentValues values) {
for (Map.Entry<String, String> repoField : REPO_FIELDS.entrySet()) {
final String field = repoField.getKey();
if (values.containsKey(field)) {
values.remove(field);
}
}
for (Map.Entry<String, String> appField : PACKAGE_FIELDS.entrySet()) {
final String field = appField.getKey();
if (values.containsKey(field)) {
values.remove(field);
}
}
}
@Override
public Uri insert(Uri uri, ContentValues values) {
removeFieldsFromOtherTables(values);
validateFields(Cols.ALL, values);
long newId = db().insertOrThrow(getTableName(), null, values);
if (!isApplyingBatch()) {
getContext().getContentResolver().notifyChange(uri, null);
}
return getApkUri(newId);
}
@Override
public int delete(Uri uri, String where, String[] whereArgs) {
QuerySelection query = new QuerySelection(where, whereArgs);
switch (MATCHER.match(uri)) {
case CODE_REPO:
query = query.add(queryRepo(Long.parseLong(uri.getLastPathSegment()), false));
break;
case CODE_APKS:
query = query.add(queryApks(uri.getLastPathSegment(), false));
break;
// TODO: Add tests for this.
case CODE_REPO_APK:
List<String> pathSegments = uri.getPathSegments();
query = query.add(queryRepo(Long.parseLong(pathSegments.get(1)))).add(queryApks(pathSegments.get(2)));
break;
default:
Log.e(TAG, "Invalid URI for apk content provider: " + uri);
throw new UnsupportedOperationException("Invalid URI for apk content provider: " + uri);
}
int rowsAffected = db().delete(getTableName(), query.getSelection(), query.getArgs());
getContext().getContentResolver().notifyChange(uri, null);
return rowsAffected;
}
@Override
public int update(Uri uri, ContentValues values, String where, String[] whereArgs) {
if (MATCHER.match(uri) != CODE_APK_FROM_REPO) {
throw new UnsupportedOperationException("Cannot update anything other than a single apk.");
}
return performUpdateUnchecked(uri, values, where, whereArgs);
}
protected int performUpdateUnchecked(Uri uri, ContentValues values, String where, String[] whereArgs) {
validateFields(Cols.ALL, values);
removeFieldsFromOtherTables(values);
QuerySelection query = new QuerySelection(where, whereArgs);
query = query.add(querySingleWithAppId(uri));
int numRows = db().update(getTableName(), values, query.getSelection(), query.getArgs());
if (!isApplyingBatch()) {
getContext().getContentResolver().notifyChange(uri, null);
}
return numRows;
}
}