diff --git a/AndroidManifest.xml b/AndroidManifest.xml index cafacbd19..79160e0db 100644 --- a/AndroidManifest.xml +++ b/AndroidManifest.xml @@ -61,6 +61,11 @@ android:name="org.fdroid.fdroid.data.ApkProvider" android:exported="false"/> + + - + - + + + + + + + + + + + + + diff --git a/src/org/fdroid/fdroid/AppDetails.java b/src/org/fdroid/fdroid/AppDetails.java index 7364e85bf..a49fdf973 100644 --- a/src/org/fdroid/fdroid/AppDetails.java +++ b/src/org/fdroid/fdroid/AppDetails.java @@ -136,7 +136,7 @@ public class AppDetails extends ListActivity { + " " + apk.version + (apk.vercode == app.suggestedVercode ? " ☆" : "")); - if (apk.vercode == app.getInstalledVerCode(getContext()) + if (apk.vercode == app.installedVersionCode && mInstalledSigID != null && apk.sig != null && apk.sig.equals(mInstalledSigID)) { holder.status.setText(getString(R.string.inst)); @@ -437,7 +437,7 @@ public class AppDetails extends ListActivity { // Get the signature of the installed package... mInstalledSignature = null; mInstalledSigID = null; - if (app.getInstalledVersion(this) != null) { + if (app.isInstalled()) { PackageManager pm = getBaseContext().getPackageManager(); try { PackageInfo pi = pm.getPackageInfo(appid, @@ -624,11 +624,11 @@ public class AppDetails extends ListActivity { adapter.notifyDataSetChanged(); TextView tv = (TextView) findViewById(R.id.status); - if (app.getInstalledVersion(this) == null) + if (!app.isInstalled()) tv.setText(getString(R.string.details_notinstalled)); else tv.setText(getString(R.string.details_installed, - app.getInstalledVersion(this))); + app.installedVersionName)); tv = (TextView) infoView.findViewById(R.id.signature); if (pref_expert && mInstalledSignature != null) { @@ -643,9 +643,9 @@ public class AppDetails extends ListActivity { @Override protected void onListItemClick(ListView l, View v, int position, long id) { final Apk apk = adapter.getItem(position - l.getHeaderViewsCount()); - if (app.getInstalledVerCode(this) == apk.vercode) + if (app.installedVersionCode == apk.vercode) removeApk(app.id); - else if (app.getInstalledVerCode(this) > apk.vercode) { + else if (app.installedVersionCode > apk.vercode) { AlertDialog.Builder ask_alrt = new AlertDialog.Builder(this); ask_alrt.setMessage(getString(R.string.installDowngrade)); ask_alrt.setPositiveButton(getString(R.string.yes), @@ -676,7 +676,7 @@ public class AppDetails extends ListActivity { menu.clear(); if (app == null) return true; - if (app.canAndWantToUpdate(this)) { + if (app.canAndWantToUpdate()) { MenuItemCompat.setShowAsAction(menu.add( Menu.NONE, INSTALL, 0, R.string.menu_upgrade) .setIcon(R.drawable.ic_menu_refresh), @@ -685,14 +685,14 @@ public class AppDetails extends ListActivity { } // Check count > 0 due to incompatible apps resulting in an empty list. - if (app.getInstalledVersion(this) == null && app.suggestedVercode > 0 && + if (!app.isInstalled() && app.suggestedVercode > 0 && adapter.getCount() > 0) { MenuItemCompat.setShowAsAction(menu.add( Menu.NONE, INSTALL, 1, R.string.menu_install) .setIcon(android.R.drawable.ic_menu_add), MenuItemCompat.SHOW_AS_ACTION_ALWAYS | MenuItemCompat.SHOW_AS_ACTION_WITH_TEXT); - } else if (app.getInstalledVersion(this) != null) { + } else if (app.isInstalled()) { MenuItemCompat.setShowAsAction(menu.add( Menu.NONE, UNINSTALL, 1, R.string.menu_uninstall) .setIcon(android.R.drawable.ic_menu_delete), @@ -719,7 +719,7 @@ public class AppDetails extends ListActivity { .setCheckable(true) .setChecked(app.ignoreAllUpdates); - if (app.hasUpdates(this)) { + if (app.hasUpdates()) { menu.add(Menu.NONE, IGNORETHIS, 2, R.string.menu_ignore_this) .setIcon(android.R.drawable.ic_menu_close_clear_cancel) .setCheckable(true) diff --git a/src/org/fdroid/fdroid/FDroidApp.java b/src/org/fdroid/fdroid/FDroidApp.java index e0d6f2925..66d54a219 100644 --- a/src/org/fdroid/fdroid/FDroidApp.java +++ b/src/org/fdroid/fdroid/FDroidApp.java @@ -18,42 +18,30 @@ package org.fdroid.fdroid; -import java.io.File; -import java.security.KeyManagementException; -import java.security.KeyStore; -import java.security.KeyStoreException; -import java.security.NoSuchAlgorithmException; -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.Semaphore; - -import javax.net.ssl.HttpsURLConnection; -import javax.net.ssl.SSLContext; -import javax.net.ssl.TrustManager; -import javax.net.ssl.TrustManagerFactory; -import javax.net.ssl.X509TrustManager; - import android.app.Activity; import android.app.Application; -import android.content.Context; import android.content.SharedPreferences; - import android.preference.PreferenceManager; import android.util.Log; - import com.nostra13.universalimageloader.cache.disc.impl.LimitedAgeDiscCache; import com.nostra13.universalimageloader.cache.disc.naming.FileNameGenerator; import com.nostra13.universalimageloader.core.ImageLoader; import com.nostra13.universalimageloader.core.ImageLoaderConfiguration; import com.nostra13.universalimageloader.utils.StorageUtils; - import de.duenndns.ssl.MemorizingTrustManager; - -import org.fdroid.fdroid.data.AppProvider; import org.fdroid.fdroid.compat.PRNGFixes; +import org.fdroid.fdroid.data.AppProvider; +import org.fdroid.fdroid.data.InstalledAppCacheUpdater; import org.thoughtcrime.ssl.pinning.PinningTrustManager; import org.thoughtcrime.ssl.pinning.SystemKeyStore; +import javax.net.ssl.*; +import java.io.File; +import java.security.KeyManagementException; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; + public class FDroidApp extends Application { private static enum Theme { @@ -91,9 +79,12 @@ public class FDroidApp extends Application { //Apply the Google PRNG fixes to properly seed SecureRandom PRNGFixes.apply(); - // Set this up here, and the testing framework will override it when - // it gets fired up. - Utils.setupInstalledApkCache(new Utils.InstalledApkCache()); + // Check that the installed app cache hasn't gotten out of sync somehow. + // e.g. if we crashed/ran out of battery half way through responding + // to a package installed intent. It doesn't really matter where + // we put this in the bootstrap process, because it runs on a different + // thread. In fact, we may as well start early for this reason. + InstalledAppCacheUpdater.updateInBackground(getApplicationContext()); // If the user changes the preference to do with filtering rooted apps, // it is easier to just notify a change in the app provider, diff --git a/src/org/fdroid/fdroid/PackageAddedReceiver.java b/src/org/fdroid/fdroid/PackageAddedReceiver.java new file mode 100644 index 000000000..5d218b00c --- /dev/null +++ b/src/org/fdroid/fdroid/PackageAddedReceiver.java @@ -0,0 +1,44 @@ +/* + * Copyright (C) 2014 Peter Serwylo, peter@serwylo.com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 3 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +package org.fdroid.fdroid; + +import android.content.ContentValues; +import android.content.Context; +import android.content.pm.PackageInfo; +import android.net.Uri; +import android.util.Log; +import org.fdroid.fdroid.data.InstalledAppProvider; + +public class PackageAddedReceiver extends PackageReceiver { + + @Override + protected void handle(Context context, String appId) { + PackageInfo info = getPackageInfo(context, appId); + + Log.d("FDroid", "Inserting installed app info for '" + appId + "' (v" + info.versionCode + ")"); + + Uri uri = InstalledAppProvider.getContentUri(); + ContentValues values = new ContentValues(3); + values.put(InstalledAppProvider.DataColumns.APP_ID, appId); + values.put(InstalledAppProvider.DataColumns.VERSION_CODE, info.versionCode); + values.put(InstalledAppProvider.DataColumns.VERSION_NAME, info.versionName); + context.getContentResolver().insert(uri, values); + } + +} \ No newline at end of file diff --git a/src/org/fdroid/fdroid/PackageReceiver.java b/src/org/fdroid/fdroid/PackageReceiver.java index 08aebc755..00b85227f 100644 --- a/src/org/fdroid/fdroid/PackageReceiver.java +++ b/src/org/fdroid/fdroid/PackageReceiver.java @@ -1,5 +1,6 @@ /* - * Copyright (C) 2012 Ciaran Gultnieks, ciaran@ciarang.com + * Copyright (C) 2014 Ciaran Gultnieks, ciaran@ciarang.com, + * Peter Serwylo, peter@serwylo.com * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License @@ -21,17 +22,31 @@ package org.fdroid.fdroid; import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; +import android.content.pm.PackageInfo; import android.util.Log; +import org.fdroid.fdroid.data.ApkProvider; import org.fdroid.fdroid.data.AppProvider; -public class PackageReceiver extends BroadcastReceiver { +abstract class PackageReceiver extends BroadcastReceiver { + + abstract protected void handle(Context context, String appId); + + protected PackageInfo getPackageInfo(Context context, String appId) { + for( PackageInfo info : context.getPackageManager().getInstalledPackages(0)) { + if (info.packageName.equals(appId)) { + return info; + } + } + return null; + } @Override - public void onReceive(Context ctx, Intent intent) { - String appid = intent.getData().getSchemeSpecificPart(); - Log.d("FDroid", "PackageReceiver received "+appid); - ctx.getContentResolver().notifyChange(AppProvider.getContentUri(appid), null); - Utils.clearInstalledApksCache(); + public void onReceive(Context context, Intent intent) { + Log.d("FDroid", "PackageReceiver received [action = '" + intent.getAction() + "', data = '" + intent.getData() + "']"); + String appId = intent.getData().getSchemeSpecificPart(); + handle(context, appId); + context.getContentResolver().notifyChange(AppProvider.getContentUri(appId), null); + context.getContentResolver().notifyChange(ApkProvider.getAppUri(appId), null); } } diff --git a/src/org/fdroid/fdroid/PackageRemovedReceiver.java b/src/org/fdroid/fdroid/PackageRemovedReceiver.java new file mode 100644 index 000000000..163a8278b --- /dev/null +++ b/src/org/fdroid/fdroid/PackageRemovedReceiver.java @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2014 Peter Serwylo, peter@serwylo.com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 3 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +package org.fdroid.fdroid; + +import android.content.Context; +import android.net.Uri; +import android.util.Log; +import org.fdroid.fdroid.data.AppProvider; +import org.fdroid.fdroid.data.InstalledAppProvider; + +public class PackageRemovedReceiver extends PackageReceiver { + + @Override + protected void handle(Context context, String appId) { + + Log.d("FDroid", "Removing installed app info for '" + appId + "'"); + + Uri uri = InstalledAppProvider.getAppUri(appId); + context.getContentResolver().delete(uri, null, null); + } + +} \ No newline at end of file diff --git a/src/org/fdroid/fdroid/PackageUpgradedReceiver.java b/src/org/fdroid/fdroid/PackageUpgradedReceiver.java new file mode 100644 index 000000000..516a9660d --- /dev/null +++ b/src/org/fdroid/fdroid/PackageUpgradedReceiver.java @@ -0,0 +1,49 @@ +/* + * Copyright (C) 2014 Peter Serwylo, peter@serwylo.com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 3 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +package org.fdroid.fdroid; + +import android.content.ContentValues; +import android.content.Context; +import android.content.pm.PackageInfo; +import android.net.Uri; +import android.util.Log; +import org.fdroid.fdroid.data.InstalledAppProvider; + +/** + * For some reason, devices seem to be keen on sending a REMOVED and then an INSTALLED + * intent, rather than an CHANGED intent. Therefore, this is probably not used on many + * devices. Regardless, it is tested in the unit tests and should work on devices that + * opt instead to send the PACKAGE_CHANGED intent. + */ +public class PackageUpgradedReceiver extends PackageReceiver { + + @Override + protected void handle(Context context, String appId) { + PackageInfo info = getPackageInfo(context, appId); + + Log.d("FDroid", "Updating installed app info for '" + appId + "' to v" + info.versionCode + " (" + info.versionName + ")"); + + Uri uri = InstalledAppProvider.getAppUri(appId); + ContentValues values = new ContentValues(1); + values.put(InstalledAppProvider.DataColumns.VERSION_CODE, info.versionCode); + values.put(InstalledAppProvider.DataColumns.VERSION_NAME, info.versionName); + context.getContentResolver().update(uri, values, null, null); + } + +} \ No newline at end of file diff --git a/src/org/fdroid/fdroid/UpdateService.java b/src/org/fdroid/fdroid/UpdateService.java index 35b603aad..d0c74735f 100644 --- a/src/org/fdroid/fdroid/UpdateService.java +++ b/src/org/fdroid/fdroid/UpdateService.java @@ -405,7 +405,7 @@ public class UpdateService extends IntentService implements ProgressListener { break; } } - if (!ignored && app.hasUpdates(this)) { + if (!ignored && app.hasUpdates()) { updateCount++; } } diff --git a/src/org/fdroid/fdroid/Utils.java b/src/org/fdroid/fdroid/Utils.java index b5fa3a37b..62409bd15 100644 --- a/src/org/fdroid/fdroid/Utils.java +++ b/src/org/fdroid/fdroid/Utils.java @@ -195,14 +195,6 @@ public final class Utils { return apkCacheDir; } - public static Map getInstalledApps(Context context) { - return installedApkCache.getApks(context); - } - - public static void clearInstalledApksCache() { - installedApkCache.emptyCache(); - } - public static String calcFingerprint(String keyHexString) { if (TextUtils.isEmpty(keyHexString)) return null; @@ -296,44 +288,4 @@ public final class Utils { } } - private static InstalledApkCache installedApkCache = null; - - /** - * We do a lot of querying of the installed app's. As a result, we like - * to cache this information quite heavily (and flush the cache when new - * apps are installed). The caching implementation needs to be setup like - * this so that it is possible to mock for testing purposes. - */ - public static void setupInstalledApkCache(InstalledApkCache cache) { - installedApkCache = cache; - } - - public static class InstalledApkCache { - - protected Map installedApks = null; - - protected Map buildAppList(Context context) { - Map info = new HashMap(); - Log.d("FDroid", "Reading installed packages"); - List installedPackages = context.getPackageManager().getInstalledPackages(0); - for (PackageInfo appInfo : installedPackages) { - info.put(appInfo.packageName, appInfo); - } - return info; - } - - public Map getApks(Context context) { - if (installedApks == null) { - installedApks = buildAppList(context); - } - return installedApks; - } - - public void emptyCache() { - installedApks = null; - } - - } - - } diff --git a/src/org/fdroid/fdroid/data/ApkProvider.java b/src/org/fdroid/fdroid/data/ApkProvider.java index 9f4f7630b..80b88ef87 100644 --- a/src/org/fdroid/fdroid/data/ApkProvider.java +++ b/src/org/fdroid/fdroid/data/ApkProvider.java @@ -8,7 +8,6 @@ import android.database.Cursor; import android.net.Uri; import android.provider.BaseColumns; import android.util.Log; -import org.fdroid.fdroid.UpdateService; import java.util.*; diff --git a/src/org/fdroid/fdroid/data/App.java b/src/org/fdroid/fdroid/data/App.java index 30688b21a..b5ffb8292 100644 --- a/src/org/fdroid/fdroid/data/App.java +++ b/src/org/fdroid/fdroid/data/App.java @@ -79,6 +79,10 @@ public class App extends ValueObject implements Comparable { public String iconUrl; + public String installedVersionName; + + public int installedVersionCode; + @Override public int compareTo(App app) { return name.compareToIgnoreCase(app.name); @@ -148,6 +152,10 @@ public class App extends ValueObject implements Comparable { ignoreThisUpdate = cursor.getInt(i); } else if (column.equals(AppProvider.DataColumns.ICON_URL)) { iconUrl = cursor.getString(i); + } else if (column.equals(AppProvider.DataColumns.InstalledApp.VERSION_CODE)) { + installedVersionCode = cursor.getInt(i); + } else if (column.equals(AppProvider.DataColumns.InstalledApp.VERSION_NAME)) { + installedVersionName = cursor.getString(i); } } } @@ -186,53 +194,25 @@ public class App extends ValueObject implements Comparable { return values; } - /** - * Version string for the currently installed version of this apk. - * If not installed, returns null. - */ - public String getInstalledVersion(Context context) { - PackageInfo info = getInstalledInfo(context); - return info == null ? null : info.versionName; - } - - /** - * Version code for the currently installed version of this apk. - * If not installed, it returns -1. - */ - public int getInstalledVerCode(Context context) { - PackageInfo info = getInstalledInfo(context); - return info == null ? -1 : info.versionCode; - } - - /** - * True if installed by the user, false if a system apk or not installed. - */ - public boolean getUserInstalled(Context context) { - PackageInfo info = getInstalledInfo(context); - return info != null && ((info.applicationInfo.flags & ApplicationInfo.FLAG_SYSTEM) != 1); - } - - public PackageInfo getInstalledInfo(Context context) { - Map installed = Utils.getInstalledApps(context); - return installed.containsKey(id) ? installed.get(id) : null; + public boolean isInstalled() { + return installedVersionCode > 0; } /** * True if there are new versions (apks) available */ - public boolean hasUpdates(Context context) { + public boolean hasUpdates() { boolean updates = false; if (suggestedVercode > 0) { - int installedVerCode = getInstalledVerCode(context); - updates = (installedVerCode > 0 && installedVerCode < suggestedVercode); + updates = (installedVersionCode > 0 && installedVersionCode < suggestedVercode); } return updates; } // True if there are new versions (apks) available and the user wants // to be notified about them - public boolean canAndWantToUpdate(Context context) { - boolean canUpdate = hasUpdates(context); + public boolean canAndWantToUpdate() { + boolean canUpdate = hasUpdates(); boolean wantsUpdate = !ignoreAllUpdates && ignoreThisUpdate < suggestedVercode; return canUpdate && wantsUpdate && !isFiltered(); } diff --git a/src/org/fdroid/fdroid/data/AppProvider.java b/src/org/fdroid/fdroid/data/AppProvider.java index 9bd19d476..5298f3efa 100644 --- a/src/org/fdroid/fdroid/data/AppProvider.java +++ b/src/org/fdroid/fdroid/data/AppProvider.java @@ -1,7 +1,6 @@ package org.fdroid.fdroid.data; import android.content.*; -import android.content.pm.PackageInfo; import android.database.Cursor; import android.net.Uri; import android.util.Log; @@ -13,9 +12,6 @@ import java.util.*; public class AppProvider extends FDroidProvider { - /** - * @see org.fdroid.fdroid.data.ApkProvider.MAX_APKS_TO_QUERY - */ public static final int MAX_APPS_TO_QUERY = 900; public static final class Helper { @@ -166,6 +162,11 @@ public class AppProvider extends FDroidProvider { public static final String VERSION = "suggestedApkVersion"; } + public interface InstalledApp { + public static final String VERSION_CODE = "installedVersionCode"; + public static final String VERSION_NAME = "installedVersionName"; + } + public static String[] ALL = { IS_COMPATIBLE, APP_ID, NAME, SUMMARY, ICON, DESCRIPTION, LICENSE, WEB_URL, TRACKER_URL, SOURCE_URL, DONATE_URL, @@ -173,14 +174,76 @@ public class AppProvider extends FDroidProvider { UPSTREAM_VERSION, UPSTREAM_VERSION_CODE, ADDED, LAST_UPDATED, CATEGORIES, ANTI_FEATURES, REQUIREMENTS, IGNORE_ALLUPDATES, IGNORE_THISUPDATE, ICON_URL, SUGGESTED_VERSION_CODE, - SuggestedApk.VERSION + SuggestedApk.VERSION, InstalledApp.VERSION_CODE, + InstalledApp.VERSION_NAME }; } + /** + * 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. + */ + private static class AppQuerySelection extends QuerySelection { + + private boolean naturalJoinToInstalled = false; + + public 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"); + } + + public AppQuerySelection(String selection) { + super(selection); + } + + public AppQuerySelection(String selection, String[] args) { + super(selection, args); + } + + public AppQuerySelection(String selection, List 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 + * return new AppQuerySelection(selection).requiresInstalledTable()) + */ + public AppQuerySelection requireNaturalInstalledTable() { + naturalJoinToInstalled = 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(); + } + return bothWithJoin; + } + + } + private static class Query extends QueryBuilder { private boolean isSuggestedApkTableAdded = false; - + private boolean requiresInstalledTable = false; private boolean categoryFieldAdded = false; @Override @@ -193,10 +256,43 @@ public class AppProvider extends FDroidProvider { return fieldCount() == 1 && categoryFieldAdded; } + public void addSelection(AppQuerySelection selection) { + addSelection(selection.getSelection()); + if (selection.naturalJoinToInstalled()) { + naturalJoinToInstalledTable(); + } + } + + // 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( + DBHelper.TABLE_INSTALLED_APP, + "installed", + "installed." + InstalledAppProvider.DataColumns.APP_ID + " = " + DBHelper.TABLE_APP + ".id"); + requiresInstalledTable = true; + } + } + + public void leftJoinToInstalledTable() { + if (!requiresInstalledTable) { + leftJoin( + DBHelper.TABLE_INSTALLED_APP, + "installed", + "installed." + InstalledAppProvider.DataColumns.APP_ID + " = " + DBHelper.TABLE_APP + ".id"); + requiresInstalledTable = true; + } + } + @Override public void addField(String field) { if (field.equals(DataColumns.SuggestedApk.VERSION)) { addSuggestedApkVersionField(); + } else if (field.equals(DataColumns.InstalledApp.VERSION_NAME)) { + addInstalledAppVersionName(); + } else if (field.equals(DataColumns.InstalledApp.VERSION_CODE)) { + addInstalledAppVersionCode(); } else if (field.equals(DataColumns._COUNT)) { appendCountField(); } else { @@ -227,6 +323,25 @@ public class AppProvider extends FDroidProvider { } appendField(fieldName, "suggestedApk", alias); } + + private void addInstalledAppVersionName() { + addInstalledAppField( + InstalledAppProvider.DataColumns.VERSION_NAME, + DataColumns.InstalledApp.VERSION_NAME + ); + } + + private void addInstalledAppVersionCode() { + addInstalledAppField( + InstalledAppProvider.DataColumns.VERSION_CODE, + DataColumns.InstalledApp.VERSION_CODE + ); + } + + private void addInstalledAppField(String fieldName, String alias) { + leftJoinToInstalledTable(); + appendField(fieldName, "installed", alias); + } } private static final String PROVIDER_NAME = "AppProvider"; @@ -242,7 +357,6 @@ public class AppProvider extends FDroidProvider { private static final String PATH_NEWLY_ADDED = "newlyAdded"; private static final String PATH_CATEGORY = "category"; private static final String PATH_IGNORED = "ignored"; - private static final String PATH_CALC_APP_DETAILS_FROM_INDEX = "calcDetailsFromIndex"; private static final int CAN_UPDATE = CODE_SINGLE + 1; @@ -254,7 +368,6 @@ public class AppProvider extends FDroidProvider { private static final int NEWLY_ADDED = RECENTLY_UPDATED + 1; private static final int CATEGORY = NEWLY_ADDED + 1; private static final int IGNORED = CATEGORY + 1; - private static final int CALC_APP_DETAILS_FROM_INDEX = IGNORED + 1; static { @@ -359,49 +472,19 @@ public class AppProvider extends FDroidProvider { return matcher; } - private QuerySelection queryCanUpdate() { - Map installedApps = Utils.getInstalledApps(getContext()); - + private AppQuerySelection queryCanUpdate() { String ignoreCurrent = " fdroid_app.ignoreThisUpdate != fdroid_app.suggestedVercode "; String ignoreAll = " fdroid_app.ignoreAllUpdates != 1 "; String ignore = " ( " + ignoreCurrent + " AND " + ignoreAll + " ) "; - - StringBuilder where = new StringBuilder( ignore + " AND ( 0 "); - String[] selectionArgs = new String[installedApps.size() * 2]; - int i = 0; - for (PackageInfo info : installedApps.values() ) { - where.append(" OR ( fdroid_app.") - .append(DataColumns.APP_ID) - .append(" = ? AND fdroid_app.") - .append(DataColumns.SUGGESTED_VERSION_CODE) - .append(" > ?) "); - selectionArgs[ i * 2 ] = info.packageName; - selectionArgs[ i * 2 + 1 ] = Integer.toString(info.versionCode); - i ++; - } - where.append(") "); - - return new QuerySelection(where.toString(), selectionArgs); + String where = ignore + " AND fdroid_app." + DataColumns.SUGGESTED_VERSION_CODE + " > installed.versionCode"; + return new AppQuerySelection(where).requireNaturalInstalledTable(); } - private QuerySelection queryInstalled() { - Map installedApps = Utils.getInstalledApps(getContext()); - StringBuilder where = new StringBuilder( " ( 0 "); - String[] selectionArgs = new String[installedApps.size()]; - int i = 0; - for (Map.Entry entry : installedApps.entrySet() ) { - where.append(" OR fdroid_app.") - .append(AppProvider.DataColumns.APP_ID) - .append(" = ? "); - selectionArgs[i] = entry.getKey(); - i ++; - } - where.append(" ) "); - - return new QuerySelection(where.toString(), selectionArgs); + private AppQuerySelection queryInstalled() { + return new AppQuerySelection().requireNaturalInstalledTable(); } - private QuerySelection querySearch(String keywords) { + private AppQuerySelection querySearch(String keywords) { keywords = "%" + keywords + "%"; String selection = "fdroid_app.id like ? OR " + @@ -409,34 +492,34 @@ public class AppProvider extends FDroidProvider { "fdroid_app.summary like ? OR " + "fdroid_app.description like ? "; String[] args = new String[] { keywords, keywords, keywords, keywords}; - return new QuerySelection(selection, args); + return new AppQuerySelection(selection, args); } - private QuerySelection querySingle(String id) { + private AppQuerySelection querySingle(String id) { String selection = "fdroid_app.id = ?"; String[] args = { id }; - return new QuerySelection(selection, args); + return new AppQuerySelection(selection, args); } - private QuerySelection queryIgnored() { + private AppQuerySelection queryIgnored() { String selection = "fdroid_app.ignoreAllUpdates = 1 OR " + "fdroid_app.ignoreThisUpdate >= fdroid_app.suggestedVercode"; - return new QuerySelection(selection); + return new AppQuerySelection(selection); } - private QuerySelection queryNewlyAdded() { + private AppQuerySelection queryNewlyAdded() { String selection = "fdroid_app.added > ?"; String[] args = { Utils.DATE_FORMAT.format(Preferences.get().calcMaxHistory()) }; - return new QuerySelection(selection, args); + return new AppQuerySelection(selection, args); } - private QuerySelection queryRecentlyUpdated() { + private AppQuerySelection queryRecentlyUpdated() { String selection = "fdroid_app.added != fdroid_app.lastUpdated AND fdroid_app.lastUpdated > ?"; String[] args = { Utils.DATE_FORMAT.format(Preferences.get().calcMaxHistory()) }; - return new QuerySelection(selection, args); + return new AppQuerySelection(selection, args); } - private QuerySelection queryCategory(String category) { + private AppQuerySelection queryCategory(String category) { // TODO: In the future, add a new table for categories, // so we can join onto it. String selection = @@ -450,67 +533,68 @@ public class AppProvider extends FDroidProvider { "%," + category, "%," + category + ",%", }; - return new QuerySelection(selection, args); + return new AppQuerySelection(selection, args); } - private QuerySelection queryNoApks() { + private AppQuerySelection queryNoApks() { String selection = "(SELECT COUNT(*) FROM fdroid_apk WHERE fdroid_apk.id = fdroid_app.id) = 0"; - return new QuerySelection(selection); + return new AppQuerySelection(selection); } - private QuerySelection queryApps(String appIds) { + private AppQuerySelection queryApps(String appIds) { String[] args = appIds.split(","); String selection = "fdroid_app.id IN (" + generateQuestionMarksForInClause(args.length) + ")"; - return new QuerySelection(selection, args); + return new AppQuerySelection(selection, args); } @Override - public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) { - QuerySelection query = new QuerySelection(selection, selectionArgs); + public Cursor query(Uri uri, String[] projection, String customSelection, String[] selectionArgs, String sortOrder) { + Query query = new Query(); + AppQuerySelection selection = new AppQuerySelection(customSelection, selectionArgs); switch (matcher.match(uri)) { case CODE_LIST: break; case CODE_SINGLE: - query = query.add(querySingle(uri.getLastPathSegment())); + selection = selection.add(querySingle(uri.getLastPathSegment())); break; case CAN_UPDATE: - query = query.add(queryCanUpdate()); + selection = selection.add(queryCanUpdate()); break; case INSTALLED: - query = query.add(queryInstalled()); + selection = selection.add(queryInstalled()); break; case SEARCH: - query = query.add(querySearch(uri.getLastPathSegment())); + selection = selection.add(querySearch(uri.getLastPathSegment())); break; case NO_APKS: - query = query.add(queryNoApks()); + selection = selection.add(queryNoApks()); break; case APPS: - query = query.add(queryApps(uri.getLastPathSegment())); + selection = selection.add(queryApps(uri.getLastPathSegment())); break; case IGNORED: - query = query.add(queryIgnored()); + selection = selection.add(queryIgnored()); break; case CATEGORY: - query = query.add(queryCategory(uri.getLastPathSegment())); + selection = selection.add(queryCategory(uri.getLastPathSegment())); break; case RECENTLY_UPDATED: sortOrder = " fdroid_app.lastUpdated DESC"; - query = query.add(queryRecentlyUpdated()); + selection = selection.add(queryRecentlyUpdated()); break; case NEWLY_ADDED: sortOrder = " fdroid_app.added DESC"; - query = query.add(queryNewlyAdded()); + selection = selection.add(queryNewlyAdded()); break; default: @@ -522,12 +606,11 @@ public class AppProvider extends FDroidProvider { sortOrder = " lower( fdroid_app." + sortOrder + " ) "; } - Query q = new Query(); - q.addFields(projection); - q.addSelection(query.getSelection()); - q.addOrderBy(sortOrder); + query.addSelection(selection); + query.addFields(projection); // TODO: Make the order of addFields/addSelection not dependent on each other... + query.addOrderBy(sortOrder); - Cursor cursor = read().rawQuery(q.toString(), query.getArgs()); + Cursor cursor = read().rawQuery(query.toString(), selection.getArgs()); cursor.setNotificationUri(getContext().getContentResolver(), uri); return cursor; } diff --git a/src/org/fdroid/fdroid/data/DBHelper.java b/src/org/fdroid/fdroid/data/DBHelper.java index c75fe67e9..49cfda93a 100644 --- a/src/org/fdroid/fdroid/data/DBHelper.java +++ b/src/org/fdroid/fdroid/data/DBHelper.java @@ -2,18 +2,20 @@ package org.fdroid.fdroid.data; import android.content.ContentValues; import android.content.Context; -import android.content.res.Resources; import android.database.Cursor; import android.database.sqlite.SQLiteDatabase; import android.database.sqlite.SQLiteOpenHelper; import android.util.Log; -import org.fdroid.fdroid.*; +import org.fdroid.fdroid.R; +import org.fdroid.fdroid.Utils; import java.util.ArrayList; import java.util.List; public class DBHelper extends SQLiteOpenHelper { + private static final String TAG = "org.fdroid.fdroid.data.DBHelper"; + public static final String DATABASE_NAME = "fdroid"; public static final String TABLE_REPO = "fdroid_repo"; @@ -23,7 +25,6 @@ public class DBHelper extends SQLiteOpenHelper { // This information is retrieved from the repositories. public static final String TABLE_APK = "fdroid_apk"; - private static final String CREATE_TABLE_REPO = "create table " + TABLE_REPO + " (_id integer primary key, " + "address text not null, " @@ -87,7 +88,15 @@ public class DBHelper extends SQLiteOpenHelper { + "iconUrl text, " + "primary key(id));"; - private static final int DB_VERSION = 42; + public static final String TABLE_INSTALLED_APP = "fdroid_installedApp"; + private static final String CREATE_TABLE_INSTALLED_APP = "CREATE TABLE " + TABLE_INSTALLED_APP + + " ( " + + "appId TEXT NOT NULL PRIMARY KEY, " + + "versionCode INT NOT NULL, " + + "versionName TEXT NOT NULL " + + " );"; + + private static final int DB_VERSION = 43; private Context context; @@ -177,6 +186,7 @@ public class DBHelper extends SQLiteOpenHelper { public void onCreate(SQLiteDatabase db) { createAppApk(db); + createInstalledApp(db); db.execSQL(CREATE_TABLE_REPO); insertRepo( @@ -239,6 +249,8 @@ public class DBHelper extends SQLiteOpenHelper { addLastUpdatedToRepo(db, oldVersion); renameRepoId(db, oldVersion); populateRepoNames(db, oldVersion); + + if (oldVersion < 43) createInstalledApp(db); } /** @@ -381,6 +393,11 @@ public class DBHelper extends SQLiteOpenHelper { db.execSQL("create index apk_id on " + TABLE_APK + " (id);"); } + private void createInstalledApp(SQLiteDatabase db) { + Log.d(TAG, "Creating 'installed app' database table."); + db.execSQL(CREATE_TABLE_INSTALLED_APP); + } + private static boolean columnExists(SQLiteDatabase db, String table, String column) { return (db.rawQuery( "select * from " + table + " limit 0,1", null ) diff --git a/src/org/fdroid/fdroid/data/FDroidProvider.java b/src/org/fdroid/fdroid/data/FDroidProvider.java index 7ae36ff51..aad493d4a 100644 --- a/src/org/fdroid/fdroid/data/FDroidProvider.java +++ b/src/org/fdroid/fdroid/data/FDroidProvider.java @@ -26,6 +26,14 @@ public abstract class FDroidProvider extends ContentProvider { abstract protected String getProviderName(); + /** + * Should always be the same as the provider:name in the AndroidManifest + * @return + */ + public final String getName() { + return AUTHORITY + "." + getProviderName(); + } + /** * Tells us if we are in the middle of a batch of operations. Allows us to * decide not to notify the content resolver of changes, diff --git a/src/org/fdroid/fdroid/data/InstalledAppCacheUpdater.java b/src/org/fdroid/fdroid/data/InstalledAppCacheUpdater.java new file mode 100644 index 000000000..7d7b65d22 --- /dev/null +++ b/src/org/fdroid/fdroid/data/InstalledAppCacheUpdater.java @@ -0,0 +1,194 @@ +package org.fdroid.fdroid.data; + +import android.content.ContentProviderOperation; +import android.content.Context; +import android.content.OperationApplicationException; +import android.content.pm.PackageInfo; +import android.net.Uri; +import android.os.AsyncTask; +import android.os.RemoteException; +import android.util.Log; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * Compares what is in the fdroid_installedApp SQLite database table with the package + * info that we can gleam from the {@link android.content.pm.PackageManager}. If there + * is any updates/removals/insertions which need to take place, we will perform them. + * TODO: The content providers are not thread safe, so it is possible we will be writing + * to the database at the same time we respond to a broadcasted intent. + */ +public class InstalledAppCacheUpdater { + + private static final String TAG = "org.fdroid.fdroid.data.InstalledAppCacheUpdater"; + + private Context context; + + private List toInsert = new ArrayList(); + private List toUpdate = new ArrayList(); + private List toDelete = new ArrayList(); + + protected InstalledAppCacheUpdater(Context context) { + this.context = context; + } + + /** + * Ensure our database of installed apps is in sync with what the PackageManager tells us is installed. + * Once completed, the relevant ContentProviders will be notified of any changes to installed statuses. + * This method will block until completed, which could be in the order of a few seconds (depending on + * how many apps are installed). + */ + public static void updateInForeground(Context context) { + InstalledAppCacheUpdater updater = new InstalledAppCacheUpdater(context); + if (updater.update()) { + updater.notifyProviders(); + } + } + + /** + * Ensure our database of installed apps is in sync with what the PackageManager tells us is installed. + * Once completed, the relevant ContentProviders will be notified of any changes to installed statuses. + * This method returns immediately, and will continue to work in an AsyncTask. + */ + public static void updateInBackground(Context context) { + InstalledAppCacheUpdater updater = new InstalledAppCacheUpdater(context); + updater.startBackgroundWorker(); + } + + protected boolean update() { + + long startTime = System.currentTimeMillis(); + + compareCacheToPackageManager(); + updateCache(); + + long duration = System.currentTimeMillis() - startTime; + Log.d(TAG, "Took " + duration + "ms to compare the installed app cache with PackageManager."); + + return hasChanged(); + } + + protected void notifyProviders() { + Log.i(TAG, "Installed app cache has changed, notifying content providers (so they can update the relevant views)."); + context.getContentResolver().notifyChange(AppProvider.getContentUri(), null); + context.getContentResolver().notifyChange(ApkProvider.getContentUri(), null); + } + + protected void startBackgroundWorker() { + new Worker().execute(); + } + + /** + * If any of the cached app details have been removed, updated or inserted, + * then the cache has changed. + */ + private boolean hasChanged() { + return toInsert.size() > 0 || toUpdate.size() > 0 || toDelete.size() > 0; + } + + private void updateCache() { + + ArrayList ops = new ArrayList(); + ops.addAll(deleteFromCache(toDelete)); + ops.addAll(updateCachedValues(toUpdate)); + ops.addAll(insertIntoCache(toInsert)); + + if (ops.size() > 0) { + try { + context.getContentResolver().applyBatch(InstalledAppProvider.getAuthority(), ops); + Log.d(TAG, "Finished executing " + ops.size() + " CRUD operations on installed app cache."); + } catch (RemoteException e) { + Log.e(TAG, "Error updating installed app cache: " + e); + } catch (OperationApplicationException e) { + Log.e(TAG, "Error updating installed app cache: " + e); + } + } + + } + + private void compareCacheToPackageManager() { + + Map cachedInfo = InstalledAppProvider.Helper.all(context); + + List installedPackages = context.getPackageManager().getInstalledPackages(0); + for (PackageInfo appInfo : installedPackages) { + if (!cachedInfo.containsKey(appInfo.packageName)) { + toInsert.add(appInfo); + } else { + if (cachedInfo.get(appInfo.packageName) < appInfo.versionCode) { + toUpdate.add(appInfo); + } + cachedInfo.remove(appInfo.packageName); + } + } + + if (cachedInfo.size() > 0) { + for (Map.Entry entry : cachedInfo.entrySet() ) { + toDelete.add(entry.getKey()); + } + } + } + + private List insertIntoCache(List appsToInsert) { + List ops = new ArrayList(appsToInsert.size()); + if (appsToInsert.size() > 0) { + Log.d(TAG, "Preparing to cache installed info for " + appsToInsert.size() + " new apps."); + Uri uri = InstalledAppProvider.getContentUri(); + for (PackageInfo info : appsToInsert) { + ContentProviderOperation op = ContentProviderOperation.newInsert(uri) + .withValue(InstalledAppProvider.DataColumns.APP_ID, info.packageName) + .withValue(InstalledAppProvider.DataColumns.VERSION_CODE, info.versionCode) + .withValue(InstalledAppProvider.DataColumns.VERSION_NAME, info.versionName) + .build(); + ops.add(op); + } + } + return ops; + } + + private List updateCachedValues(List appsToUpdate) { + List ops = new ArrayList(appsToUpdate.size()); + if (appsToUpdate.size() > 0) { + Log.d(TAG, "Preparing to update installed app cache for " + appsToUpdate.size() + " apps."); + for (PackageInfo info : appsToUpdate) { + Uri uri = InstalledAppProvider.getAppUri(info.packageName); + ContentProviderOperation op = ContentProviderOperation.newUpdate(uri) + .withValue(InstalledAppProvider.DataColumns.VERSION_CODE, info.versionCode) + .withValue(InstalledAppProvider.DataColumns.VERSION_NAME, info.versionName) + .build(); + ops.add(op); + } + } + return ops; + } + + private List deleteFromCache(List appIds) { + List ops = new ArrayList(appIds.size()); + if (appIds.size() > 0) { + Log.d(TAG, "Preparing to remove " + appIds.size() + " apps from the installed app cache."); + for (String appId : appIds) { + Uri uri = InstalledAppProvider.getAppUri(appId); + ops.add(ContentProviderOperation.newDelete(uri).build()); + } + } + return ops; + } + + private class Worker extends AsyncTask { + + @Override + protected Boolean doInBackground(Void... params) { + return update(); + } + + @Override + protected void onPostExecute(Boolean changed) { + if (changed) { + notifyProviders(); + } + } + } + +} diff --git a/src/org/fdroid/fdroid/data/InstalledAppProvider.java b/src/org/fdroid/fdroid/data/InstalledAppProvider.java new file mode 100644 index 000000000..20cacc656 --- /dev/null +++ b/src/org/fdroid/fdroid/data/InstalledAppProvider.java @@ -0,0 +1,180 @@ +package org.fdroid.fdroid.data; + +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.R; + +import java.util.HashMap; +import java.util.Map; + +public class InstalledAppProvider extends FDroidProvider { + + public static class Helper { + + /** + * @return The keys are the app ids (package names), and their corresponding values are + * the version code which is installed. + */ + public static Map all(Context context) { + + Map cachedInfo = new HashMap(); + + Uri uri = InstalledAppProvider.getContentUri(); + String[] projection = InstalledAppProvider.DataColumns.ALL; + Cursor cursor = context.getContentResolver().query(uri, projection, null, null, null); + if (cursor != null) { + cursor.moveToFirst(); + while (!cursor.isAfterLast()) { + cachedInfo.put( + cursor.getString(cursor.getColumnIndex(InstalledAppProvider.DataColumns.APP_ID)), + cursor.getInt(cursor.getColumnIndex(InstalledAppProvider.DataColumns.VERSION_CODE)) + ); + cursor.moveToNext(); + } + cursor.close(); + } + + return cachedInfo; + } + + } + + public interface DataColumns { + + public static final String APP_ID = "appId"; + public static final String VERSION_CODE = "versionCode"; + public static final String VERSION_NAME = "versionName"; + + public static String[] ALL = { APP_ID, VERSION_CODE, VERSION_NAME }; + + } + + private static final String PROVIDER_NAME = "InstalledAppProvider"; + + private static final UriMatcher matcher = new UriMatcher(-1); + + static { + matcher.addURI(getAuthority(), null, CODE_LIST); + matcher.addURI(getAuthority(), "*", CODE_SINGLE); + } + + public static Uri getContentUri() { + return Uri.parse("content://" + getAuthority()); + } + + public static Uri getAppUri(String appId) { + return Uri.withAppendedPath(getContentUri(), appId); + } + + @Override + protected String getTableName() { + return DBHelper.TABLE_INSTALLED_APP; + } + + @Override + protected String getProviderName() { + return "InstalledAppProvider"; + } + + public static String getAuthority() { + return AUTHORITY + "." + PROVIDER_NAME; + } + + @Override + protected UriMatcher getMatcher() { + return matcher; + } + + private QuerySelection queryApp(String appId) { + return new QuerySelection("appId = ?", new String[] { appId } ); + } + + @Override + public Cursor query(Uri uri, String[] projection, String customSelection, String[] selectionArgs, String sortOrder) { + QuerySelection selection = new QuerySelection(customSelection, selectionArgs); + switch (matcher.match(uri)) { + case CODE_LIST: + break; + + case CODE_SINGLE: + selection = selection.add(queryApp(uri.getLastPathSegment())); + break; + + default: + String message = "Invalid URI for installed app content provider: " + uri; + Log.e("FDroid", message); + throw new UnsupportedOperationException(message); + } + + Cursor cursor = read().query(getTableName(), projection, selection.getSelection(), selection.getArgs(), null, null, null); + cursor.setNotificationUri(getContext().getContentResolver(), uri); + return cursor; + } + + @Override + public int delete(Uri uri, String where, String[] whereArgs) { + + if (matcher.match(uri) != CODE_SINGLE) { + throw new UnsupportedOperationException("Delete not supported for " + uri + "."); + } + + QuerySelection query = new QuerySelection(where, whereArgs); + query = query.add(queryApp(uri.getLastPathSegment())); + + int count = write().delete(getTableName(), query.getSelection(), query.getArgs()); + if (!isApplyingBatch()) { + getContext().getContentResolver().notifyChange(uri, null); + } + return count; + } + + @Override + public Uri insert(Uri uri, ContentValues values) { + + if (matcher.match(uri) != CODE_LIST) { + throw new UnsupportedOperationException("Insert not supported for " + uri + "."); + } + + verifyVersionNameNotNull(values); + write().insertOrThrow(getTableName(), null, values); + if (!isApplyingBatch()) { + getContext().getContentResolver().notifyChange(uri, null); + } + return getAppUri(values.getAsString(DataColumns.APP_ID)); + } + + @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 + "."); + } + + QuerySelection query = new QuerySelection(where, whereArgs); + query = query.add(queryApp(uri.getLastPathSegment())); + + verifyVersionNameNotNull(values); + int count = write().update(getTableName(), values, query.getSelection(), query.getArgs()); + if (!isApplyingBatch()) { + getContext().getContentResolver().notifyChange(uri, null); + } + return count; + } + + /** + * During development, I stumbled across one (out of over 300) installed apps which had a versionName + * of null. As such, I figured we may as well store it as "Unknown". The alternative is to allow the + * column to accept NULL values in the database, and then deal with the potential of a null everywhere + * "versionName" is used. + */ + private void verifyVersionNameNotNull(ContentValues values) { + if (values.containsKey(DataColumns.VERSION_NAME) && values.getAsString(DataColumns.VERSION_NAME) == null) { + values.put(DataColumns.VERSION_NAME, getContext().getString(R.string.unknown)); + } + } + +} diff --git a/src/org/fdroid/fdroid/data/QueryBuilder.java b/src/org/fdroid/fdroid/data/QueryBuilder.java index 6c988efc0..63cb4dd37 100644 --- a/src/org/fdroid/fdroid/data/QueryBuilder.java +++ b/src/org/fdroid/fdroid/data/QueryBuilder.java @@ -62,17 +62,27 @@ abstract class QueryBuilder { this.orderBy = orderBy; } - protected final void leftJoin(String table, String alias, - String condition) { - tables.append(" LEFT JOIN "); - tables.append(table); + protected final void leftJoin(String table, String alias, String condition) { + joinWithType("LEFT", table, alias, condition); + } + + protected final void join(String table, String alias, String condition) { + joinWithType("", table, alias, condition); + } + + private void joinWithType(String type, String table, String alias, String condition) { + tables.append(' ') + .append(type) + .append(" JOIN ") + .append(table); + if (alias != null) { - tables.append(" AS "); - tables.append(alias); + tables.append(" AS ").append(alias); } - tables.append(" ON ("); - tables.append(condition); - tables.append(")"); + + tables.append(" ON (") + .append(condition) + .append(')'); } private String fieldsSql() { diff --git a/src/org/fdroid/fdroid/views/AppListAdapter.java b/src/org/fdroid/fdroid/views/AppListAdapter.java index d7c179c49..8396d5a57 100644 --- a/src/org/fdroid/fdroid/views/AppListAdapter.java +++ b/src/org/fdroid/fdroid/views/AppListAdapter.java @@ -126,16 +126,14 @@ abstract public class AppListAdapter extends CursorAdapter { return null; } - PackageInfo installedInfo = app.getInstalledInfo(mContext); - - if (installedInfo == null) { + if (!app.isInstalled()) { return app.getSuggestedVersion(); } - String installedVersionString = installedInfo.versionName; - int installedVersionCode = installedInfo.versionCode; + String installedVersionString = app.installedVersionName; + int installedVersionCode = app.installedVersionCode; - if (app.canAndWantToUpdate(mContext) && showStatusUpdate()) { + if (app.canAndWantToUpdate() && showStatusUpdate()) { return installedVersionString + " → " + app.getSuggestedVersion(); } diff --git a/src/org/fdroid/fdroid/views/fragments/AppListFragment.java b/src/org/fdroid/fdroid/views/fragments/AppListFragment.java index f2cfc9f93..d2fd1d9e9 100644 --- a/src/org/fdroid/fdroid/views/fragments/AppListFragment.java +++ b/src/org/fdroid/fdroid/views/fragments/AppListFragment.java @@ -35,6 +35,8 @@ abstract public class AppListFragment extends ListFragment implements AppProvider.DataColumns.LICENSE, AppProvider.DataColumns.ICON, AppProvider.DataColumns.ICON_URL, + AppProvider.DataColumns.InstalledApp.VERSION_CODE, + AppProvider.DataColumns.InstalledApp.VERSION_NAME, AppProvider.DataColumns.SuggestedApk.VERSION, AppProvider.DataColumns.SUGGESTED_VERSION_CODE, AppProvider.DataColumns.IGNORE_ALLUPDATES, diff --git a/test/src/mock/MockInstallablePackageManager.java b/test/src/mock/MockInstallablePackageManager.java index bd47d86f4..f291c1517 100644 --- a/test/src/mock/MockInstallablePackageManager.java +++ b/test/src/mock/MockInstallablePackageManager.java @@ -4,6 +4,7 @@ import android.content.pm.PackageInfo; import android.test.mock.MockPackageManager; import java.util.ArrayList; +import java.util.Iterator; import java.util.List; public class MockInstallablePackageManager extends MockPackageManager { @@ -16,11 +17,36 @@ public class MockInstallablePackageManager extends MockPackageManager { } public void install(String id, int version, String versionName) { - PackageInfo p = new PackageInfo(); - p.packageName = id; - p.versionCode = version; - p.versionName = versionName; - info.add(p); + PackageInfo existing = getPackageInfo(id); + if (existing != null) { + existing.versionCode = version; + existing.versionName = versionName; + } else { + PackageInfo p = new PackageInfo(); + p.packageName = id; + p.versionCode = version; + p.versionName = versionName; + info.add(p); + } + } + + public PackageInfo getPackageInfo(String id) { + for (PackageInfo i : info) { + if (i.packageName.equals(id)) { + return i; + } + } + return null; + } + + public void remove(String id) { + for (Iterator it = info.iterator(); it.hasNext();) { + PackageInfo info = it.next(); + if (info.packageName.equals(id)) { + it.remove(); + return; + } + } } } diff --git a/test/src/org/fdroid/fdroid/AppProviderTest.java b/test/src/org/fdroid/fdroid/AppProviderTest.java index e36a32ba3..12124848e 100644 --- a/test/src/org/fdroid/fdroid/AppProviderTest.java +++ b/test/src/org/fdroid/fdroid/AppProviderTest.java @@ -4,14 +4,13 @@ import android.content.ContentResolver; import android.content.ContentValues; import android.content.res.Resources; import android.database.Cursor; - import mock.MockCategoryResources; import mock.MockContextSwappableComponents; import mock.MockInstallablePackageManager; - import org.fdroid.fdroid.data.ApkProvider; import org.fdroid.fdroid.data.App; import org.fdroid.fdroid.data.AppProvider; +import org.fdroid.fdroid.data.InstalledAppCacheUpdater; import java.util.ArrayList; import java.util.List; @@ -41,6 +40,42 @@ public class AppProviderTest extends FDroidProviderTest { }; } + /** + * Although this doesn't directly relate to the AppProvider, it is here because + * the AppProvider used to stumble across this bug when asking for installed apps, + * and the device had over 1000 apps installed. + */ + public void testMaxSqliteParams() { + + MockInstallablePackageManager pm = new MockInstallablePackageManager(); + getSwappableContext().setPackageManager(pm); + + insertApp("com.example.app1", "App 1"); + insertApp("com.example.app100", "App 100"); + insertApp("com.example.app1000", "App 1000"); + + for (int i = 0; i < 50; i ++) { + pm.install("com.example.app" + i, 1, "v" + 1); + } + InstalledAppCacheUpdater.updateInForeground(getMockContext()); + + assertResultCount(1, AppProvider.getInstalledUri()); + + for (int i = 50; i < 500; i ++) { + pm.install("com.example.app" + i, 1, "v" + 1); + } + InstalledAppCacheUpdater.updateInForeground(getMockContext()); + + assertResultCount(2, AppProvider.getInstalledUri()); + + for (int i = 500; i < 1100; i ++) { + pm.install("com.example.app" + i, 1, "v" + 1); + } + InstalledAppCacheUpdater.updateInForeground(getMockContext()); + + assertResultCount(3, AppProvider.getInstalledUri()); + } + public void testCantFindApp() { assertNull(AppProvider.Helper.findById(getMockContentResolver(), "com.example.doesnt-exist")); } @@ -87,7 +122,7 @@ public class AppProviderTest extends FDroidProviderTest { values.put(AppProvider.DataColumns.IGNORE_THISUPDATE, ignoreVercode); insertApp(id, "App: " + id, values); - packageManager.install(id, installedVercode, "v" + installedVercode); + TestUtils.installAndBroadcast(getMockContext(), packageManager, id, installedVercode, "v" + installedVercode); } public void testCanUpdate() { @@ -112,7 +147,7 @@ public class AppProviderTest extends FDroidProviderTest { // Can't "update", although can "install"... App notInstalled = AppProvider.Helper.findById(r, "not installed"); - assertFalse(notInstalled.canAndWantToUpdate(c)); + assertFalse(notInstalled.canAndWantToUpdate()); App installedOnlyOneVersionAvailable = AppProvider.Helper.findById(r, "installed, only one version available"); App installedAlreadyLatestNoIgnore = AppProvider.Helper.findById(r, "installed, already latest, no ignore"); @@ -120,21 +155,36 @@ public class AppProviderTest extends FDroidProviderTest { App installedAlreadyLatestIgnoreLatest = AppProvider.Helper.findById(r, "installed, already latest, ignore latest"); App installedAlreadyLatestIgnoreOld = AppProvider.Helper.findById(r, "installed, already latest, ignore old"); - assertFalse(installedOnlyOneVersionAvailable.canAndWantToUpdate(c)); - assertFalse(installedAlreadyLatestNoIgnore.canAndWantToUpdate(c)); - assertFalse(installedAlreadyLatestIgnoreAll.canAndWantToUpdate(c)); - assertFalse(installedAlreadyLatestIgnoreLatest.canAndWantToUpdate(c)); - assertFalse(installedAlreadyLatestIgnoreOld.canAndWantToUpdate(c)); + assertFalse(installedOnlyOneVersionAvailable.canAndWantToUpdate()); + assertFalse(installedAlreadyLatestNoIgnore.canAndWantToUpdate()); + assertFalse(installedAlreadyLatestIgnoreAll.canAndWantToUpdate()); + assertFalse(installedAlreadyLatestIgnoreLatest.canAndWantToUpdate()); + assertFalse(installedAlreadyLatestIgnoreOld.canAndWantToUpdate()); App installedOldNoIgnore = AppProvider.Helper.findById(r, "installed, old version, no ignore"); App installedOldIgnoreAll = AppProvider.Helper.findById(r, "installed, old version, ignore all"); App installedOldIgnoreLatest = AppProvider.Helper.findById(r, "installed, old version, ignore latest"); App installedOldIgnoreNewerNotLatest = AppProvider.Helper.findById(r, "installed, old version, ignore newer, but not latest"); - assertTrue(installedOldNoIgnore.canAndWantToUpdate(c)); - assertFalse(installedOldIgnoreAll.canAndWantToUpdate(c)); - assertFalse(installedOldIgnoreLatest.canAndWantToUpdate(c)); - assertTrue(installedOldIgnoreNewerNotLatest.canAndWantToUpdate(c)); + assertTrue(installedOldNoIgnore.canAndWantToUpdate()); + assertFalse(installedOldIgnoreAll.canAndWantToUpdate()); + assertFalse(installedOldIgnoreLatest.canAndWantToUpdate()); + assertTrue(installedOldIgnoreNewerNotLatest.canAndWantToUpdate()); + + Cursor canUpdateCursor = r.query(AppProvider.getCanUpdateUri(), AppProvider.DataColumns.ALL, null, null, null); + canUpdateCursor.moveToFirst(); + List canUpdateIds = new ArrayList(canUpdateCursor.getCount()); + while (!canUpdateCursor.isAfterLast()) { + canUpdateIds.add(new App(canUpdateCursor).id); + canUpdateCursor.moveToNext(); + } + + String[] expectedUpdateableIds = { + "installed, old version, no ignore", + "installed, old version, ignore newer, but not latest", + }; + + TestUtils.assertContainsOnly(expectedUpdateableIds, canUpdateIds); } public void testIgnored() { @@ -182,9 +232,6 @@ public class AppProviderTest extends FDroidProviderTest { } public void testInstalled() { - - Utils.clearInstalledApksCache(); - MockInstallablePackageManager pm = new MockInstallablePackageManager(); getSwappableContext().setPackageManager(pm); @@ -194,7 +241,7 @@ public class AppProviderTest extends FDroidProviderTest { assertResultCount(0, AppProvider.getInstalledUri()); for (int i = 10; i < 20; i ++) { - pm.install("com.example.test." + i, i, "v1"); + TestUtils.installAndBroadcast(getMockContext(), pm, "com.example.test." + i, i, "v1"); } assertResultCount(10, AppProvider.getInstalledUri()); @@ -237,6 +284,13 @@ public class AppProviderTest extends FDroidProviderTest { return getMockContentResolver().query(AppProvider.getContentUri(), getMinimalProjection(), null, null, null); } + // ======================================================================== + // "Categories" + // (at this point) not an additional table, but we treat them sort of + // like they are. That means that if we change the implementation to + // use a separate table in the future, these should still pass. + // ======================================================================== + public void testCategoriesSingle() { insertAppWithCategory("com.dog", "Dog", "Animal"); insertAppWithCategory("com.rock", "Rock", "Mineral"); @@ -300,12 +354,16 @@ public class AppProviderTest extends FDroidProviderTest { TestUtils.assertContainsOnly(categoriesLonger, expectedLonger); } + // ======================================================================= + // Misc helper functions + // (to be used by any tests in this suite) + // ======================================================================= + private void insertApp(String id, String name) { insertApp(id, name, new ContentValues()); } - private void insertAppWithCategory(String id, String name, - String categories) { + private void insertAppWithCategory(String id, String name, String categories) { ContentValues values = new ContentValues(1); values.put(AppProvider.DataColumns.CATEGORIES, categories); insertApp(id, name, values); diff --git a/test/src/org/fdroid/fdroid/FDroidProviderTest.java b/test/src/org/fdroid/fdroid/FDroidProviderTest.java index a2b4a8a18..1be7eec3d 100644 --- a/test/src/org/fdroid/fdroid/FDroidProviderTest.java +++ b/test/src/org/fdroid/fdroid/FDroidProviderTest.java @@ -9,17 +9,22 @@ import android.net.Uri; import android.os.Build; import android.provider.ContactsContract; import android.test.ProviderTestCase2MockContext; -import mock.MockCategoryResources; import mock.MockContextEmptyComponents; import mock.MockContextSwappableComponents; import mock.MockFDroidResources; -import org.fdroid.fdroid.data.FDroidProvider; -import org.fdroid.fdroid.mock.MockInstalledApkCache; +import org.fdroid.fdroid.data.*; import java.util.List; public abstract class FDroidProviderTest extends ProviderTestCase2MockContext { + private FDroidProvider[] allProviders = { + new AppProvider(), + new RepoProvider(), + new ApkProvider(), + new InstalledAppProvider(), + }; + private MockContextSwappableComponents swappableContext; public FDroidProviderTest(Class providerClass, String providerAuthority) { @@ -33,7 +38,18 @@ public abstract class FDroidProviderTest extends Provi @Override public void setUp() throws Exception { super.setUp(); - Utils.setupInstalledApkCache(new MockInstalledApkCache()); + + // Instantiate all providers other than the one which was already created by the base class. + // This is because F-Droid providers tend to perform joins onto tables managed by other + // providers, and so we need to be able to insert into those other providers for these + // joins to be tested correctly. + for (FDroidProvider provider : allProviders) { + if (!provider.getName().equals(getProvider().getName())) { + provider.attachInfo(getMockContext(), null); + getMockContentResolver().addProvider(provider.getName(), provider); + } + } + getSwappableContext().setResources(getMockResources()); // The *Provider.Helper.* functions tend to take a Context as their @@ -127,4 +143,26 @@ public abstract class FDroidProviderTest extends Provi assertNotNull(result); assertEquals(expectedCount, result.getCount()); } + + protected void assertIsInstalledVersionInDb(String appId, int versionCode, String versionName) { + Uri uri = InstalledAppProvider.getAppUri(appId); + + String[] projection = { + InstalledAppProvider.DataColumns.APP_ID, + InstalledAppProvider.DataColumns.VERSION_CODE, + InstalledAppProvider.DataColumns.VERSION_NAME, + }; + + Cursor cursor = getMockContentResolver().query(uri, projection, null, null, null); + + assertNotNull(cursor); + assertEquals("App \"" + appId + "\" not installed", 1, cursor.getCount()); + + cursor.moveToFirst(); + + assertEquals(appId, cursor.getString(cursor.getColumnIndex(InstalledAppProvider.DataColumns.APP_ID))); + assertEquals(versionCode, cursor.getInt(cursor.getColumnIndex(InstalledAppProvider.DataColumns.VERSION_CODE))); + assertEquals(versionName, cursor.getString(cursor.getColumnIndex(InstalledAppProvider.DataColumns.VERSION_NAME))); + } + } diff --git a/test/src/org/fdroid/fdroid/InstalledAppCacheTest.java b/test/src/org/fdroid/fdroid/InstalledAppCacheTest.java new file mode 100644 index 000000000..2a849da7f --- /dev/null +++ b/test/src/org/fdroid/fdroid/InstalledAppCacheTest.java @@ -0,0 +1,179 @@ +package org.fdroid.fdroid; + +import android.database.Cursor; +import android.net.Uri; +import mock.MockInstallablePackageManager; +import org.fdroid.fdroid.data.InstalledAppCacheUpdater; +import org.fdroid.fdroid.data.InstalledAppProvider; + +/** + * Tests the ability of the {@link org.fdroid.fdroid.data.InstalledAppCacheUpdater} to stay in sync with + * the {@link android.content.pm.PackageManager}. + * For practical reasons, it extends FDroidProviderTest, although there is also a + * separate test for the InstalledAppProvider which tests the CRUD operations in more detail. + */ +public class InstalledAppCacheTest extends FDroidProviderTest { + + private MockInstallablePackageManager packageManager; + + public InstalledAppCacheTest() { + super(InstalledAppProvider.class, InstalledAppProvider.getAuthority()); + } + + @Override + public void setUp() throws Exception { + super.setUp(); + packageManager = new MockInstallablePackageManager(); + getSwappableContext().setPackageManager(packageManager); + } + + @Override + protected String[] getMinimalProjection() { + return new String[] { + InstalledAppProvider.DataColumns.APP_ID + }; + } + + public void install(String appId, int versionCode, String versionName) { + packageManager.install(appId, versionCode, versionName); + } + + public void remove(String appId) { + packageManager.remove(appId); + } + + public void testFromEmptyCache() { + assertResultCount(0, InstalledAppProvider.getContentUri()); + for (int i = 1; i <= 15; i ++) { + install("com.example.app" + i, 200, "2.0"); + } + InstalledAppCacheUpdater.updateInForeground(getMockContext()); + + String[] expectedInstalledIds = { + "com.example.app1", + "com.example.app2", + "com.example.app3", + "com.example.app4", + "com.example.app5", + "com.example.app6", + "com.example.app7", + "com.example.app8", + "com.example.app9", + "com.example.app10", + "com.example.app11", + "com.example.app12", + "com.example.app13", + "com.example.app14", + "com.example.app15", + }; + + TestUtils.assertContainsOnly(getInstalledAppIdsFromProvider(), expectedInstalledIds); + } + + private String[] getInstalledAppIdsFromProvider() { + Uri uri = InstalledAppProvider.getContentUri(); + String[] projection = { InstalledAppProvider.DataColumns.APP_ID }; + Cursor result = getMockContext().getContentResolver().query(uri, projection, null, null, null); + if (result == null) { + return new String[0]; + } + + String[] installedAppIds = new String[result.getCount()]; + result.moveToFirst(); + int i = 0; + while (!result.isAfterLast()) { + installedAppIds[i] = result.getString(result.getColumnIndex(InstalledAppProvider.DataColumns.APP_ID)); + result.moveToNext(); + i ++; + } + result.close(); + return installedAppIds; + } + + public void testAppsAdded() { + assertResultCount(0, InstalledAppProvider.getContentUri()); + + install("com.example.app1", 1, "v1"); + install("com.example.app2", 1, "v1"); + install("com.example.app3", 1, "v1"); + InstalledAppCacheUpdater.updateInForeground(getMockContext()); + + assertResultCount(3, InstalledAppProvider.getContentUri()); + assertIsInstalledVersionInDb("com.example.app1", 1, "v1"); + assertIsInstalledVersionInDb("com.example.app2", 1, "v1"); + assertIsInstalledVersionInDb("com.example.app3", 1, "v1"); + + install("com.example.app10", 1, "v1"); + install("com.example.app11", 1, "v1"); + install("com.example.app12", 1, "v1"); + InstalledAppCacheUpdater.updateInForeground(getMockContext()); + + assertResultCount(6, InstalledAppProvider.getContentUri()); + assertIsInstalledVersionInDb("com.example.app10", 1, "v1"); + assertIsInstalledVersionInDb("com.example.app11", 1, "v1"); + assertIsInstalledVersionInDb("com.example.app12", 1, "v1"); + } + + public void testAppsRemoved() { + install("com.example.app1", 1, "v1"); + install("com.example.app2", 1, "v1"); + install("com.example.app3", 1, "v1"); + InstalledAppCacheUpdater.updateInForeground(getMockContext()); + + assertResultCount(3, InstalledAppProvider.getContentUri()); + assertIsInstalledVersionInDb("com.example.app1", 1, "v1"); + assertIsInstalledVersionInDb("com.example.app2", 1, "v1"); + assertIsInstalledVersionInDb("com.example.app3", 1, "v1"); + + remove("com.example.app2"); + InstalledAppCacheUpdater.updateInForeground(getMockContext()); + + assertResultCount(2, InstalledAppProvider.getContentUri()); + assertIsInstalledVersionInDb("com.example.app1", 1, "v1"); + assertIsInstalledVersionInDb("com.example.app3", 1, "v1"); + } + + public void testAppsUpdated() { + install("com.example.app1", 1, "v1"); + install("com.example.app2", 1, "v1"); + InstalledAppCacheUpdater.updateInForeground(getMockContext()); + + assertResultCount(2, InstalledAppProvider.getContentUri()); + assertIsInstalledVersionInDb("com.example.app1", 1, "v1"); + assertIsInstalledVersionInDb("com.example.app2", 1, "v1"); + + install("com.example.app2", 20, "v2.0"); + InstalledAppCacheUpdater.updateInForeground(getMockContext()); + + assertResultCount(2, InstalledAppProvider.getContentUri()); + assertIsInstalledVersionInDb("com.example.app1", 1, "v1"); + assertIsInstalledVersionInDb("com.example.app2", 20, "v2.0"); + } + + public void testAppsAddedRemovedAndUpdated() { + install("com.example.app1", 1, "v1"); + install("com.example.app2", 1, "v1"); + install("com.example.app3", 1, "v1"); + install("com.example.app4", 1, "v1"); + InstalledAppCacheUpdater.updateInForeground(getMockContext()); + + assertResultCount(4, InstalledAppProvider.getContentUri()); + assertIsInstalledVersionInDb("com.example.app1", 1, "v1"); + assertIsInstalledVersionInDb("com.example.app2", 1, "v1"); + assertIsInstalledVersionInDb("com.example.app3", 1, "v1"); + assertIsInstalledVersionInDb("com.example.app4", 1, "v1"); + + install("com.example.app1", 13, "v1.3"); + remove("com.example.app2"); + remove("com.example.app3"); + install("com.example.app10", 1, "v1"); + InstalledAppCacheUpdater.updateInForeground(getMockContext()); + + assertResultCount(3, InstalledAppProvider.getContentUri()); + assertIsInstalledVersionInDb("com.example.app1", 13, "v1.3"); + assertIsInstalledVersionInDb("com.example.app4", 1, "v1"); + assertIsInstalledVersionInDb("com.example.app10", 1, "v1"); + + } + +} diff --git a/test/src/org/fdroid/fdroid/InstalledAppProviderTest.java b/test/src/org/fdroid/fdroid/InstalledAppProviderTest.java new file mode 100644 index 000000000..017ac4e7a --- /dev/null +++ b/test/src/org/fdroid/fdroid/InstalledAppProviderTest.java @@ -0,0 +1,168 @@ +package org.fdroid.fdroid; + +import android.content.ContentValues; +import mock.MockInstallablePackageManager; +import org.fdroid.fdroid.data.ApkProvider; +import org.fdroid.fdroid.data.AppProvider; +import org.fdroid.fdroid.data.InstalledAppProvider; +import org.fdroid.fdroid.data.RepoProvider; + +public class InstalledAppProviderTest extends FDroidProviderTest { + + private MockInstallablePackageManager packageManager; + + public InstalledAppProviderTest() { + super(InstalledAppProvider.class, InstalledAppProvider.getAuthority()); + } + + @Override + public void setUp() throws Exception { + super.setUp(); + packageManager = new MockInstallablePackageManager(); + getSwappableContext().setPackageManager(packageManager); + } + + protected MockInstallablePackageManager getPackageManager() { + return packageManager; + } + + public void testUris() { + assertInvalidUri(InstalledAppProvider.getAuthority()); + assertInvalidUri(RepoProvider.getContentUri()); + assertInvalidUri(AppProvider.getContentUri()); + assertInvalidUri(ApkProvider.getContentUri()); + assertInvalidUri("blah"); + + assertValidUri(InstalledAppProvider.getContentUri()); + assertValidUri(InstalledAppProvider.getAppUri("com.example.com")); + assertValidUri(InstalledAppProvider.getAppUri("blah")); + } + + public void testInsert() { + + assertResultCount(0, InstalledAppProvider.getContentUri()); + + insertInstalledApp("com.example.com1", 1, "v1"); + insertInstalledApp("com.example.com2", 2, "v2"); + insertInstalledApp("com.example.com3", 3, "v3"); + + assertResultCount(3, InstalledAppProvider.getContentUri()); + assertIsInstalledVersionInDb("com.example.com1", 1, "v1"); + assertIsInstalledVersionInDb("com.example.com2", 2, "v2"); + assertIsInstalledVersionInDb("com.example.com3", 3, "v3"); + } + + public void testUpdate() { + + insertInstalledApp("com.example.app1", 10, "1.0"); + insertInstalledApp("com.example.app2", 10, "1.0"); + + assertResultCount(2, InstalledAppProvider.getContentUri()); + assertIsInstalledVersionInDb("com.example.app2", 10, "1.0"); + + getMockContentResolver().update( + InstalledAppProvider.getAppUri("com.example.app2"), + createContentValues(11, "1.1"), + null, null + ); + + assertResultCount(2, InstalledAppProvider.getContentUri()); + assertIsInstalledVersionInDb("com.example.app2", 11, "1.1"); + + } + + public void testDelete() { + + insertInstalledApp("com.example.app1", 10, "1.0"); + insertInstalledApp("com.example.app2", 10, "1.0"); + + assertResultCount(2, InstalledAppProvider.getContentUri()); + + getMockContentResolver().delete(InstalledAppProvider.getAppUri("com.example.app1"), null, null); + + assertResultCount(1, InstalledAppProvider.getContentUri()); + assertIsInstalledVersionInDb("com.example.app2", 10, "1.0"); + + } + + public void testInsertWithBroadcast() { + + installAndBroadcast("com.example.broadcasted1", 10, "v1.0"); + installAndBroadcast("com.example.broadcasted2", 105, "v1.05"); + + assertResultCount(2, InstalledAppProvider.getContentUri()); + assertIsInstalledVersionInDb("com.example.broadcasted1", 10, "v1.0"); + assertIsInstalledVersionInDb("com.example.broadcasted2", 105, "v1.05"); + } + + public void testUpdateWithBroadcast() { + + installAndBroadcast("com.example.toUpgrade", 1, "v0.1"); + + assertResultCount(1, InstalledAppProvider.getContentUri()); + assertIsInstalledVersionInDb("com.example.toUpgrade", 1, "v0.1"); + + upgradeAndBroadcast("com.example.toUpgrade", 2, "v0.2"); + + assertResultCount(1, InstalledAppProvider.getContentUri()); + assertIsInstalledVersionInDb("com.example.toUpgrade", 2, "v0.2"); + + } + + public void testDeleteWithBroadcast() { + + installAndBroadcast("com.example.toKeep", 1, "v0.1"); + installAndBroadcast("com.example.toDelete", 1, "v0.1"); + + assertResultCount(2, InstalledAppProvider.getContentUri()); + assertIsInstalledVersionInDb("com.example.toKeep", 1, "v0.1"); + assertIsInstalledVersionInDb("com.example.toDelete", 1, "v0.1"); + + removeAndBroadcast("com.example.toDelete"); + + assertResultCount(1, InstalledAppProvider.getContentUri()); + assertIsInstalledVersionInDb("com.example.toKeep", 1, "v0.1"); + + } + + @Override + protected String[] getMinimalProjection() { + return new String[] { + InstalledAppProvider.DataColumns.APP_ID, + InstalledAppProvider.DataColumns.VERSION_CODE, + InstalledAppProvider.DataColumns.VERSION_NAME, + }; + } + + private ContentValues createContentValues(int versionCode, String versionNumber) { + return createContentValues(null, versionCode, versionNumber); + } + + private ContentValues createContentValues(String appId, int versionCode, String versionNumber) { + ContentValues values = new ContentValues(3); + if (appId != null) { + values.put(InstalledAppProvider.DataColumns.APP_ID, appId); + } + values.put(InstalledAppProvider.DataColumns.VERSION_CODE, versionCode); + values.put(InstalledAppProvider.DataColumns.VERSION_NAME, versionNumber); + return values; + } + + private void insertInstalledApp(String appId, int versionCode, String versionNumber) { + ContentValues values = createContentValues(appId, versionCode, versionNumber); + getMockContentResolver().insert(InstalledAppProvider.getContentUri(), values); + } + + private void removeAndBroadcast(String appId) { + TestUtils.removeAndBroadcast(getMockContext(), getPackageManager(), appId); + } + + private void upgradeAndBroadcast(String appId, int versionCode, String versionName) { + TestUtils.upgradeAndBroadcast(getMockContext(), getPackageManager(), appId, versionCode, versionName); + } + + private void installAndBroadcast(String appId, int versionCode, String versionName) { + TestUtils.installAndBroadcast(getMockContext(), getPackageManager(), appId, versionCode, versionName); + } + +} diff --git a/test/src/org/fdroid/fdroid/TestUtils.java b/test/src/org/fdroid/fdroid/TestUtils.java index 1afc0454c..e4927a3fe 100644 --- a/test/src/org/fdroid/fdroid/TestUtils.java +++ b/test/src/org/fdroid/fdroid/TestUtils.java @@ -3,6 +3,7 @@ package org.fdroid.fdroid; import android.content.*; import android.net.Uri; import junit.framework.AssertionFailedError; +import mock.MockInstallablePackageManager; import org.fdroid.fdroid.data.ApkProvider; import org.fdroid.fdroid.data.AppProvider; @@ -12,10 +13,22 @@ import java.util.List; public class TestUtils { - public static void assertContainsOnly(List actualList, T[] expectedContains) { - List containsList = new ArrayList(expectedContains.length); - Collections.addAll(containsList, expectedContains); - assertContainsOnly(actualList, containsList); + public static void assertContainsOnly(List actualList, T[] expectedArray) { + List expectedList = new ArrayList(expectedArray.length); + Collections.addAll(expectedList, expectedArray); + assertContainsOnly(actualList, expectedList); + } + + public static void assertContainsOnly(T[] actualArray, List expectedList) { + List actualList = new ArrayList(actualArray.length); + Collections.addAll(actualList, actualArray); + assertContainsOnly(actualList, expectedList); + } + + public static void assertContainsOnly(T[] actualArray, T[] expectedArray) { + List expectedList = new ArrayList(expectedArray.length); + Collections.addAll(expectedList, expectedArray); + assertContainsOnly(actualArray, expectedList); } public static String listToString(List list) { @@ -60,6 +73,10 @@ public class TestUtils { } } + public static void insertApp(ContentResolver resolver, String appId, String name) { + insertApp(resolver, appId, name, new ContentValues()); + } + public static void insertApp(ContentResolver resolver, String id, String name, ContentValues additionalValues) { ContentValues values = new ContentValues(); @@ -106,4 +123,56 @@ public class TestUtils { return providerTest.getMockContentResolver().insert(uri, values); } + + /** + * Will tell {@code pm} that we are installing {@code appId}, and then alert the + * {@link org.fdroid.fdroid.PackageAddedReceiver}. This will in turn update the + * "installed apps" table in the database. + * + * Note: in order for this to work, the {@link AppProviderTest#getSwappableContext()} + * will need to be aware of the package manager that we have passed in. Therefore, + * you will have to have called + * {@link mock.MockContextSwappableComponents#setPackageManager(android.content.pm.PackageManager)} + * on the {@link AppProviderTest#getSwappableContext()} before invoking this method. + */ + public static void installAndBroadcast( + Context context, MockInstallablePackageManager pm, + String appId, int versionCode, String versionName) { + + pm.install(appId, versionCode, versionName); + Intent installIntent = new Intent(Intent.ACTION_PACKAGE_ADDED); + installIntent.setData(Uri.parse("package:" + appId)); + new PackageAddedReceiver().onReceive(context, installIntent); + + } + + /** + * @see org.fdroid.fdroid.TestUtils#installAndBroadcast(android.content.Context context, mock.MockInstallablePackageManager, String, int, String) + */ + public static void upgradeAndBroadcast( + Context context, MockInstallablePackageManager pm, + String appId, int versionCode, String versionName) { + /* + removeAndBroadcast(context, pm, appId); + installAndBroadcast(context, pm, appId, versionCode, versionName); + */ + pm.install(appId, versionCode, versionName); + Intent installIntent = new Intent(Intent.ACTION_PACKAGE_CHANGED); + installIntent.setData(Uri.parse("package:" + appId)); + new PackageUpgradedReceiver().onReceive(context, installIntent); + + } + + /** + * @see org.fdroid.fdroid.TestUtils#installAndBroadcast(android.content.Context context, mock.MockInstallablePackageManager, String, int, String) + */ + public static void removeAndBroadcast(Context context, MockInstallablePackageManager pm, String appId) { + + pm.remove(appId); + Intent installIntent = new Intent(Intent.ACTION_PACKAGE_REMOVED); + installIntent.setData(Uri.parse("package:" + appId)); + new PackageRemovedReceiver().onReceive(context, installIntent); + + } + } diff --git a/test/src/org/fdroid/fdroid/mock/MockInstalledApkCache.java b/test/src/org/fdroid/fdroid/mock/MockInstalledApkCache.java deleted file mode 100644 index acac7557f..000000000 --- a/test/src/org/fdroid/fdroid/mock/MockInstalledApkCache.java +++ /dev/null @@ -1,16 +0,0 @@ -package org.fdroid.fdroid.mock; - -import android.content.Context; -import android.content.pm.PackageInfo; -import org.fdroid.fdroid.Utils; - -import java.util.Map; - -public class MockInstalledApkCache extends Utils.InstalledApkCache { - - @Override - public Map getApks(Context context) { - return buildAppList(context); - } - -}