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);
- }
-
-}