package org.fdroid.fdroid.data; import android.content.ContentValues; import android.content.Context; import android.content.UriMatcher; import android.database.Cursor; import android.database.sqlite.SQLiteDatabase; import android.database.sqlite.SQLiteException; import android.net.Uri; 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; /** * This class does all of its operations in a temporary sqlite table. */ public class TempAppProvider extends AppProvider { /** * The name of the in memory database used for updating. */ static final String DB = "temp_update_db"; 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"; private static final int CODE_INIT = 10000; private static final int CODE_COMMIT = CODE_INIT + 1; private static final int APPS = CODE_COMMIT + 1; private static final UriMatcher MATCHER = new UriMatcher(-1); static { MATCHER.addURI(getAuthority(), PATH_INIT, CODE_INIT); MATCHER.addURI(getAuthority(), PATH_COMMIT, CODE_COMMIT); MATCHER.addURI(getAuthority(), PATH_APPS + "/#/*", APPS); MATCHER.addURI(getAuthority(), PATH_SPECIFIC_APP + "/#/*", CODE_SINGLE); } @Override protected String getTableName() { return TABLE_TEMP_APP; } @Override protected String getCatJoinTableName() { return TABLE_TEMP_CAT_JOIN; } public static String getAuthority() { return AUTHORITY + "." + PROVIDER_NAME; } public static Uri getContentUri() { return Uri.parse("content://" + getAuthority()); } /** * Same as {@link AppProvider#getSpecificAppUri(String, long)}, except loads data from the temp * table being used during a repo update rather than the persistent table. */ public static Uri getSpecificTempAppUri(String packageName, long repoId) { return getContentUri() .buildUpon() .appendPath(PATH_SPECIFIC_APP) .appendPath(Long.toString(repoId)) .appendPath(packageName) .build(); } public static Uri getAppsUri(List<String> apps, long repoId) { return getContentUri().buildUpon() .appendPath(PATH_APPS) .appendPath(Long.toString(repoId)) .appendPath(TextUtils.join(",", apps)) .build(); } private AppQuerySelection queryRepoApps(long repoId, String packageNames) { return queryPackageNames(packageNames, PackageTable.NAME + "." + PackageTable.Cols.PACKAGE_NAME) .add(queryRepo(repoId)); } private AppQuerySelection queryRepo(long repoId) { String[] args = new String[] {Long.toString(repoId)}; String selection = getTableName() + "." + Cols.REPO_ID + " = ? "; return new AppQuerySelection(selection, args); } public static class Helper { /** * Deletes the old temporary table (if it exists). Then creates a new temporary apk provider * table and populates it with all the data from the real apk provider table. */ public static void init(Context context) { Uri uri = Uri.withAppendedPath(getContentUri(), PATH_INIT); context.getContentResolver().insert(uri, new ContentValues()); TempApkProvider.Helper.init(context); } public static List<App> findByPackageNames(Context context, List<String> packageNames, long repoId, String[] projection) { Uri uri = getAppsUri(packageNames, repoId); Cursor cursor = context.getContentResolver().query(uri, projection, null, null, null); return AppProvider.Helper.cursorToList(cursor); } /** * Saves data from the temp table to the apk table, by removing _EVERYTHING_ from the real * apk table and inserting all of the records from here. The temporary table is then removed. */ public static void commitAppsAndApks(Context context) { Uri uri = Uri.withAppendedPath(getContentUri(), PATH_COMMIT); context.getContentResolver().insert(uri, new ContentValues()); } } @Override protected String getApkTableName() { return TempApkProvider.TABLE_TEMP_APK; } @Override public Uri insert(Uri uri, ContentValues values) { switch (MATCHER.match(uri)) { case CODE_INIT: initTable(); return null; case CODE_COMMIT: updateAllAppDetails(); commitTable(); return null; default: return super.insert(uri, values); } } @Override public int update(Uri uri, ContentValues values, String where, String[] whereArgs) { if (MATCHER.match(uri) != CODE_SINGLE) { throw new UnsupportedOperationException("Update not supported for " + uri + "."); } List<String> pathParts = uri.getPathSegments(); String packageName = pathParts.get(2); long repoId = Long.parseLong(pathParts.get(1)); QuerySelection query = new QuerySelection(where, whereArgs).add(querySingleForUpdate(packageName, repoId)); // 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); } 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); switch (MATCHER.match(uri)) { case APPS: List<String> segments = uri.getPathSegments(); selection = selection.add(queryRepoApps(Long.parseLong(segments.get(1)), segments.get(2))); break; } return super.runQuery(uri, selection, projection, true, sortOrder); } private void ensureTempTableDetached(SQLiteDatabase db) { try { db.execSQL("DETACH DATABASE " + DB); } catch (SQLiteException e) { // We expect that most of the time the database will not exist unless an error occurred // midway through the last update, The resulting exception is: // android.database.sqlite.SQLiteException: no such database: temp_update_db (code 1) } } private void initTable() { final SQLiteDatabase db = db(); 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 + ");"); } /** * Constructs an INSERT INTO ... SELECT statement as a means from getting data from one table * into another. The list of columns to copy are explicitly specified using colsToCopy. */ static String copyData(String[] colsToCopy, String fromTable, String toTable) { String cols = TextUtils.join(", ", colsToCopy); return "INSERT INTO " + toTable + " (" + cols + ") SELECT " + cols + " FROM " + fromTable; } private void commitTable() { final SQLiteDatabase db = db(); try { db.beginTransaction(); 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)); 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); getContext().getContentResolver().notifyChange(ApkProvider.getContentUri(), null); } finally { db.endTransaction(); db.execSQL("DETACH DATABASE " + DB); // Can't be done in a transaction. } } }