diff --git a/AndroidManifest.xml b/AndroidManifest.xml index 248240f6c..56149a5c2 100644 --- a/AndroidManifest.xml +++ b/AndroidManifest.xml @@ -35,6 +35,11 @@ android:theme="@style/AppThemeDark" android:supportsRtl="false" > + + + + diff --git a/res/values/array.xml b/res/values/array.xml index 7481d8fa6..bea7d606e 100644 --- a/res/values/array.xml +++ b/res/values/array.xml @@ -12,10 +12,4 @@ Dark Light - - - Off (unsafe) - Normal - Full - diff --git a/res/values/strings.xml b/res/values/strings.xml index 558a506b7..3ded618fa 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -127,8 +127,6 @@ Search applications - Database sync mode - Application compatibility Incompatible versions Show app versions incompatible with the device @@ -155,6 +153,7 @@ Processing application\n%2$d of %3$d from\n%1$s Connecting to\n%1$s Checking apps compatibility with your device… + Saving application details (%1$d%%) No permissions are used. Permissions for version %s Show permissions @@ -195,7 +194,8 @@ Disabled "%1$s".\n\nYou will need to re-enable this repository to install apps from it. - %s or later + Android %s or later Your device is not on the same WiFi as the local repo you just added! Try joining this network: %s + Requires: %1$s diff --git a/res/xml/preferences.xml b/res/xml/preferences.xml index 7b8cbd11e..5ae1f7d10 100644 --- a/res/xml/preferences.xml +++ b/res/xml/preferences.xml @@ -49,10 +49,5 @@ - diff --git a/src/org/fdroid/fdroid/AppDetails.java b/src/org/fdroid/fdroid/AppDetails.java index d39486d8a..7e2a09053 100644 --- a/src/org/fdroid/fdroid/AppDetails.java +++ b/src/org/fdroid/fdroid/AppDetails.java @@ -21,13 +21,12 @@ package org.fdroid.fdroid; import java.io.File; import java.security.NoSuchAlgorithmException; -import java.util.ArrayList; import java.util.Iterator; import java.util.List; -import org.fdroid.fdroid.data.Apk; -import org.fdroid.fdroid.data.Repo; -import org.fdroid.fdroid.data.RepoProvider; +import android.content.*; +import android.widget.*; +import org.fdroid.fdroid.data.*; import org.xml.sax.XMLReader; import android.app.AlertDialog; @@ -38,19 +37,10 @@ import android.os.Bundle; import android.os.Handler; import android.os.Message; import android.preference.PreferenceManager; -import android.widget.ImageView; -import android.widget.LinearLayout; -import android.widget.ListView; -import android.widget.TextView; -import android.widget.Toast; import android.content.pm.PackageManager; import android.content.pm.PackageInfo; import android.content.pm.Signature; import android.content.pm.PackageManager.NameNotFoundException; -import android.content.Context; -import android.content.DialogInterface; -import android.content.Intent; -import android.content.SharedPreferences; import android.text.Editable; import android.text.Html; import android.text.Html.TagHandler; @@ -64,7 +54,6 @@ import android.view.MenuItem; import android.view.SubMenu; import android.view.View; import android.view.ViewGroup; -import android.widget.BaseAdapter; import android.graphics.Bitmap; import android.support.v4.app.NavUtils; @@ -73,7 +62,7 @@ import android.support.v4.view.MenuItemCompat; import org.fdroid.fdroid.compat.PackageManagerCompat; import org.fdroid.fdroid.compat.ActionBarCompat; import org.fdroid.fdroid.compat.MenuManager; -import org.fdroid.fdroid.DB.CommaSeparatedList; +import org.fdroid.fdroid.Utils.CommaSeparatedList; import com.nostra13.universalimageloader.core.DisplayImageOptions; import com.nostra13.universalimageloader.core.ImageLoader; @@ -83,63 +72,40 @@ public class AppDetails extends ListActivity { private static final int REQUEST_INSTALL = 0; private static final int REQUEST_UNINSTALL = 1; + private ApkListAdapter adapter; private static class ViewHolder { TextView version; TextView status; TextView size; TextView api; + TextView incompatibleReasons; TextView buildtype; TextView added; TextView nativecode; } - private class ApkListAdapter extends BaseAdapter { + private class ApkListAdapter extends ArrayAdapter { - private List items; - private LayoutInflater mInflater; + private LayoutInflater mInflater = (LayoutInflater) mctx.getSystemService( + Context.LAYOUT_INFLATER_SERVICE); - public ApkListAdapter(Context context, List items) { - this.items = new ArrayList(); - if (items != null) { - for (Apk apk : items) { - this.addItem(apk); + public ApkListAdapter(Context context, App app) { + super(context, 0); + List apks = ApkProvider.Helper.findByApp(context.getContentResolver(), app.id); + for (Apk apk : apks ) { + if (apk.compatible || pref_incompatibleVersions) { + add(apk); } } - mInflater = (LayoutInflater) mctx.getSystemService( - Context.LAYOUT_INFLATER_SERVICE); - } - public void addItem(Apk apk) { - if (apk.compatible || pref_incompatibleVersions) { - items.add(apk); - } - } - - public List getItems() { - return items; - } - - @Override - public int getCount() { - return items.size(); - } - - @Override - public Object getItem(int position) { - return items.get(position); - } - - @Override - public long getItemId(int position) { - return position; } @Override public View getView(int position, View convertView, ViewGroup parent) { java.text.DateFormat df = DateFormat.getDateFormat(mctx); - Apk apk = items.get(position); + Apk apk = getItem(position); ViewHolder holder; if (convertView == null) { @@ -150,6 +116,7 @@ public class AppDetails extends ListActivity { holder.status = (TextView) convertView.findViewById(R.id.status); holder.size = (TextView) convertView.findViewById(R.id.size); holder.api = (TextView) convertView.findViewById(R.id.api); + holder.incompatibleReasons = (TextView) convertView.findViewById(R.id.incompatible_reasons); holder.buildtype = (TextView) convertView.findViewById(R.id.buildtype); holder.added = (TextView) convertView.findViewById(R.id.added); holder.nativecode = (TextView) convertView.findViewById(R.id.nativecode); @@ -161,9 +128,9 @@ public class AppDetails extends ListActivity { holder.version.setText(getString(R.string.version) + " " + apk.version - + (apk == app.curApk ? " ☆" : "")); + + (apk.vercode == app.curVercode ? " ☆" : "")); - if (apk.vercode == app.installedVerCode + if (apk.vercode == app.getInstalledVerCode(getContext()) && mInstalledSigID != null && apk.sig != null && apk.sig.equals(mInstalledSigID)) { holder.status.setText(getString(R.string.inst)); @@ -171,14 +138,14 @@ public class AppDetails extends ListActivity { holder.status.setText(getString(R.string.not_inst)); } - if (apk.detail_size > 0) { - holder.size.setText(Utils.getFriendlySize(apk.detail_size)); + if (apk.size > 0) { + holder.size.setText(Utils.getFriendlySize(apk.size)); holder.size.setVisibility(View.VISIBLE); } else { holder.size.setVisibility(View.GONE); } - if (apk.minSdkVersion > 0) { + if (pref_expert && apk.minSdkVersion > 0) { holder.api.setText(getString(R.string.minsdk_or_later, Utils.getAndroidVersionName(apk.minSdkVersion))); holder.api.setVisibility(View.VISIBLE); @@ -208,8 +175,13 @@ public class AppDetails extends ListActivity { } if (apk.incompatible_reasons != null) { - holder.api.setText(apk.incompatible_reasons.toString()); - holder.api.setVisibility(View.VISIBLE); + holder.incompatibleReasons.setText( + getResources().getString( + R.string.requires_features, + apk.incompatible_reasons.toPrettyString())); + holder.incompatibleReasons.setVisibility(View.VISIBLE); + } else { + holder.incompatibleReasons.setVisibility(View.GONE); } // Disable it all if it isn't compatible... @@ -219,13 +191,14 @@ public class AppDetails extends ListActivity { holder.status, holder.size, holder.api, + holder.incompatibleReasons, holder.buildtype, holder.added, holder.nativecode }; - for (View view : views) { - view.setEnabled(apk.compatible); + for (View v : views) { + v.setEnabled(apk.compatible); } return convertView; @@ -248,7 +221,7 @@ public class AppDetails extends ListActivity { private static final int FLATTR = Menu.FIRST + 13; private static final int DONATE_URL = Menu.FIRST + 14; - private DB.App app; + private App app; private String appid; private PackageManager mPm; private DownloadHandler downloadHandler; @@ -336,8 +309,8 @@ public class AppDetails extends ListActivity { headerView = new LinearLayout(this); ListView lv = (ListView) findViewById(android.R.id.list); lv.addHeaderView(headerView); - ApkListAdapter la = new ApkListAdapter(this, app.apks); - setListAdapter(la); + adapter = new ApkListAdapter(this, app); + setListAdapter(adapter); startViews(); @@ -378,17 +351,24 @@ public class AppDetails extends ListActivity { } if (app != null && (app.ignoreAllUpdates != startingIgnoreAll || app.ignoreThisUpdate != startingIgnoreThis)) { - try { - DB db = DB.getDB(); - db.setIgnoreUpdates(app.id, - app.ignoreAllUpdates, app.ignoreThisUpdate); - } finally { - DB.releaseDB(); - } + setIgnoreUpdates(app.id, app.ignoreAllUpdates, app.ignoreThisUpdate); } super.onPause(); } + public void setIgnoreUpdates(String appId, boolean ignoreAll, int ignoreVersionCode) { + + Uri uri = AppProvider.getContentUri(appId); + + ContentValues values = new ContentValues(2); + values.put(AppProvider.DataColumns.IGNORE_ALLUPDATES, ignoreAll ? 1 : 0); + values.put(AppProvider.DataColumns.IGNORE_THISUPDATE, ignoreVersionCode); + + getContentResolver().update(uri, values, null, null); + + } + + @Override public Object onRetainNonConfigurationInstance() { stateRetained = true; @@ -423,15 +403,11 @@ public class AppDetails extends ListActivity { Log.d("FDroid", "Getting application details for " + appid); app = null; + if (appid != null && appid.length() > 0) { - List apps = ((FDroidApp) getApplication()).getApps(); - for (DB.App tapp : apps) { - if (tapp.id.equals(appid)) { - app = tapp; - break; - } - } + app = AppProvider.Helper.findById(getContentResolver(), appid); } + if (app == null) { Toast toast = Toast.makeText(this, getString(R.string.no_such_app), Toast.LENGTH_LONG); @@ -440,23 +416,13 @@ public class AppDetails extends ListActivity { return false; } - // Make sure the app is populated. - try { - DB db = DB.getDB(); - db.populateDetails(app, 0); - } catch (Exception ex) { - Log.d("FDroid", "Failed to populate app - " + ex.getMessage()); - } finally { - DB.releaseDB(); - } - startingIgnoreAll = app.ignoreAllUpdates; startingIgnoreThis = app.ignoreThisUpdate; // Get the signature of the installed package... mInstalledSignature = null; mInstalledSigID = null; - if (app.installedVersion != null) { + if (app.getInstalledVersion(this) != null) { PackageManager pm = getBaseContext().getPackageManager(); try { PackageInfo pi = pm.getPackageInfo(appid, @@ -545,7 +511,7 @@ public class AppDetails extends ListActivity { } } Spanned desc = Html.fromHtml( - app.detail_description, null, new HtmlTagHandler()); + app.description, null, new HtmlTagHandler()); tv.setText(desc.subSequence(0, desc.length() - 2)); tv = (TextView) infoView.findViewById(R.id.appid); @@ -557,11 +523,20 @@ public class AppDetails extends ListActivity { tv = (TextView) infoView.findViewById(R.id.summary); tv.setText(app.summary); - if (pref_permissions && app.curApk != null && - (app.curApk.compatible || pref_incompatibleVersions)) { + Apk curApk = null; + for (int i = 0; i < adapter.getCount(); i ++) { + Apk apk = adapter.getItem(i); + if (apk.vercode == app.curVercode) { + curApk = apk; + break; + } + } + + if (pref_permissions && !adapter.isEmpty() && + ((curApk != null && curApk.compatible) || pref_incompatibleVersions)) { tv = (TextView) infoView.findViewById(R.id.permissions_list); - CommaSeparatedList permsList = app.curApk.detail_permissions; + CommaSeparatedList permsList = adapter.getItem(0).permissions; if (permsList == null) { tv.setText(getString(R.string.no_permissions)); } else { @@ -586,7 +561,7 @@ public class AppDetails extends ListActivity { } tv = (TextView) infoView.findViewById(R.id.permissions); tv.setText(getString( - R.string.permissions_for_long, app.apks.get(0).version)); + R.string.permissions_for_long, adapter.getItem(0).version)); } else { infoView.findViewById(R.id.permissions).setVisibility(View.GONE); infoView.findViewById(R.id.permissions_list).setVisibility(View.GONE); @@ -631,15 +606,14 @@ public class AppDetails extends ListActivity { private void updateViews() { // Refresh the list... - ApkListAdapter la = (ApkListAdapter) getListAdapter(); - la.notifyDataSetChanged(); + adapter.notifyDataSetChanged(); TextView tv = (TextView) findViewById(R.id.status); - if (app.installedVersion == null) + if (app.getInstalledVersion(this) == null) tv.setText(getString(R.string.details_notinstalled)); else tv.setText(getString(R.string.details_installed, - app.installedVersion)); + app.getInstalledVersion(this))); tv = (TextView) infoView.findViewById(R.id.signature); if (pref_expert && mInstalledSignature != null) { @@ -653,10 +627,10 @@ public class AppDetails extends ListActivity { @Override protected void onListItemClick(ListView l, View v, int position, long id) { - app.curApk = app.apks.get(position - l.getHeaderViewsCount()); - if (app.installedVerCode == app.curApk.vercode) + final Apk apk = adapter.getItem(position - l.getHeaderViewsCount()); + if (app.getInstalledVerCode(this) == apk.vercode) removeApk(app.id); - else if (app.installedVerCode > app.curApk.vercode) { + else if (app.getInstalledVerCode(this) > apk.vercode) { AlertDialog.Builder ask_alrt = new AlertDialog.Builder(this); ask_alrt.setMessage(getString(R.string.installDowngrade)); ask_alrt.setPositiveButton(getString(R.string.yes), @@ -664,7 +638,7 @@ public class AppDetails extends ListActivity { @Override public void onClick(DialogInterface dialog, int whichButton) { - install(); + install(apk); } }); ask_alrt.setNegativeButton(getString(R.string.no), @@ -677,7 +651,7 @@ public class AppDetails extends ListActivity { AlertDialog alert = ask_alrt.create(); alert.show(); } else - install(); + install(apk); } @Override @@ -687,20 +661,23 @@ public class AppDetails extends ListActivity { menu.clear(); if (app == null) return true; - if (app.toUpdate) { + if (app.canAndWantToUpdate(this)) { MenuItemCompat.setShowAsAction(menu.add( Menu.NONE, INSTALL, 0, R.string.menu_upgrade) .setIcon(R.drawable.ic_menu_refresh), MenuItemCompat.SHOW_AS_ACTION_ALWAYS | MenuItemCompat.SHOW_AS_ACTION_WITH_TEXT); } - if (app.installedVersion == null && app.curApk != null) { + + // Check count > 0 due to incompatible apps resulting in an empty list. + if (app.getInstalledVersion(this) == null && app.curVercode > 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.installedVersion != null) { + } else if (app.getInstalledVersion(this) != null) { MenuItemCompat.setShowAsAction(menu.add( Menu.NONE, UNINSTALL, 1, R.string.menu_uninstall) .setIcon(android.R.drawable.ic_menu_delete), @@ -727,40 +704,40 @@ public class AppDetails extends ListActivity { .setCheckable(true) .setChecked(app.ignoreAllUpdates); - if (app.hasUpdates) { + if (app.hasUpdates(this)) { menu.add(Menu.NONE, IGNORETHIS, 2, R.string.menu_ignore_this) .setIcon(android.R.drawable.ic_menu_close_clear_cancel) .setCheckable(true) - .setChecked(app.ignoreThisUpdate >= app.curApk.vercode); + .setChecked(app.ignoreThisUpdate >= app.curVercode); } - if (app.detail_webURL.length() > 0) { + if (app.webURL.length() > 0) { menu.add(Menu.NONE, WEBSITE, 3, R.string.menu_website).setIcon( android.R.drawable.ic_menu_view); } - if (app.detail_trackerURL.length() > 0) { + if (app.trackerURL.length() > 0) { menu.add(Menu.NONE, ISSUES, 4, R.string.menu_issues).setIcon( android.R.drawable.ic_menu_view); } - if (app.detail_sourceURL.length() > 0) { + if (app.sourceURL.length() > 0) { menu.add(Menu.NONE, SOURCE, 5, R.string.menu_source).setIcon( android.R.drawable.ic_menu_view); } - if (app.detail_bitcoinAddr != null || app.detail_litecoinAddr != null || - app.detail_dogecoinAddr != null || - app.detail_flattrID != null || app.detail_donateURL != null) { + if (app.bitcoinAddr != null || app.litecoinAddr != null || + app.dogecoinAddr != null || + app.flattrID != null || app.donateURL != null) { SubMenu donate = menu.addSubMenu(Menu.NONE, DONATE, 7, R.string.menu_donate).setIcon( android.R.drawable.ic_menu_send); - if (app.detail_bitcoinAddr != null) + if (app.bitcoinAddr != null) donate.add(Menu.NONE, BITCOIN, 8, R.string.menu_bitcoin); - if (app.detail_litecoinAddr != null) + if (app.litecoinAddr != null) donate.add(Menu.NONE, LITECOIN, 8, R.string.menu_litecoin); - if (app.detail_dogecoinAddr != null) + if (app.dogecoinAddr != null) donate.add(Menu.NONE, DOGECOIN, 8, R.string.menu_dogecoin); - if (app.detail_flattrID != null) + if (app.flattrID != null) donate.add(Menu.NONE, FLATTR, 9, R.string.menu_flattr); - if (app.detail_donateURL != null) + if (app.donateURL != null) donate.add(Menu.NONE, DONATE_URL, 10, R.string.menu_website); } @@ -798,8 +775,10 @@ public class AppDetails extends ListActivity { case INSTALL: // Note that this handles updating as well as installing. - if (app.curApk != null) - install(); + if (app.curVercode > 0) { + final Apk apkToInstall = ApkProvider.Helper.find(this, app.id, app.curVercode); + install(apkToInstall); + } return true; case UNINSTALL: @@ -812,43 +791,43 @@ public class AppDetails extends ListActivity { return true; case IGNORETHIS: - if (app.ignoreThisUpdate >= app.curApk.vercode) + if (app.ignoreThisUpdate >= app.curVercode) app.ignoreThisUpdate = 0; else - app.ignoreThisUpdate = app.curApk.vercode; + app.ignoreThisUpdate = app.curVercode; item.setChecked(app.ignoreThisUpdate > 0); return true; case WEBSITE: - tryOpenUri(app.detail_webURL); + tryOpenUri(app.webURL); return true; case ISSUES: - tryOpenUri(app.detail_trackerURL); + tryOpenUri(app.trackerURL); return true; case SOURCE: - tryOpenUri(app.detail_sourceURL); + tryOpenUri(app.sourceURL); return true; case BITCOIN: - tryOpenUri("bitcoin:" + app.detail_bitcoinAddr); + tryOpenUri("bitcoin:" + app.bitcoinAddr); return true; case LITECOIN: - tryOpenUri("litecoin:" + app.detail_litecoinAddr); + tryOpenUri("litecoin:" + app.litecoinAddr); return true; case DOGECOIN: - tryOpenUri("dogecoin:" + app.detail_dogecoinAddr); + tryOpenUri("dogecoin:" + app.dogecoinAddr); return true; case FLATTR: - tryOpenUri("https://flattr.com/thing/" + app.detail_flattrID); + tryOpenUri("https://flattr.com/thing/" + app.flattrID); return true; case DONATE_URL: - tryOpenUri(app.detail_donateURL); + tryOpenUri(app.donateURL); return true; } @@ -856,17 +835,16 @@ public class AppDetails extends ListActivity { } // Install the version of this app denoted by 'app.curApk'. - private void install() { - + private void install(final Apk apk) { String [] projection = { RepoProvider.DataColumns.ADDRESS }; Repo repo = RepoProvider.Helper.findById( - getContentResolver(), app.curApk.repo, projection); + getContentResolver(), apk.repo, projection); if (repo == null || repo.address == null) { return; } final String repoaddress = repo.address; - if (!app.curApk.compatible) { + if (!apk.compatible) { AlertDialog.Builder ask_alrt = new AlertDialog.Builder(this); ask_alrt.setMessage(getString(R.string.installIncompatible)); ask_alrt.setPositiveButton(getString(R.string.yes), @@ -874,7 +852,7 @@ public class AppDetails extends ListActivity { @Override public void onClick(DialogInterface dialog, int whichButton) { - downloadHandler = new DownloadHandler(app.curApk, + downloadHandler = new DownloadHandler(apk, repoaddress, Utils .getApkCacheDir(getBaseContext())); } @@ -890,8 +868,8 @@ public class AppDetails extends ListActivity { alert.show(); return; } - if (mInstalledSigID != null && app.curApk.sig != null - && !app.curApk.sig.equals(mInstalledSigID)) { + if (mInstalledSigID != null && apk.sig != null + && !apk.sig.equals(mInstalledSigID)) { AlertDialog.Builder builder = new AlertDialog.Builder(this); builder.setMessage(R.string.SignatureMismatch).setPositiveButton( getString(R.string.ok), @@ -905,7 +883,7 @@ public class AppDetails extends ListActivity { alert.show(); return; } - downloadHandler = new DownloadHandler(app.curApk, repoaddress, + downloadHandler = new DownloadHandler(apk, repoaddress, Utils.getApkCacheDir(getBaseContext())); } @@ -937,7 +915,7 @@ public class AppDetails extends ListActivity { startActivity(intent); } - private void shareApp(DB.App app) { + private void shareApp(App app) { Intent shareIntent = new Intent(Intent.ACTION_SEND); shareIntent.setType("text/plain"); diff --git a/src/org/fdroid/fdroid/AppFilter.java b/src/org/fdroid/fdroid/AppFilter.java index 9e85f8eae..ff2be9a46 100644 --- a/src/org/fdroid/fdroid/AppFilter.java +++ b/src/org/fdroid/fdroid/AppFilter.java @@ -21,28 +21,25 @@ package org.fdroid.fdroid; import android.content.Context; import android.content.SharedPreferences; import android.preference.PreferenceManager; +import org.fdroid.fdroid.data.App; public class AppFilter { - boolean pref_rooted; - - public AppFilter(Context ctx) { - - // Read preferences and cache them so we can do quick lookups. - SharedPreferences prefs = PreferenceManager - .getDefaultSharedPreferences(ctx); - pref_rooted = prefs.getBoolean(Preferences.PREF_ROOTED, true); - } - // Return true if the given app should be filtered out based on user // preferences, and false otherwise. - public boolean filter(DB.App app) { - if (app.requirements == null) return false; + public boolean filter(App app) { + + boolean filterRequiringRoot = Preferences.get().filterAppsRequiringRoot(); + + if (app.requirements == null || !filterRequiringRoot) return false; + for (String r : app.requirements) { - if (r.equals("root") && !pref_rooted) + if (r.equals("root")) return true; } + return false; + } } diff --git a/src/org/fdroid/fdroid/AppListManager.java b/src/org/fdroid/fdroid/AppListManager.java deleted file mode 100644 index ee66e5736..000000000 --- a/src/org/fdroid/fdroid/AppListManager.java +++ /dev/null @@ -1,248 +0,0 @@ -package org.fdroid.fdroid; - -import java.util.*; - -import android.content.SharedPreferences; -import android.preference.PreferenceManager; -import android.util.Log; -import android.widget.ArrayAdapter; -import android.os.Build; - -import org.fdroid.fdroid.views.AppListAdapter; -import org.fdroid.fdroid.views.AvailableAppListAdapter; -import org.fdroid.fdroid.views.CanUpdateAppListAdapter; -import org.fdroid.fdroid.views.InstalledAppListAdapter; - -/** - * Should be owned by the FDroid Activity, but used by the AppListFragments. - * The idea is that it takes a non-trivial amount of time to work this stuff - * out, and it is quicker if we only do it once for each view, rather than - * each fragment figuring out their own list independently. - */ -public class AppListManager { - - private List allApps = null; - - private FDroid fdroidActivity; - - private AppListAdapter availableApps; - private AppListAdapter installedApps; - private AppListAdapter canUpgradeApps; - private ArrayAdapter categories; - - private String currentCategory = null; - private String categoryAll = null; - private String categoryWhatsNew = null; - private String categoryRecentlyUpdated = null; - - public AppListAdapter getAvailableAdapter() { - return availableApps; - } - - public AppListAdapter getInstalledAdapter() { - return installedApps; - } - - public AppListAdapter getCanUpdateAdapter() { - return canUpgradeApps; - } - - public ArrayAdapter getCategoriesAdapter() { - return categories; - } - - public AppListManager(FDroid activity) { - this.fdroidActivity = activity; - - availableApps = new AvailableAppListAdapter(fdroidActivity); - installedApps = new InstalledAppListAdapter(fdroidActivity); - canUpgradeApps = new CanUpdateAppListAdapter(fdroidActivity); - - // Needs to be created before createViews(), because that will use the - // getCategoriesAdapter() accessor which expects this object... - categories = new ArrayAdapter(activity, - android.R.layout.simple_spinner_item, new ArrayList()); - categories - .setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); - } - - private void clear() { - installedApps.clear(); - availableApps.clear(); - canUpgradeApps.clear(); - categories.clear(); - } - - private void notifyLists() { - // Tell the lists that the data behind the adapter has changed, so - // they can refresh... - availableApps.notifyDataSetChanged(); - installedApps.notifyDataSetChanged(); - canUpgradeApps.notifyDataSetChanged(); - categories.notifyDataSetChanged(); - } - - private void updateCategories() { - try { - DB db = DB.getDB(); - - // Populate the category list with the real categories, and the - // locally generated meta-categories for "All", "What's New" and - // "Recently Updated"... - categoryAll = fdroidActivity - .getString(R.string.category_all); - categoryWhatsNew = fdroidActivity - .getString(R.string.category_whatsnew); - categoryRecentlyUpdated = fdroidActivity - .getString(R.string.category_recentlyupdated); - - categories.add(categoryWhatsNew); - categories.add(categoryRecentlyUpdated); - categories.add(categoryAll); - if (Build.VERSION.SDK_INT >= 11) { - categories.addAll(db.getCategories()); - } else { - List categs = db.getCategories(); - for (String category : categs) { - categories.add(category); - } - } - - if (currentCategory == null) - currentCategory = categoryWhatsNew; - - } finally { - DB.releaseDB(); - } - } - - // Tell the FDroid activity to update its "Update (x)" tab to correctly - // reflect the number of updates available. - private void notifyActivity() { - fdroidActivity.refreshUpdateTabLabel(); - } - - public void repopulateLists() { - - long startTime = System.currentTimeMillis(); - - clear(); - - updateCategories(); - updateApps(); - notifyLists(); - notifyActivity(); - - Log.d("FDroid", "Updated lists - " + allApps.size() + " in total" - + " (update took " + (System.currentTimeMillis() - startTime) - + " ms)"); - } - - // Calculate the cutoff date we'll use for What's New and Recently - // Updated... - private Date calcMaxHistory() { - SharedPreferences prefs = PreferenceManager - .getDefaultSharedPreferences(fdroidActivity.getBaseContext()); - String daysPreference = prefs.getString(Preferences.PREF_UPD_HISTORY, "14"); - int maxHistoryDays = Integer.parseInt(daysPreference); - Calendar recent = Calendar.getInstance(); - recent.add(Calendar.DAY_OF_YEAR, -maxHistoryDays); - return recent.getTime(); - } - - // recentDate could really be calculated here, but this is just a hack so - // it doesn't need to be calculated for every single app. The reason it - // isn't an instance variable is because the preferences may change, and - // we wouldn't know. - private boolean isInCategory(DB.App app, String category, Date recentDate) { - if (category.equals(categoryAll)) { - return true; - } - if (category.equals(categoryWhatsNew)) { - if (app.added == null) - return false; - if (app.added.compareTo(recentDate) < 0) - return false; - return true; - } - if (category.equals(categoryRecentlyUpdated)) { - if (app.lastUpdated == null) - return false; - // Don't include in the recently updated category if the - // 'update' was actually it being added. - if (app.lastUpdated.compareTo(app.added) == 0) - return false; - if (app.lastUpdated.compareTo(recentDate) < 0) - return false; - return true; - } - if (app.categories == null) return false; - return app.categories.contains(category); - } - - // Returns false if the app list is empty and the fdroid activity decided - // to attempt updating it. - private boolean updateApps() { - - allApps = ((FDroidApp)fdroidActivity.getApplication()).getApps(); - - if (allApps.isEmpty()) { - // If its the first time we've run the app, this should update - // the repos. If not, it will do nothing, presuming that the repos - // are invalid, the internet is stuffed, the sky has fallen, etc... - return fdroidActivity.updateEmptyRepos(); - } - - Date recentDate = calcMaxHistory(); - List availApps = new ArrayList(); - for (DB.App app : allApps) { - - // Add it to the list(s). Always to installed and updates, but - // only to available if it's not filtered. - if (isInCategory(app, currentCategory, recentDate)) { - availApps.add(app); - } - if (app.installedVersion != null) { - installedApps.addItem(app); - if (app.toUpdate) { - canUpgradeApps.addItem(app); - } - } - } - - if (currentCategory.equals(categoryWhatsNew)) { - Collections.sort(availApps, new WhatsNewComparator()); - } else if (currentCategory.equals(categoryRecentlyUpdated)) { - Collections.sort(availApps, new RecentlyUpdatedComparator()); - } - - availableApps.addItems(availApps); - - return true; - } - - public void setCurrentCategory(String currentCategory) { - if (!this.currentCategory.equals(currentCategory)){ - this.currentCategory = currentCategory; - repopulateLists(); - } - } - - public String getCurrentCategory() { - return this.currentCategory; - } - - static class WhatsNewComparator implements Comparator { - @Override - public int compare(DB.App lhs, DB.App rhs) { - return rhs.added.compareTo(lhs.added); - } - } - - static class RecentlyUpdatedComparator implements Comparator { - @Override - public int compare(DB.App lhs, DB.App rhs) { - return rhs.lastUpdated.compareTo(lhs.lastUpdated); - } - } -} diff --git a/src/org/fdroid/fdroid/CompatibilityChecker.java b/src/org/fdroid/fdroid/CompatibilityChecker.java new file mode 100644 index 000000000..5aa4ac31c --- /dev/null +++ b/src/org/fdroid/fdroid/CompatibilityChecker.java @@ -0,0 +1,103 @@ +package org.fdroid.fdroid; + +import android.content.Context; +import android.content.SharedPreferences; +import android.content.pm.FeatureInfo; +import android.content.pm.PackageManager; +import android.preference.PreferenceManager; +import android.util.Log; +import org.fdroid.fdroid.compat.Compatibility; +import org.fdroid.fdroid.compat.SupportedArchitectures; +import org.fdroid.fdroid.data.Apk; + +import java.util.*; + +// Call getIncompatibleReasons(apk) on an instance of this class to + // find reasons why an apk may be incompatible with the user's device. +public class CompatibilityChecker extends Compatibility { + + private Context context; + private Set features; + private Set cpuAbis; + private String cpuAbisDesc; + private boolean ignoreTouchscreen; + + public CompatibilityChecker(Context ctx) { + + context = ctx.getApplicationContext(); + + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(ctx); + ignoreTouchscreen = prefs.getBoolean(Preferences.PREF_IGN_TOUCH, false); + + PackageManager pm = ctx.getPackageManager(); + StringBuilder logMsg = new StringBuilder(); + logMsg.append("Available device features:"); + features = new HashSet(); + if (pm != null) { + for (FeatureInfo fi : pm.getSystemAvailableFeatures()) { + features.add(fi.name); + logMsg.append('\n'); + logMsg.append(fi.name); + } + } + + cpuAbis = SupportedArchitectures.getAbis(); + + StringBuilder builder = new StringBuilder(); + boolean first = true; + for (String abi : cpuAbis) { + if (first) first = false; + else builder.append(", "); + builder.append(abi); + } + cpuAbisDesc = builder.toString(); + + Log.d("FDroid", logMsg.toString()); + } + + private boolean compatibleApi(Utils.CommaSeparatedList nativecode) { + if (nativecode == null) return true; + for (String abi : nativecode) { + if (cpuAbis.contains(abi)) { + return true; + } + } + return false; + } + + public List getIncompatibleReasons(final Apk apk) { + + List incompatibleReasons = new ArrayList(); + + if (!hasApi(apk.minSdkVersion)) { + incompatibleReasons.add( + context.getResources().getString( + R.string.minsdk_or_later, + Utils.getAndroidVersionName(apk.minSdkVersion))); + } + + if (apk.features != null) { + for (String feat : apk.features) { + if (ignoreTouchscreen + && feat.equals("android.hardware.touchscreen")) { + // Don't check it! + } else if (!features.contains(feat)) { + Collections.addAll(incompatibleReasons, feat.split(",")); + Log.d("FDroid", apk.id + " vercode " + apk.vercode + + " is incompatible based on lack of " + + feat); + } + } + } + if (!compatibleApi(apk.nativecode)) { + for (String code : apk.nativecode) { + incompatibleReasons.add(code); + } + Log.d("FDroid", apk.id + " vercode " + apk.vercode + + " only supports " + Utils.CommaSeparatedList.str(apk.nativecode) + + " while your architectures are " + cpuAbisDesc); + } + + return incompatibleReasons; + } +} \ No newline at end of file diff --git a/src/org/fdroid/fdroid/DB.java b/src/org/fdroid/fdroid/DB.java deleted file mode 100644 index b101cad14..000000000 --- a/src/org/fdroid/fdroid/DB.java +++ /dev/null @@ -1,943 +0,0 @@ -/* - * Copyright (C) 2010-13 Ciaran Gultnieks, ciaran@ciarang.com - * Copyright (C) 2009 Roberto Jacinto, roberto.jacinto@caixamagica.pt - * - * 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 java.io.File; -import java.security.MessageDigest; -import java.net.MalformedURLException; -import java.net.URL; -import java.security.cert.Certificate; -import java.security.cert.CertificateEncodingException; -import java.text.ParseException; -import java.text.SimpleDateFormat; -import java.util.ArrayList; -import java.util.Collections; -import java.util.Date; -import java.util.Formatter; -import java.util.HashMap; -import java.util.Iterator; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.concurrent.Semaphore; - -import android.content.ContentValues; -import android.content.Context; -import android.content.SharedPreferences; -import android.content.pm.ApplicationInfo; -import android.content.pm.FeatureInfo; -import android.content.pm.PackageInfo; -import android.content.pm.PackageManager; -import android.database.Cursor; -import android.database.sqlite.SQLiteDatabase; -import android.preference.PreferenceManager; -import android.text.TextUtils; -import android.text.TextUtils.SimpleStringSplitter; -import android.util.DisplayMetrics; -import android.util.Log; - -import org.fdroid.fdroid.compat.Compatibility; -import org.fdroid.fdroid.compat.ContextCompat; -import org.fdroid.fdroid.compat.SupportedArchitectures; -import org.fdroid.fdroid.data.*; - -public class DB { - - private static Semaphore dbSync = new Semaphore(1, true); - private static DB dbInstance = null; - - // Initialise the database. Called once when the application starts up. - static void initDB(Context ctx) { - dbInstance = new DB(ctx); - } - - // Get access to the database. Must be called before any database activity, - // and releaseDB must be called subsequently. Returns null in the event of - // failure. - public static DB getDB() { - try { - dbSync.acquire(); - return dbInstance; - } catch (InterruptedException e) { - return null; - } - } - - // Release database access lock acquired via getDB(). - public static void releaseDB() { - dbSync.release(); - } - - // Possible values of the SQLite flag "synchronous" - public static final int SYNC_OFF = 0; - public static final int SYNC_NORMAL = 1; - public static final int SYNC_FULL = 2; - - private SQLiteDatabase db; - - public static final String TABLE_APP = "fdroid_app"; - - public static class App implements Comparable { - - public App() { - name = "Unknown"; - summary = "Unknown application"; - icon = null; - id = "unknown"; - license = "Unknown"; - detail_trackerURL = null; - detail_sourceURL = null; - detail_donateURL = null; - detail_bitcoinAddr = null; - detail_litecoinAddr = null; - detail_dogecoinAddr = null; - detail_webURL = null; - categories = null; - antiFeatures = null; - requirements = null; - hasUpdates = false; - toUpdate = false; - updated = false; - added = null; - lastUpdated = null; - apks = new ArrayList(); - detail_Populated = false; - compatible = false; - ignoreAllUpdates = false; - ignoreThisUpdate = 0; - filtered = false; - iconUrl = null; - } - - // True when all the detail fields are populated, False otherwise. - public boolean detail_Populated; - - // True if compatible with the device (i.e. if at least one apk is) - public boolean compatible; - - public String id; - public String name; - public String summary; - public String icon; - - // Null when !detail_Populated - public String detail_description; - - public String license; - - // Null when !detail_Populated - public String detail_webURL; - - // Null when !detail_Populated - public String detail_trackerURL; - - // Null when !detail_Populated - public String detail_sourceURL; - - // Donate link, or null - // Null when !detail_Populated - public String detail_donateURL; - - // Bitcoin donate address, or null - // Null when !detail_Populated - public String detail_bitcoinAddr; - - // Litecoin donate address, or null - // Null when !detail_Populated - public String detail_litecoinAddr; - - // Dogecoin donate address, or null - // Null when !detail_Populated - public String detail_dogecoinAddr; - - // Flattr donate ID, or null - // Null when !detail_Populated - public String detail_flattrID; - - public String curVersion; - public int curVercode; - public Apk curApk; - public Date added; - public Date lastUpdated; - - // Installed version (or null), version code and whether it was - // installed by the user or bundled with the system. These are valid - // only when getApps() has been called with getinstalledinfo=true. - public String installedVersion; - public int installedVerCode; - public boolean userInstalled; - - // List of categories (as defined in the metadata - // documentation) or null if there aren't any. - public CommaSeparatedList categories; - - // List of anti-features (as defined in the metadata - // documentation) or null if there aren't any. - public CommaSeparatedList antiFeatures; - - // List of special requirements (such as root privileges) or - // null if there aren't any. - public CommaSeparatedList requirements; - - // Whether the app is filtered or not based on AntiFeatures and root - // permission (set in the Settings page) - public boolean filtered; - - // True if there are new versions (apks) available, regardless of - // any filtering - public boolean hasUpdates; - - // True if there are new versions (apks) available and the user wants - // to be notified about them - public boolean toUpdate; - - // True if all updates for this app are to be ignored - public boolean ignoreAllUpdates; - - // True if the current update for this app is to be ignored - public int ignoreThisUpdate; - - // Used internally for tracking during repo updates. - public boolean updated; - - // List of apks. - public List apks; - - public String iconUrl; - - // Get the current version - this will be one of the Apks from 'apks'. - // Can return null if there are no available versions. - // This should be the 'current' version, as in the most recent stable - // one, that most users would want by default. It might not be the - // most recent, if for example there are betas etc. - public Apk getCurrentVersion() { - - // Try and return the real current version first. It will find the - // closest version smaller than the curVercode, being the same - // vercode if it exists. - if (curVercode > 0) { - int latestcode = -1; - Apk latestapk = null; - for (Apk apk : apks) { - if ((!this.compatible || apk.compatible) - && apk.vercode <= curVercode - && apk.vercode > latestcode) { - latestapk = apk; - latestcode = apk.vercode; - } - } - return latestapk; - } - - // If the current version was not set we return the most recent apk. - if (curVercode == -1) { - int latestcode = -1; - Apk latestapk = null; - for (Apk apk : apks) { - if ((!this.compatible || apk.compatible) - && apk.vercode > latestcode) { - latestapk = apk; - latestcode = apk.vercode; - } - } - return latestapk; - } - - return null; - } - - @Override - public int compareTo(App arg0) { - return name.compareToIgnoreCase(arg0.name); - } - - } - - public static String calcFingerprint(String keyHexString) { - if (TextUtils.isEmpty(keyHexString)) - return null; - else - return calcFingerprint(Hasher.unhex(keyHexString)); - } - - public static String calcFingerprint(Certificate cert) { - try { - return calcFingerprint(cert.getEncoded()); - } catch (CertificateEncodingException e) { - return null; - } - } - - public static String calcFingerprint(byte[] key) { - String ret = null; - try { - // keytool -list -v gives you the SHA-256 fingerprint - MessageDigest digest = MessageDigest.getInstance("SHA-256"); - digest.update(key); - byte[] fingerprint = digest.digest(); - Formatter formatter = new Formatter(new StringBuilder()); - for (int i = 1; i < fingerprint.length; i++) { - formatter.format("%02X", fingerprint[i]); - } - ret = formatter.toString(); - formatter.close(); - } catch (Exception e) { - Log.w("FDroid", "Unable to get certificate fingerprint.\n" - + Log.getStackTraceString(e)); - } - return ret; - } - - /** - * Get the local storage (cache) path. This will also create it if - * it doesn't exist. It can return null if it's currently unavailable. - */ - public static File getDataPath(Context ctx) { - return ContextCompat.create(ctx).getExternalCacheDir(); - } - - private Context mContext; - private Apk.CompatibilityChecker compatChecker = null; - - // The date format used for storing dates (e.g. lastupdated, added) in the - // database. - public static final SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd", Locale.ENGLISH); - - private DB(Context ctx) { - - mContext = ctx; - DBHelper h = new DBHelper(ctx); - db = h.getWritableDatabase(); - SharedPreferences prefs = PreferenceManager - .getDefaultSharedPreferences(mContext); - String sync_mode = prefs.getString(Preferences.PREF_DB_SYNC, null); - if ("off".equals(sync_mode)) - setSynchronizationMode(SYNC_OFF); - else if ("normal".equals(sync_mode)) - setSynchronizationMode(SYNC_NORMAL); - else if ("full".equals(sync_mode)) - setSynchronizationMode(SYNC_FULL); - else - sync_mode = null; - if (sync_mode != null) - Log.d("FDroid", "Database synchronization mode: " + sync_mode); - } - - public void close() { - db.close(); - db = null; - } - - // Delete the database, which should cause it to be re-created next time - // it's used. - public static void delete(Context ctx) { - try { - ctx.deleteDatabase(DBHelper.DATABASE_NAME); - // Also try and delete the old one, from versions 0.13 and earlier. - ctx.deleteDatabase("fdroid_db"); - } catch (Exception ex) { - Log.e("FDroid", - "Exception in DB.delete:\n" + Log.getStackTraceString(ex)); - } - } - - public List getCategories() { - List result = new ArrayList(); - Cursor c = null; - try { - c = db.query(true, TABLE_APP, new String[] { "categories" }, - null, null, null, null, null, null); - c.moveToFirst(); - while (!c.isAfterLast()) { - CommaSeparatedList categories = CommaSeparatedList - .make(c.getString(0)); - if (categories != null) { - for (String category : categories) { - if (!result.contains(category)) { - result.add(category); - } - } - } - c.moveToNext(); - } - } catch (Exception e) { - Log.e("FDroid", - "Exception during database reading:\n" - + Log.getStackTraceString(e)); - } finally { - if (c != null) { - c.close(); - } - } - Collections.sort(result); - return result; - } - - private static final String[] POPULATE_APP_COLS = new String[] { - "description", "webURL", "trackerURL", "sourceURL", - "donateURL", "bitcoinAddr", "flattrID", "litecoinAddr", "dogecoinAddr" }; - - private void populateAppDetails(App app) { - Cursor cursor = null; - try { - cursor = db.query(TABLE_APP, POPULATE_APP_COLS, "id = ?", - new String[] { app.id }, null, null, null, null); - cursor.moveToFirst(); - app.detail_description = cursor.getString(0); - app.detail_webURL = cursor.getString(1); - app.detail_trackerURL = cursor.getString(2); - app.detail_sourceURL = cursor.getString(3); - app.detail_donateURL = cursor.getString(4); - app.detail_bitcoinAddr = cursor.getString(5); - app.detail_flattrID = cursor.getString(6); - app.detail_litecoinAddr = cursor.getString(7); - app.detail_dogecoinAddr = cursor.getString(8); - app.detail_Populated = true; - } catch (Exception e) { - Log.d("FDroid", "Error populating app details " + app.id ); - Log.d("FDroid", e.getMessage()); - } finally { - if (cursor != null) { - cursor.close(); - } - } - } - - private static final String[] POPULATE_APK_COLS = new String[] { - ApkProvider.DataColumns.HASH, - ApkProvider.DataColumns.HASH_TYPE, - ApkProvider.DataColumns.SIZE, - ApkProvider.DataColumns.PERMISSIONS - }; - - private void populateApkDetails(Apk apk, long repo) { - if (repo == 0 || repo == apk.repo) { - Apk loadedApk = ApkProvider.Helper.find( - mContext, apk.id, apk.vercode, POPULATE_APK_COLS); - apk.detail_hash = loadedApk.detail_hash; - apk.detail_hashType = loadedApk.detail_hashType; - apk.detail_size = loadedApk.detail_size; - apk.detail_permissions = loadedApk.detail_permissions; - } else { - Log.d("FDroid", "Not setting details for apk '" + apk.id + "' (version " + apk.version +") because it belongs to a different repo."); - } - } - - // Populate the details for the given app, if necessary. - // If 'apkrepo' is non-zero, only apks from that repo are - // populated (this is used during the update process) - public void populateDetails(App app, long apkRepo) { - if (!app.detail_Populated) { - populateAppDetails(app); - } - - for (Apk apk : app.apks) { - if (apk.detail_hash == null) { - populateApkDetails(apk, apkRepo); - } - } - } - - // Return a list of apps matching the given criteria. Filtering is - // also done based on compatibility and anti-features according to - // the user's current preferences. - public List getApps(boolean getinstalledinfo) { - - // If we're going to need it, get info in what's currently installed - Map systemApks = null; - if (getinstalledinfo) { - Log.d("FDroid", "Reading installed packages"); - systemApks = new HashMap(); - List installedPackages = mContext.getPackageManager() - .getInstalledPackages(0); - for (PackageInfo appInfo : installedPackages) { - systemApks.put(appInfo.packageName, appInfo); - } - } - - Map apps = new HashMap(); - Cursor c = null; - long startTime = System.currentTimeMillis(); - try { - - String cols[] = new String[] { "antiFeatures", "requirements", - "categories", "id", "name", "summary", "icon", "license", - "curVersion", "curVercode", "added", "lastUpdated", - "compatible", "ignoreAllUpdates", "ignoreThisUpdate" }; - c = db.query(TABLE_APP, cols, null, null, null, null, null); - c.moveToFirst(); - while (!c.isAfterLast()) { - - App app = new App(); - app.antiFeatures = DB.CommaSeparatedList.make(c.getString(0)); - app.requirements = DB.CommaSeparatedList.make(c.getString(1)); - app.categories = DB.CommaSeparatedList.make(c.getString(2)); - app.id = c.getString(3); - app.name = c.getString(4); - app.summary = c.getString(5); - app.icon = c.getString(6); - app.license = c.getString(7); - app.curVersion = c.getString(8); - app.curVercode = c.getInt(9); - String sAdded = c.getString(10); - app.added = (sAdded == null || sAdded.length() == 0) ? null - : DATE_FORMAT.parse(sAdded); - String sLastUpdated = c.getString(11); - app.lastUpdated = (sLastUpdated == null || sLastUpdated - .length() == 0) ? null : DATE_FORMAT - .parse(sLastUpdated); - app.compatible = c.getInt(12) == 1; - app.ignoreAllUpdates = c.getInt(13) == 1; - app.ignoreThisUpdate = c.getInt(14); - app.hasUpdates = false; - - if (getinstalledinfo && systemApks.containsKey(app.id)) { - PackageInfo sysapk = systemApks.get(app.id); - app.installedVersion = sysapk.versionName; - if (app.installedVersion == null) - app.installedVersion = "null"; - app.installedVerCode = sysapk.versionCode; - if (sysapk.applicationInfo != null) { - app.userInstalled = ((sysapk.applicationInfo.flags - & ApplicationInfo.FLAG_SYSTEM) != 1); - } - } else { - app.installedVersion = null; - app.installedVerCode = 0; - app.userInstalled = false; - } - - apps.put(app.id, app); - - c.moveToNext(); - } - c.close(); - c = null; - - Log.d("FDroid", "Read app data from database " + " (took " - + (System.currentTimeMillis() - startTime) + " ms)"); - - DisplayMetrics metrics = mContext.getResources() - .getDisplayMetrics(); - String iconsDir = null; - if (metrics.densityDpi >= 640) { - iconsDir = "/icons-640/"; - } else if (metrics.densityDpi >= 480) { - iconsDir = "/icons-480/"; - } else if (metrics.densityDpi >= 320) { - iconsDir = "/icons-320/"; - } else if (metrics.densityDpi >= 240) { - iconsDir = "/icons-240/"; - } else if (metrics.densityDpi >= 160) { - iconsDir = "/icons-160/"; - } else { - iconsDir = "/icons-120/"; - } - metrics = null; - Log.d("FDroid", "Density-specific icons dir is " + iconsDir); - - List apks = ApkProvider.Helper.all(mContext); - for (Apk apk : apks) { - App app = apps.get(apk.id); - if (app == null) { - continue; - } - app.apks.add(apk); - if (app.iconUrl == null && app.icon != null) { - if (apk.repoVersion >= 11) { - app.iconUrl = apk.repoAddress + iconsDir + app.icon; - } else { - app.iconUrl = apk.repoAddress + "/icons/" + app.icon; - } - } - } - - } catch (Exception e) { - Log.e("FDroid", - "Exception during database reading:\n" - + Log.getStackTraceString(e)); - } finally { - if (c != null) { - c.close(); - } - - Log.d("FDroid", "Read app and apk data from database " + " (took " - + (System.currentTimeMillis() - startTime) + " ms)"); - } - - List result = new ArrayList(apps.values()); - Collections.sort(result); - - // Fill in the hasUpdates fields if we have the necessary information... - if (getinstalledinfo) { - - // We'll say an application has updates if it's installed AND the - // version is older than the current one - for (App app : result) { - app.curApk = app.getCurrentVersion(); - if (app.curApk != null - && app.installedVerCode > 0 - && app.installedVerCode < app.curApk.vercode) { - app.hasUpdates = true; - } - } - } - - return result; - } - - - // Alternative to getApps() that only refreshes the installation details - // of those apps in invalidApps. Much faster when returning from - // installs/uninstalls, where getApps() was already called before. - public List refreshApps(List apps, List invalidApps) { - - List installedPackages = mContext.getPackageManager() - .getInstalledPackages(0); - long startTime = System.currentTimeMillis(); - List refreshedApps = new ArrayList(); - for (String appid : invalidApps) { - if (refreshedApps.contains(appid)) continue; - App app = null; - int index = -1; - for (App oldapp : apps) { - index++; - if (oldapp.id.equals(appid)) { - app = oldapp; - break; - } - } - - if (app == null) continue; - - PackageInfo installed = null; - - for (PackageInfo appInfo : installedPackages) { - if (appInfo.packageName.equals(appid)) { - installed = appInfo; - break; - } - } - - if (installed != null) { - app.installedVersion = installed.versionName; - if (app.installedVersion == null) - app.installedVersion = "null"; - app.installedVerCode = installed.versionCode; - } else { - app.installedVersion = null; - app.installedVerCode = 0; - } - - app.hasUpdates = false; - app.curApk = app.getCurrentVersion(); - if (app.curApk != null - && app.installedVersion != null - && app.installedVerCode < app.curApk.vercode) { - app.hasUpdates = true; - } - - apps.set(index, app); - refreshedApps.add(appid); - } - Log.d("FDroid", "Refreshing " + refreshedApps.size() + " apps took " - + (System.currentTimeMillis() - startTime) + " ms"); - - return apps; - } - - public List doSearch(String query) { - - List ids = new ArrayList(); - Cursor c = null; - try { - String filter = "%" + query + "%"; - c = db.query(TABLE_APP, new String[] { "id" }, - "id like ? or name like ? or summary like ? or description like ?", - new String[] { filter, filter, filter, filter }, null, null, null); - c.moveToFirst(); - while (!c.isAfterLast()) { - ids.add(c.getString(0)); - c.moveToNext(); - } - } finally { - if (c != null) - c.close(); - } - return ids; - } - - public static class CommaSeparatedList implements Iterable { - private String value; - - private CommaSeparatedList(String list) { - value = list; - } - - public static CommaSeparatedList make(String list) { - if (list == null || list.length() == 0) - return null; - else - return new CommaSeparatedList(list); - } - - public static String str(CommaSeparatedList instance) { - return (instance == null ? null : instance.toString()); - } - - @Override - public String toString() { - return value; - } - - @Override - public Iterator iterator() { - SimpleStringSplitter splitter = new SimpleStringSplitter(','); - splitter.setString(value); - return splitter.iterator(); - } - - public boolean contains(String v) { - for (String s : this) { - if (s.equals(v)) - return true; - } - return false; - } - } - - private List updateApps = null; - - // Called before a repo update starts. - public void beginUpdate(List apps) { - // Get a list of all apps. All the apps and apks in this list will - // have 'updated' set to false at this point, and we will only set - // it to true when we see the app/apk in a repository. Thus, at the - // end, any that are still false can be removed. - updateApps = apps; - Log.d("FDroid", "AppUpdate: " + updateApps.size() + " apps before starting."); - // Wrap the whole update in a transaction. Make sure to call - // either endUpdate or cancelUpdate to commit or discard it, - // respectively. - db.beginTransaction(); - } - - // Called when a repo update ends. Any applications that have not been - // updated (by a call to updateApplication) are assumed to be no longer - // in the repos. - public void endUpdate() { - if (updateApps == null) - return; - Log.d("FDroid", "Processing endUpdate - " + updateApps.size() - + " apps before"); - for (App app : updateApps) { - if (!app.updated) { - // The application hasn't been updated, so it's no longer - // in the repos. - Log.d("FDroid", "AppUpdate: " + app.name - + " is no longer in any repository - removing"); - db.delete(TABLE_APP, "id = ?", new String[]{app.id}); - ApkProvider.Helper.deleteApksByApp(mContext, app); - } else { - for (Apk apk : app.apks) { - if (!apk.updated) { - // The package hasn't been updated, so this is a - // version that's no longer available. - Log.d("FDroid", "AppUpdate: Package " + apk.id + "/" - + apk.version - + " is no longer in any repository - removing"); - ApkProvider.Helper.delete(mContext, app.id, apk.vercode); - } - } - } - } - // Commit updates to the database. - db.setTransactionSuccessful(); - db.endTransaction(); - Log.d("FDroid", "AppUpdate: " + updateApps.size() - + " apps on completion."); - updateApps = null; - } - - // Called instead of endUpdate if the update failed. - public void cancelUpdate() { - if (updateApps != null) { - db.endTransaction(); - updateApps = null; - } - } - - // Called during update to supply new details for an application (or - // details of a completely new one). Calls to this must be wrapped by - // a call to beginUpdate and a call to endUpdate. - public void updateApplication(App upapp) { - - if (updateApps == null) { - return; - } - - // Lazy initialise this... - if (compatChecker == null) { - compatChecker = new Apk.CompatibilityChecker(mContext); - } - - // See if it's compatible (by which we mean if it has at least one - // compatible apk) - upapp.compatible = false; - for (Apk apk : upapp.apks) { - if (compatChecker.isCompatible(apk)) { - apk.compatible = true; - upapp.compatible = true; - } - } - - boolean found = false; - for (App app : updateApps) { - if (app.id.equals(upapp.id)) { - updateApp(app, upapp); - app.updated = true; - found = true; - for (Apk upapk : upapp.apks) { - boolean afound = false; - for (Apk apk : app.apks) { - if (apk.vercode == upapk.vercode) { - - ApkProvider.Helper.update( - mContext, - upapk, - apk.id, - apk.vercode); - - apk.updated = true; - afound = true; - break; - } - } - if (!afound) { - // A new version of this application. - ApkProvider.Helper.insert(mContext, upapk); - upapk.updated = true; - app.apks.add(upapk); - } - } - break; - } - } - if (!found) { - // It's a brand new application... - updateApp(null, upapp); - for (Apk upapk : upapp.apks) { - ApkProvider.Helper.insert(mContext, upapk); - upapk.updated = true; - } - upapp.updated = true; - updateApps.add(upapp); - } - - } - - // Update application details in the database. - // 'oldapp' - previous details - i.e. what's in the database. - // If null, this app is not in the database at all and - // should be added. - // 'upapp' - updated details - private void updateApp(App oldapp, App upapp) { - ContentValues values = new ContentValues(); - values.put("id", upapp.id); - values.put("name", upapp.name); - values.put("summary", upapp.summary); - values.put("icon", upapp.icon); - values.put("description", upapp.detail_description); - values.put("license", upapp.license); - values.put("webURL", upapp.detail_webURL); - values.put("trackerURL", upapp.detail_trackerURL); - values.put("sourceURL", upapp.detail_sourceURL); - values.put("donateURL", upapp.detail_donateURL); - values.put("bitcoinAddr", upapp.detail_bitcoinAddr); - values.put("litecoinAddr", upapp.detail_litecoinAddr); - values.put("dogecoinAddr", upapp.detail_dogecoinAddr); - values.put("flattrID", upapp.detail_flattrID); - values.put("added", - upapp.added == null ? "" : DATE_FORMAT.format(upapp.added)); - values.put( - "lastUpdated", - upapp.added == null ? "" : DATE_FORMAT - .format(upapp.lastUpdated)); - values.put("curVersion", upapp.curVersion); - values.put("curVercode", upapp.curVercode); - values.put("categories", CommaSeparatedList.str(upapp.categories)); - values.put("antiFeatures", CommaSeparatedList.str(upapp.antiFeatures)); - values.put("requirements", CommaSeparatedList.str(upapp.requirements)); - values.put("compatible", upapp.compatible ? 1 : 0); - - // Values to keep if already present - if (oldapp == null) { - values.put("ignoreAllUpdates", upapp.ignoreAllUpdates ? 1 : 0); - values.put("ignoreThisUpdate", upapp.ignoreThisUpdate); - } else { - values.put("ignoreAllUpdates", oldapp.ignoreAllUpdates ? 1 : 0); - values.put("ignoreThisUpdate", oldapp.ignoreThisUpdate); - } - - if (oldapp != null) { - db.update(TABLE_APP, values, "id = ?", new String[] { oldapp.id }); - } else { - db.insert(TABLE_APP, null, values); - } - } - - public void setIgnoreUpdates(String appid, boolean All, int This) { - db.execSQL("update " + TABLE_APP + " set" - + " ignoreAllUpdates=" + (All ? '1' : '0') - + ", ignoreThisUpdate="+This - + " where id = ?", new String[] { appid }); - } - - public void purgeApps(Repo repo, FDroidApp fdroid) { - db.beginTransaction(); - - try { - ApkProvider.Helper.deleteApksByRepo(mContext, repo); - List apps = getApps(false); - for (App app : apps) { - if (app.apks.isEmpty()) { - db.delete(TABLE_APP, "id = ?", new String[] { app.id }); - } - } - db.setTransactionSuccessful(); - } finally { - db.endTransaction(); - } - - fdroid.invalidateAllApps(); - } - - public int getSynchronizationMode() { - Cursor cursor = db.rawQuery("PRAGMA synchronous", null); - cursor.moveToFirst(); - int mode = cursor.getInt(0); - cursor.close(); - return mode; - } - - public void setSynchronizationMode(int mode) { - db.execSQL("PRAGMA synchronous = " + mode); - } -} diff --git a/src/org/fdroid/fdroid/Downloader.java b/src/org/fdroid/fdroid/Downloader.java index ebd45271c..22bcac17e 100644 --- a/src/org/fdroid/fdroid/Downloader.java +++ b/src/org/fdroid/fdroid/Downloader.java @@ -108,8 +108,8 @@ public class Downloader extends Thread { // See if we already have this apk cached... if (localfile.exists()) { // We do - if its hash matches, we'll use it... - Hasher hash = new Hasher(curapk.detail_hashType, localfile); - if (hash.match(curapk.detail_hash)) { + Hasher hash = new Hasher(curapk.hashType, localfile); + if (hash.match(curapk.hash)) { Log.d("FDroid", "Using cached apk at " + localfile); synchronized (this) { progress = 1; @@ -130,7 +130,7 @@ public class Downloader extends Thread { synchronized (this) { filename = remotefile; progress = 0; - max = curapk.detail_size; + max = curapk.size; status = Status.RUNNING; } @@ -159,11 +159,11 @@ public class Downloader extends Thread { } return; } - Hasher hash = new Hasher(curapk.detail_hashType, localfile); - if (!hash.match(curapk.detail_hash)) { + Hasher hash = new Hasher(curapk.hashType, localfile); + if (!hash.match(curapk.hash)) { synchronized (this) { Log.d("FDroid", "Downloaded file hash of " + hash.getHash() - + " did not match repo's " + curapk.detail_hash); + + " did not match repo's " + curapk.hash); // No point keeping a bad file, whether we're // caching or not. localfile.delete(); diff --git a/src/org/fdroid/fdroid/FDroid.java b/src/org/fdroid/fdroid/FDroid.java index 9ef40383e..e54b8ec25 100644 --- a/src/org/fdroid/fdroid/FDroid.java +++ b/src/org/fdroid/fdroid/FDroid.java @@ -25,9 +25,11 @@ import android.app.NotificationManager; import android.content.*; import android.content.pm.PackageInfo; import android.content.res.Configuration; +import android.database.ContentObserver; import android.net.Uri; import android.os.Build; import android.os.Bundle; +import android.os.Handler; import android.util.Log; import android.view.ContextThemeWrapper; import android.view.LayoutInflater; @@ -41,6 +43,7 @@ import android.support.v4.view.MenuItemCompat; import android.support.v4.view.ViewPager; import org.fdroid.fdroid.compat.TabManager; +import org.fdroid.fdroid.data.AppProvider; import org.fdroid.fdroid.views.AppListFragmentPageAdapter; public class FDroid extends FragmentActivity { @@ -58,21 +61,14 @@ public class FDroid extends FragmentActivity { private ViewPager viewPager; - private AppListManager manager = null; - private TabManager tabManager = null; - public AppListManager getManager() { - return manager; - } - @Override protected void onCreate(Bundle savedInstanceState) { ((FDroidApp) getApplication()).applyTheme(this); super.onCreate(savedInstanceState); - manager = new AppListManager(this); setContentView(R.layout.fdroid); createViews(); getTabManager().createTabs(); @@ -99,21 +95,9 @@ public class FDroid extends FragmentActivity { call.putExtra("appid", appid); startActivityForResult(call, REQUEST_APPDETAILS); } - } - @Override - protected void onResume() { - super.onResume(); - repopulateViews(); - } - - /** - * Must be done *after* createViews, because it will involve a - * callback to update the tab label for the "update" tab. This - * will fail unless the tabs have actually been created. - */ - protected void repopulateViews() { - manager.repopulateLists(); + Uri uri = AppProvider.getContentUri(); + getContentResolver().registerContentObserver(uri, true, new AppObserver()); } @Override @@ -253,8 +237,6 @@ public class FDroid extends FragmentActivity { if ((resultCode & PreferencesActivity.RESULT_RELOAD) != 0) { ((FDroidApp) getApplication()).invalidateAllApps(); - } else if ((resultCode & PreferencesActivity.RESULT_REFILTER) != 0) { - ((FDroidApp) getApplication()).filterApps(); } if ((resultCode & PreferencesActivity.RESULT_RESTART) != 0) { @@ -308,14 +290,7 @@ public class FDroid extends FragmentActivity { // is told to do the update, which will result in the database changing. The // UpdateReceiver class should get told when this is finished. public void updateRepos() { - UpdateService.updateNow(this).setListener(new ProgressListener() { - @Override - public void onProgress(Event event) { - if (event.type == UpdateService.STATUS_COMPLETE_WITH_CHANGES){ - repopulateViews(); - } - } - }); + UpdateService.updateNow(this); } private TabManager getTabManager() { @@ -335,4 +310,27 @@ public class FDroid extends FragmentActivity { nMgr.cancel(id); } + private class AppObserver extends ContentObserver { + + public AppObserver() { + super(null); + } + + @Override + public void onChange(boolean selfChange, Uri uri) { + FDroid.this.runOnUiThread(new Runnable() { + @Override + public void run() { + refreshUpdateTabLabel(); + } + }); + } + + @Override + public void onChange(boolean selfChange) { + onChange(selfChange, null); + } + + } + } diff --git a/src/org/fdroid/fdroid/FDroidApp.java b/src/org/fdroid/fdroid/FDroidApp.java index cd4e0179b..13f01829b 100644 --- a/src/org/fdroid/fdroid/FDroidApp.java +++ b/src/org/fdroid/fdroid/FDroidApp.java @@ -38,23 +38,19 @@ import android.app.Application; import android.content.Context; import android.content.SharedPreferences; -import org.fdroid.fdroid.Utils; - -import android.graphics.Bitmap; import android.preference.PreferenceManager; import android.util.Log; import com.nostra13.universalimageloader.cache.disc.impl.LimitedAgeDiscCache; -import com.nostra13.universalimageloader.cache.disc.impl.UnlimitedDiscCache; import com.nostra13.universalimageloader.cache.disc.naming.FileNameGenerator; -import com.nostra13.universalimageloader.core.DisplayImageOptions; import com.nostra13.universalimageloader.core.ImageLoader; import com.nostra13.universalimageloader.core.ImageLoaderConfiguration; -import com.nostra13.universalimageloader.core.display.FadeInBitmapDisplayer; import com.nostra13.universalimageloader.utils.StorageUtils; import de.duenndns.ssl.MemorizingTrustManager; +import org.fdroid.fdroid.data.App; +import org.fdroid.fdroid.data.AppProvider; import org.thoughtcrime.ssl.pinning.PinningTrustManager; import org.thoughtcrime.ssl.pinning.SystemKeyStore; @@ -90,6 +86,20 @@ public class FDroidApp extends Application { // it is more deterministic as to when this gets called... Preferences.setup(this); + // Set this up here, and the testing framework will override it when + // it gets fired up. + Utils.setupInstalledApkCache(new Utils.InstalledApkCache()); + + // If the user changes the preference to do with filtering rooted apps, + // it is easier to just notify a change in the app provider, + // so that the newly updated list will correctly filter relevant apps. + Preferences.get().registerAppsRequiringRootChangeListener(new Preferences.ChangeListener() { + @Override + public void onPreferenceChange() { + getContentResolver().notifyChange(AppProvider.getContentUri(), null); + } + }); + // Clear cached apk files. We used to just remove them after they'd // been installed, but this causes problems for proprietary gapps // users since the introduction of verification (on pre-4.2 Android), @@ -117,7 +127,6 @@ public class FDroidApp extends Application { apps = null; invalidApps = new ArrayList(); ctx = getApplicationContext(); - DB.initDB(ctx); UpdateService.schedule(ctx); ImageLoaderConfiguration config = new ImageLoaderConfiguration.Builder(ctx) @@ -179,7 +188,7 @@ public class FDroidApp extends Application { private Context ctx; // Global list of all known applications. - private List apps; + private List apps; // Set when something has changed (database or installed apps) so we know // we should invalidate the apps. @@ -206,59 +215,4 @@ public class FDroidApp extends Application { invalidApps.add(id); } - // Get a list of all known applications. Should not be called when the - // database is locked (i.e. between DB.getDB() and db.releaseDB(). The - // contents should never be modified, it's for reading only. - public List getApps() { - - boolean invalid = false; - try { - appsInvalidLock.acquire(); - invalid = appsAllInvalid; - if (invalid) { - appsAllInvalid = false; - Log.d("FDroid", "Dropping cached app data"); - } - } catch (InterruptedException e) { - // Don't care - } finally { - appsInvalidLock.release(); - } - - if (apps == null || invalid) { - try { - DB db = DB.getDB(); - apps = db.getApps(true); - - } finally { - DB.releaseDB(); - } - } else if (!invalidApps.isEmpty()) { - try { - DB db = DB.getDB(); - apps = db.refreshApps(apps, invalidApps); - - invalidApps.clear(); - } finally { - DB.releaseDB(); - } - } - if (apps == null) - return new ArrayList(); - filterApps(); - return apps; - } - - public void filterApps() { - AppFilter appFilter = new AppFilter(ctx); - for (DB.App app : apps) { - app.filtered = appFilter.filter(app); - - app.toUpdate = (app.hasUpdates - && !app.ignoreAllUpdates - && app.curApk.vercode > app.ignoreThisUpdate - && !app.filtered); - } - } - } diff --git a/src/org/fdroid/fdroid/ManageRepo.java b/src/org/fdroid/fdroid/ManageRepo.java index 0bf667696..519ef2b80 100644 --- a/src/org/fdroid/fdroid/ManageRepo.java +++ b/src/org/fdroid/fdroid/ManageRepo.java @@ -21,13 +21,11 @@ package org.fdroid.fdroid; import android.app.Activity; import android.app.AlertDialog; -import android.content.ContentValues; -import android.content.Context; -import android.content.DialogInterface; +import android.content.*; +import android.preference.PreferenceManager; import android.support.v4.app.FragmentActivity; import android.support.v4.app.ListFragment; import android.support.v4.content.CursorLoader; -import android.content.Intent; import android.database.Cursor; import android.net.Uri; import android.net.wifi.WifiInfo; @@ -38,6 +36,7 @@ import android.support.v4.app.NavUtils; import android.support.v4.content.Loader; import android.support.v4.view.MenuItemCompat; import android.text.TextUtils; +import android.text.format.DateFormat; import android.util.Log; import android.view.Menu; import android.view.MenuInflater; @@ -55,6 +54,7 @@ import org.fdroid.fdroid.views.fragments.RepoDetailsFragment; import java.net.MalformedURLException; import java.net.URL; +import java.util.Date; import java.util.Locale; public class ManageRepo extends FragmentActivity { @@ -178,7 +178,7 @@ class RepoListFragment extends ListFragment changed = true; } else { FDroidApp app = (FDroidApp)getActivity().getApplication(); - RepoProvider.Helper.purgeApps(repo, app); + RepoProvider.Helper.purgeApps(getActivity(), repo, app); String notification = getString(R.string.repo_disabled_notification, repo.name); Toast.makeText(getActivity(), notification, Toast.LENGTH_LONG).show(); } @@ -200,6 +200,43 @@ class RepoListFragment extends ListFragment */ private boolean isImportingRepo = false; + private View createHeaderView() { + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(getActivity()); + TextView textLastUpdate = new TextView(getActivity()); + long lastUpdate = prefs.getLong(Preferences.PREF_UPD_LAST, 0); + String lastUpdateCheck = ""; + if (lastUpdate == 0) { + lastUpdateCheck = getString(R.string.never); + } else { + Date d = new Date(lastUpdate); + lastUpdateCheck = DateFormat.getDateFormat(getActivity()).format(d) + + " " + DateFormat.getTimeFormat(getActivity()).format(d); + } + textLastUpdate.setText(getString(R.string.last_update_check, lastUpdateCheck)); + + int sidePadding = (int)getResources().getDimension(R.dimen.padding_side); + int topPadding = (int)getResources().getDimension(R.dimen.padding_top); + + textLastUpdate.setPadding(sidePadding, topPadding, sidePadding, topPadding); + return textLastUpdate; + } + + @Override + public void onActivityCreated(Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + + // Can't do this in the onCreate view, because "onCreateView" which + // returns the list view is "called between onCreate and + // onActivityCreated" according to the docs. + getListView().addHeaderView(createHeaderView()); + + // This could go in onCreate (and used to) but it needs to be called + // after addHeaderView, which can only be called after onCreate... + repoAdapter = new RepoAdapter(getActivity(), null); + repoAdapter.setEnabledListener(this); + setListAdapter(repoAdapter); + } + @Override public void onCreate(Bundle savedInstanceState) { @@ -207,29 +244,6 @@ class RepoListFragment extends ListFragment setHasOptionsMenu(true); - repoAdapter = new RepoAdapter(getActivity(), null); - repoAdapter.setEnabledListener(this); - setListAdapter(repoAdapter); - - /* - TODO: Find some other way to display this info, now that we use the ListView widgets... - SharedPreferences prefs = PreferenceManager - .getDefaultSharedPreferences(getBaseContext()); - - TextView tv_lastCheck = (TextView)findViewById(R.id.lastUpdateCheck); - long lastUpdate = prefs.getLong(Preferences.PREF_UPD_LAST, 0); - String s_lastUpdateCheck = ""; - if (lastUpdate == 0) { - s_lastUpdateCheck = getString(R.string.never); - } else { - Date d = new Date(lastUpdate); - s_lastUpdateCheck = DateFormat.getDateFormat(this).format(d) + - " " + DateFormat.getTimeFormat(this).format(d); - } - tv_lastCheck.setText(getString(R.string.last_update_check,s_lastUpdateCheck)); - - */ - /* let's see if someone is trying to send us a new repo */ Intent intent = getActivity().getIntent(); /* an URL from a click, NFC, QRCode scan, etc */ @@ -342,9 +356,12 @@ class RepoListFragment extends ListFragment UpdateService.updateNow(getActivity()).setListener(new ProgressListener() { @Override public void onProgress(Event event) { + if (event.type == UpdateService.STATUS_COMPLETE_AND_SAME || + event.type == UpdateService.STATUS_COMPLETE_WITH_CHANGES) { // No need to prompt to update any more, we just did it! changed = false; } + } }); } diff --git a/src/org/fdroid/fdroid/PackageReceiver.java b/src/org/fdroid/fdroid/PackageReceiver.java index 180aa3f8c..c58bf99ff 100644 --- a/src/org/fdroid/fdroid/PackageReceiver.java +++ b/src/org/fdroid/fdroid/PackageReceiver.java @@ -30,6 +30,7 @@ public class PackageReceiver extends BroadcastReceiver { String appid = intent.getData().getSchemeSpecificPart(); Log.d("FDroid", "PackageReceiver received "+appid); ((FDroidApp) ctx.getApplicationContext()).invalidateApp(appid); + Utils.clearInstalledApksCache(); } } diff --git a/src/org/fdroid/fdroid/Preferences.java b/src/org/fdroid/fdroid/Preferences.java index 9c64b4717..de5695340 100644 --- a/src/org/fdroid/fdroid/Preferences.java +++ b/src/org/fdroid/fdroid/Preferences.java @@ -1,10 +1,8 @@ package org.fdroid.fdroid; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; +import java.util.*; +import android.app.LoaderManager; import android.content.Context; import android.content.SharedPreferences; import android.preference.PreferenceManager; @@ -38,15 +36,20 @@ public class Preferences implements SharedPreferences.OnSharedPreferenceChangeLi public static final String PREF_IGN_TOUCH = "ignoreTouchscreen"; public static final String PREF_CACHE_APK = "cacheDownloaded"; public static final String PREF_EXPERT = "expert"; - public static final String PREF_DB_SYNC = "dbSyncMode"; public static final String PREF_UPD_LAST = "lastUpdateCheck"; private static final boolean DEFAULT_COMPACT_LAYOUT = false; + private static final boolean DEFAULT_ROOTED = true; + private static final int DEFAULT_UPD_HISTORY = 14; private boolean compactLayout = DEFAULT_COMPACT_LAYOUT; + private boolean filterAppsRequiringRoot = DEFAULT_ROOTED; private Map initialized = new HashMap(); + private List compactLayoutListeners = new ArrayList(); + private List filterAppsRequiringRootListeners = new ArrayList(); + private List updateHistoryListeners = new ArrayList(); private boolean isInitialized(String key) { return initialized.containsKey(key) && initialized.get(key); @@ -76,6 +79,45 @@ public class Preferences implements SharedPreferences.OnSharedPreferenceChangeLi compactLayoutListeners.remove(listener); } + /** + * Calculate the cutoff date we'll use for What's New and Recently + * Updated... + */ + public Date calcMaxHistory() { + String daysString = preferences.getString(PREF_UPD_HISTORY, Integer.toString(DEFAULT_UPD_HISTORY)); + int maxHistoryDays; + try { + maxHistoryDays = Integer.parseInt(daysString); + } catch (NumberFormatException e) { + maxHistoryDays = DEFAULT_UPD_HISTORY; + } + Calendar recent = Calendar.getInstance(); + recent.add(Calendar.DAY_OF_YEAR, -maxHistoryDays); + return recent.getTime(); + } + + /** + * This is cached as it is called several times inside the AppListAdapter. + * Providing it here means sthe shared preferences file only needs to be + * read once, and we will keep our copy up to date by listening to changes + * in PREF_ROOTED. + */ + public boolean filterAppsRequiringRoot() { + if (!isInitialized(PREF_ROOTED)) { + initialize(PREF_ROOTED); + filterAppsRequiringRoot = preferences.getBoolean(PREF_ROOTED, DEFAULT_ROOTED); + } + return filterAppsRequiringRoot; + } + + public void registerAppsRequiringRootChangeListener(ChangeListener listener) { + filterAppsRequiringRootListeners.add(listener); + } + + public void unregisterAppsRequiringRootChangeListener(ChangeListener listener) { + filterAppsRequiringRootListeners.remove(listener); + } + @Override public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) { Log.d("FDroid", "Invalidating preference '" + key + "'."); @@ -85,9 +127,29 @@ public class Preferences implements SharedPreferences.OnSharedPreferenceChangeLi for ( ChangeListener listener : compactLayoutListeners ) { listener.onPreferenceChange(); } + } else if (key.equals(PREF_ROOTED)) { + for ( ChangeListener listener : filterAppsRequiringRootListeners ) { + listener.onPreferenceChange(); + } + } else if (key.equals(PREF_UPD_HISTORY)) { + for ( ChangeListener listener : updateHistoryListeners ) { + listener.onPreferenceChange(); + } } } + public void registerUpdateHistoryListener(ChangeListener listener) { + updateHistoryListeners.add(listener); + } + + public void unregisterUpdateHistoryListener(ChangeListener listener) { + updateHistoryListeners.remove(listener); + } + + public static interface ChangeListener { + public void onPreferenceChange(); + } + private static Preferences instance; public static void setup(Context context) { @@ -110,8 +172,4 @@ public class Preferences implements SharedPreferences.OnSharedPreferenceChangeLi return instance; } - public static interface ChangeListener { - public void onPreferenceChange(); - } - } diff --git a/src/org/fdroid/fdroid/PreferencesActivity.java b/src/org/fdroid/fdroid/PreferencesActivity.java index f66066d76..8de8a4589 100644 --- a/src/org/fdroid/fdroid/PreferencesActivity.java +++ b/src/org/fdroid/fdroid/PreferencesActivity.java @@ -37,7 +37,6 @@ public class PreferencesActivity extends PreferenceActivity implements OnSharedPreferenceChangeListener { public static final int RESULT_RELOAD = 1; - public static final int RESULT_REFILTER = 2; public static final int RESULT_RESTART = 4; private int result = 0; @@ -53,8 +52,7 @@ public class PreferencesActivity extends PreferenceActivity implements Preferences.PREF_COMPACT_LAYOUT, Preferences.PREF_IGN_TOUCH, Preferences.PREF_CACHE_APK, - Preferences.PREF_EXPERT, - Preferences.PREF_DB_SYNC + Preferences.PREF_EXPERT }; @Override @@ -133,10 +131,6 @@ public class PreferencesActivity extends PreferenceActivity implements } else if (key.equals(Preferences.PREF_ROOTED)) { onoffSummary(key, R.string.rooted_on, R.string.rooted_off); - if (changing) { - result ^= RESULT_REFILTER; - setResult(result); - } } else if (key.equals(Preferences.PREF_IGN_TOUCH)) { onoffSummary(key, R.string.ignoreTouch_on, @@ -150,8 +144,6 @@ public class PreferencesActivity extends PreferenceActivity implements onoffSummary(key, R.string.expert_on, R.string.expert_off); - } else if (key.equals(Preferences.PREF_DB_SYNC)) { - entrySummary(key); } } diff --git a/src/org/fdroid/fdroid/RepoXMLHandler.java b/src/org/fdroid/fdroid/RepoXMLHandler.java index a22c98c36..5f0e2e046 100644 --- a/src/org/fdroid/fdroid/RepoXMLHandler.java +++ b/src/org/fdroid/fdroid/RepoXMLHandler.java @@ -21,6 +21,7 @@ package org.fdroid.fdroid; import android.os.Bundle; import org.fdroid.fdroid.data.Apk; +import org.fdroid.fdroid.data.App; import org.fdroid.fdroid.data.Repo; import org.fdroid.fdroid.updater.RepoUpdater; import org.xml.sax.Attributes; @@ -28,19 +29,17 @@ import org.xml.sax.SAXException; import org.xml.sax.helpers.DefaultHandler; import java.text.ParseException; -import java.util.HashMap; -import java.util.List; -import java.util.Map; +import java.util.*; public class RepoXMLHandler extends DefaultHandler { // The repo we're processing. private Repo repo; - private Map apps; - private List appsList; + private List apps = new ArrayList(); + private List apksList = new ArrayList(); - private DB.App curapp = null; + private App curapp = null; private Apk curapk = null; private StringBuilder curchars = new StringBuilder(); @@ -64,17 +63,22 @@ public class RepoXMLHandler extends DefaultHandler { private int totalAppCount; - public RepoXMLHandler(Repo repo, List appsList, ProgressListener listener) { + public RepoXMLHandler(Repo repo, ProgressListener listener) { this.repo = repo; - this.apps = new HashMap(); - for (DB.App app : appsList) this.apps.put(app.id, app); - this.appsList = appsList; pubkey = null; name = null; description = null; progressListener = listener; } + public List getApps() { + return apps; + } + + public List getApks() { + return apksList; + } + public int getMaxAge() { return maxage; } public int getVersion() { return version; } @@ -104,21 +108,18 @@ public class RepoXMLHandler extends DefaultHandler { } if (curel.equals("application") && curapp != null) { - - // If we already have this application (must be from scanning a - // different repo) then just merge in the apks. - DB.App app = apps.get(curapp.id); - if (app != null) { - app.apks.addAll(curapp.apks); - } else { - appsList.add(curapp); - apps.put(curapp.id, curapp); - } - + apps.add(curapp); curapp = null; - + // If the app id is already present in this apps list, then it + // means the same index file has a duplicate app, which should + // not be allowed. + // However, I'm thinking that it should be unefined behaviour, + // because it is probably a bug in the fdroid server that made it + // happen, and I don't *think* it will crash the client, because + // the first app will insert, the second one will update the newly + // inserted one. } else if (curel.equals("package") && curapk != null && curapp != null) { - curapp.apks.add(curapk); + apksList.add(curapk); curapk = null; } else if (curapk != null && str != null) { if (curel.equals("version")) { @@ -131,19 +132,19 @@ public class RepoXMLHandler extends DefaultHandler { } } else if (curel.equals("size")) { try { - curapk.detail_size = Integer.parseInt(str); + curapk.size = Integer.parseInt(str); } catch (NumberFormatException ex) { - curapk.detail_size = 0; + curapk.size = 0; } } else if (curel.equals("hash")) { if (hashType == null || hashType.equals("md5")) { - if (curapk.detail_hash == null) { - curapk.detail_hash = str; - curapk.detail_hashType = "MD5"; + if (curapk.hash == null) { + curapk.hash = str; + curapk.hashType = "MD5"; } } else if (hashType.equals("sha256")) { - curapk.detail_hash = str; - curapk.detail_hashType = "SHA-256"; + curapk.hash = str; + curapk.hashType = "SHA-256"; } } else if (curel.equals("sig")) { curapk.sig = str; @@ -159,17 +160,17 @@ public class RepoXMLHandler extends DefaultHandler { } } else if (curel.equals("added")) { try { - curapk.added = str.length() == 0 ? null : DB.DATE_FORMAT + curapk.added = str.length() == 0 ? null : Utils.DATE_FORMAT .parse(str); } catch (ParseException e) { curapk.added = null; } } else if (curel.equals("permissions")) { - curapk.detail_permissions = DB.CommaSeparatedList.make(str); + curapk.permissions = Utils.CommaSeparatedList.make(str); } else if (curel.equals("features")) { - curapk.features = DB.CommaSeparatedList.make(str); + curapk.features = Utils.CommaSeparatedList.make(str); } else if (curel.equals("nativecode")) { - curapk.nativecode = DB.CommaSeparatedList.make(str); + curapk.nativecode = Utils.CommaSeparatedList.make(str); } } else if (curapp != null && str != null) { if (curel.equals("name")) { @@ -180,33 +181,33 @@ public class RepoXMLHandler extends DefaultHandler { // This is the old-style description. We'll read it // if present, to support old repos, but in newer // repos it will get overwritten straight away! - curapp.detail_description = "

" + str + "

"; + curapp.description = "

" + str + "

"; } else if (curel.equals("desc")) { // New-style description. - curapp.detail_description = str; + curapp.description = str; } else if (curel.equals("summary")) { curapp.summary = str; } else if (curel.equals("license")) { curapp.license = str; } else if (curel.equals("source")) { - curapp.detail_sourceURL = str; + curapp.sourceURL = str; } else if (curel.equals("donate")) { - curapp.detail_donateURL = str; + curapp.donateURL = str; } else if (curel.equals("bitcoin")) { - curapp.detail_bitcoinAddr = str; + curapp.bitcoinAddr = str; } else if (curel.equals("litecoin")) { - curapp.detail_litecoinAddr = str; + curapp.litecoinAddr = str; } else if (curel.equals("dogecoin")) { - curapp.detail_dogecoinAddr = str; + curapp.dogecoinAddr = str; } else if (curel.equals("flattr")) { - curapp.detail_flattrID = str; + curapp.flattrID = str; } else if (curel.equals("web")) { - curapp.detail_webURL = str; + curapp.webURL = str; } else if (curel.equals("tracker")) { - curapp.detail_trackerURL = str; + curapp.trackerURL = str; } else if (curel.equals("added")) { try { - curapp.added = str.length() == 0 ? null : DB.DATE_FORMAT + curapp.added = str.length() == 0 ? null : Utils.DATE_FORMAT .parse(str); } catch (ParseException e) { curapp.added = null; @@ -214,7 +215,7 @@ public class RepoXMLHandler extends DefaultHandler { } else if (curel.equals("lastupdated")) { try { curapp.lastUpdated = str.length() == 0 ? null - : DB.DATE_FORMAT.parse(str); + : Utils.DATE_FORMAT.parse(str); } catch (ParseException e) { curapp.lastUpdated = null; } @@ -227,11 +228,11 @@ public class RepoXMLHandler extends DefaultHandler { curapp.curVercode = -1; } } else if (curel.equals("categories")) { - curapp.categories = DB.CommaSeparatedList.make(str); + curapp.categories = Utils.CommaSeparatedList.make(str); } else if (curel.equals("antifeatures")) { - curapp.antiFeatures = DB.CommaSeparatedList.make(str); + curapp.antiFeatures = Utils.CommaSeparatedList.make(str); } else if (curel.equals("requirements")) { - curapp.requirements = DB.CommaSeparatedList.make(str); + curapp.requirements = Utils.CommaSeparatedList.make(str); } } else if (curel.equals("description")) { description = str; @@ -270,8 +271,7 @@ public class RepoXMLHandler extends DefaultHandler { description = dc; } else if (localName.equals("application") && curapp == null) { - curapp = new DB.App(); - curapp.detail_Populated = true; + curapp = new App(); curapp.id = attributes.getValue("", "id"); Bundle progressData = RepoUpdater.createProgressData(repo.address); progressCounter ++; diff --git a/src/org/fdroid/fdroid/SearchResults.java b/src/org/fdroid/fdroid/SearchResults.java index a9ea1d02b..426e3f76d 100644 --- a/src/org/fdroid/fdroid/SearchResults.java +++ b/src/org/fdroid/fdroid/SearchResults.java @@ -18,12 +18,10 @@ package org.fdroid.fdroid; -import java.util.ArrayList; -import java.util.List; - import android.app.ListActivity; import android.app.SearchManager; import android.content.Intent; +import android.database.Cursor; import android.net.Uri; import android.os.Bundle; import android.util.Log; @@ -37,8 +35,11 @@ import android.support.v4.app.NavUtils; import android.support.v4.view.MenuItemCompat; import org.fdroid.fdroid.compat.ActionBarCompat; +import org.fdroid.fdroid.data.App; +import org.fdroid.fdroid.data.AppProvider; import org.fdroid.fdroid.views.AppListAdapter; import org.fdroid.fdroid.views.AvailableAppListAdapter; +import org.fdroid.fdroid.views.fragments.AppListFragment; public class SearchResults extends ListActivity { @@ -46,7 +47,7 @@ public class SearchResults extends ListActivity { private static final int SEARCH = Menu.FIRST; - private AppListAdapter applist; + private AppListAdapter adapter; protected String getQuery() { Intent intent = getIntent(); @@ -73,7 +74,6 @@ public class SearchResults extends ListActivity { super.onCreate(savedInstanceState); ActionBarCompat.create(this).setDisplayHomeAsUpEnabled(true); - applist = new AvailableAppListAdapter(this); setContentView(R.layout.searchresults); // Start a search by just typing @@ -102,53 +102,32 @@ public class SearchResults extends ListActivity { if (query == null || query.length() == 0) finish(); - List matchingids = new ArrayList(); - try { - DB db = DB.getDB(); - matchingids = db.doSearch(query.trim()); - } catch (Exception ex) { - Log.d("FDroid", "Search failed - " + ex.getMessage()); - } finally { - DB.releaseDB(); - } - - List apps = new ArrayList(); - List allApps = ((FDroidApp) getApplication()).getApps(); - for (DB.App app : allApps) { - for (String id : matchingids) { - if (id.equals(app.id)) { - apps.add(app); - break; - } - } - } + Cursor cursor = getContentResolver().query( + AppProvider.getSearchUri(query), AppListFragment.APP_PROJECTION, + null, null, AppListFragment.APP_SORT); TextView tv = (TextView) findViewById(R.id.description); String headertext; - if (apps.size() == 0) { + int count = cursor != null ? cursor.getCount() : 0; + if (count == 0) { headertext = getString(R.string.searchres_noapps, query); - } else if (apps.size() == 1) { + } else if (count == 1) { headertext = getString(R.string.searchres_oneapp, query); } else { - headertext = getString(R.string.searchres_napps, apps.size(), query); + headertext = getString(R.string.searchres_napps, count, query); } tv.setText(headertext); - Log.d("FDroid", "Search for '" + query + "' returned " + apps.size() - + " results"); - applist.clear(); - for (DB.App app : apps) { - applist.addItem(app); - } - getListView().setFastScrollEnabled(true); - applist.notifyDataSetChanged(); - setListAdapter(applist); + Log.d("FDroid", "Search for '" + query + "' returned " + count + " results"); + adapter = new AvailableAppListAdapter(this, cursor); + getListView().setFastScrollEnabled(true); + setListAdapter(adapter); } @Override protected void onListItemClick(ListView l, View v, int position, long id) { - final DB.App app; - app = (DB.App) applist.getItem(position); + final App app; + app = new App((Cursor) adapter.getItem(position)); Intent intent = new Intent(this, AppDetails.class); intent.putExtra("appid", app.id); diff --git a/src/org/fdroid/fdroid/UpdateService.java b/src/org/fdroid/fdroid/UpdateService.java index 7d4063a88..4741aa51a 100644 --- a/src/org/fdroid/fdroid/UpdateService.java +++ b/src/org/fdroid/fdroid/UpdateService.java @@ -18,42 +18,26 @@ package org.fdroid.fdroid; -import java.util.ArrayList; -import java.util.List; -import java.util.Set; -import java.util.TreeSet; +import java.util.*; -import android.app.AlarmManager; -import android.app.IntentService; -import android.app.NotificationManager; -import android.app.PendingIntent; -import android.app.ProgressDialog; -import android.content.Context; -import android.content.Intent; -import android.content.SharedPreferences; +import android.app.*; +import android.content.*; import android.content.SharedPreferences.Editor; +import android.database.Cursor; import android.net.ConnectivityManager; import android.net.NetworkInfo; -import android.os.Build; -import android.os.Bundle; -import android.os.Handler; -import android.os.Parcelable; -import android.os.ResultReceiver; -import android.os.SystemClock; +import android.net.Uri; +import android.os.*; import android.preference.PreferenceManager; +import android.text.TextUtils; import android.util.Log; +import org.fdroid.fdroid.data.*; +import org.fdroid.fdroid.updater.RepoUpdater; import android.support.v4.app.NotificationCompat; import android.support.v4.app.TaskStackBuilder; -import android.text.TextUtils; -import android.util.Log; import android.widget.Toast; -import org.fdroid.fdroid.data.Apk; -import org.fdroid.fdroid.data.Repo; -import org.fdroid.fdroid.data.RepoProvider; -import org.fdroid.fdroid.updater.RepoUpdater; - public class UpdateService extends IntentService implements ProgressListener { public static final String RESULT_MESSAGE = "msg"; @@ -203,16 +187,6 @@ public class UpdateService extends IntentService implements ProgressListener { return receiver == null; } - // Get the number of apps that have updates available. - public int getNumUpdates(List apps) { - int count = 0; - for (DB.App app : apps) { - if (app.toUpdate) - count++; - } - return count; - } - @Override protected void onHandleIntent(Intent intent) { @@ -256,20 +230,113 @@ public class UpdateService extends IntentService implements ProgressListener { } else { Log.d("FDroid", "Unscheduled (manually requested) update"); } - errmsg = updateRepos(address); - if (TextUtils.isEmpty(errmsg)) { + + // Grab some preliminary information, then we can release the + // database while we do all the downloading, etc... + int updates = 0; + List repos = RepoProvider.Helper.all(getContentResolver()); + + // Process each repo... + Map appsToUpdate = new HashMap(); + List apksToUpdate = new ArrayList(); + List unchangedRepos = new ArrayList(); + List updatedRepos = new ArrayList(); + List disabledRepos = new ArrayList(); + boolean success = true; + boolean changes = false; + for (Repo repo : repos) { + + if (!repo.inuse) { + disabledRepos.add(repo); + continue; + } else if (!TextUtils.isEmpty(address) && !repo.address.equals(address)) { + unchangedRepos.add(repo); + continue; + } + + sendStatus(STATUS_INFO, getString(R.string.status_connecting_to_repo, repo.address)); + RepoUpdater updater = RepoUpdater.createUpdaterFor(getBaseContext(), repo); + updater.setProgressListener(this); + try { + updater.update(); + if (updater.hasChanged()) { + for (App app : updater.getApps()) { + appsToUpdate.put(app.id, app); + } + apksToUpdate.addAll(updater.getApks()); + updatedRepos.add(repo); + changes = true; + } else { + unchangedRepos.add(repo); + } + } catch (RepoUpdater.UpdateException e) { + errmsg += (errmsg.length() == 0 ? "" : "\n") + e.getMessage(); + Log.e("FDroid", "Error updating repository " + repo.address + ": " + e.getMessage()); + Log.e("FDroid", Log.getStackTraceString(e)); + } + } + + if (!changes && success) { + Log.d("FDroid", + "Not checking app details or compatibility, " + + "because all repos were up to date."); + } else if (changes && success) { + + sendStatus(STATUS_INFO, + getString(R.string.status_checking_compatibility)); + + List listOfAppsToUpdate = new ArrayList(); + listOfAppsToUpdate.addAll(appsToUpdate.values()); + + calcCompatibilityFlags(this, apksToUpdate, appsToUpdate); + calcIconUrls(this, apksToUpdate, appsToUpdate, repos); + calcCurrentApk(apksToUpdate, appsToUpdate); + + int totalInsertsUpdates = listOfAppsToUpdate.size() + apksToUpdate.size(); + updateOrInsertApps(listOfAppsToUpdate, totalInsertsUpdates, 0); + updateOrInsertApks(apksToUpdate, totalInsertsUpdates, listOfAppsToUpdate.size()); + removeApksFromRepos(disabledRepos); + removeApksNoLongerInRepo(listOfAppsToUpdate, updatedRepos); + removeAppsWithoutApks(); + notifyContentProviders(); + } + + if (success && changes && prefs.getBoolean(Preferences.PREF_UPD_NOTIFY, false)) { + int updateCount = 0; + for (App app : appsToUpdate.values()) { + if (app.hasUpdates(this)) { + updateCount ++; + } + } + + if (updateCount > 0) { + showAppUpdatesNotification(updateCount); + } + } + + if (!success) { + if (errmsg.length() == 0) + errmsg = "Unknown error"; + sendStatus(STATUS_ERROR, errmsg); + } else { Editor e = prefs.edit(); e.putLong(Preferences.PREF_UPD_LAST, System.currentTimeMillis()); e.commit(); + if (changes) { + sendStatus(STATUS_COMPLETE_WITH_CHANGES); + } else { + sendStatus(STATUS_COMPLETE_AND_SAME); + } } + } catch (Exception e) { Log.e("FDroid", "Exception during update processing:\n" + Log.getStackTraceString(e)); - if (TextUtils.isEmpty(errmsg)) + if (errmsg.length() == 0) errmsg = "Unknown error"; sendStatus(STATUS_ERROR, errmsg); - } finally { + } finally { Log.d("FDroid", "Update took " + ((System.currentTimeMillis() - startTime) / 1000) + " seconds."); @@ -277,147 +344,112 @@ public class UpdateService extends IntentService implements ProgressListener { } } - protected String updateRepos(String address) throws Exception { - SharedPreferences prefs = PreferenceManager - .getDefaultSharedPreferences(getBaseContext()); - boolean notify = prefs.getBoolean(Preferences.PREF_UPD_NOTIFY, false); - String errmsg = ""; - // Grab some preliminary information, then we can release the - // database while we do all the downloading, etc... - int updates = 0; - List repos; - List apps; - try { - DB db = DB.getDB(); - apps = db.getApps(false); - } finally { - DB.releaseDB(); - } + private void notifyContentProviders() { + getContentResolver().notifyChange(AppProvider.getContentUri(), null); + getContentResolver().notifyChange(ApkProvider.getContentUri(), null); + } - repos = RepoProvider.Helper.all(getContentResolver()); - - // Process each repo... - List updatingApps = new ArrayList(); - Set keeprepos = new TreeSet(); - boolean changes = false; - boolean update; - for (Repo repo : repos) { - if (!repo.inuse) - continue; - // are we updating all repos, or just one? - if (TextUtils.isEmpty(address)) { - update = true; + private static void calcCompatibilityFlags(Context context, List apks, + Map apps) { + CompatibilityChecker checker = new CompatibilityChecker(context); + for (Apk apk : apks) { + List reasons = checker.getIncompatibleReasons(apk); + if (reasons.size() > 0) { + apk.compatible = false; + apk.incompatible_reasons = Utils.CommaSeparatedList.make(reasons); } else { - // if only updating one repo, mark the rest as keepers - if (address.equals(repo.address)) { - update = true; + apk.compatible = true; + apk.incompatible_reasons = null; + apps.get(apk.id).compatible = true; + } + } + } + + /** + * Get the current version - this will be one of the Apks from 'apks'. + * Can return null if there are no available versions. + * This should be the 'current' version, as in the most recent stable + * one, that most users would want by default. It might not be the + * most recent, if for example there are betas etc. + */ + private static void calcCurrentApk(List apks, Map apps ) { + for ( App app : apps.values() ) { + List apksForApp = new ArrayList(); + for (Apk apk : apks) { + if (apk.id.equals(app.id)) { + apksForApp.add(apk); + } + } + calcCurrentApkForApp(app, apksForApp); + } + } + + private static void calcCurrentApkForApp(App app, List apksForApp) { + Apk latestApk = null; + // Try and return the real current version first. It will find the + // closest version smaller than the curVercode, being the same + // vercode if it exists. + if (app.curVercode > 0) { + int latestcode = -1; + for (Apk apk : apksForApp) { + if ((!app.compatible || apk.compatible) + && apk.vercode <= app.curVercode + && apk.vercode > latestcode) { + latestApk = apk; + latestcode = apk.vercode; + } + } + } else if (app.curVercode == -1) { + // If the current version was not set we return the most recent apk. + int latestCode = -1; + for (Apk apk : apksForApp) { + if ((!app.compatible || apk.compatible) + && apk.vercode > latestCode) { + latestApk = apk; + latestCode = apk.vercode; + } + } + } + + if (latestApk != null) { + app.curVercode = latestApk.vercode; + app.curVersion = latestApk.version; + } + } + + private static void calcIconUrls(Context context, List apks, + Map apps, List repos) { + String iconsDir = Utils.getIconsDir(context); + Log.d("FDroid", "Density-specific icons dir is " + iconsDir); + for (App app : apps.values()) { + if (app.iconUrl == null && app.icon != null) { + calcIconUrl(iconsDir, app, apks, repos); + } + } + } + + private static void calcIconUrl(String iconsDir, App app, + List allApks, List repos) { + List apksForApp = new ArrayList(); + for (Apk apk : allApks) { + if (apk.id.equals(app.id)) { + apksForApp.add(apk); + } + } + + Collections.sort(apksForApp); + for (int i = apksForApp.size() - 1; i >= 0; i --) { + Apk apk = apksForApp.get(i); + for (Repo repo : repos) { + if (repo.getId() != apk.repo) continue; + if (repo.version >= Repo.VERSION_DENSITY_SPECIFIC_ICONS) { + app.iconUrl = repo.address + iconsDir + app.icon; } else { - keeprepos.add(repo.getId()); - update = false; + app.iconUrl = repo.address + "/icons/" + app.icon; } - } - if (!update) - continue; - sendStatus(STATUS_INFO, getString(R.string.status_connecting_to_repo, repo.address)); - RepoUpdater updater = RepoUpdater.createUpdaterFor(getBaseContext(), repo); - updater.setProgressListener(this); - try { - updater.update(); - if (updater.hasChanged()) { - updatingApps.addAll(updater.getApps()); - changes = true; - } else { - keeprepos.add(repo.getId()); - } - } catch (RepoUpdater.UpdateException e) { - errmsg += (errmsg.length() == 0 ? "" : "\n") + e.getMessage(); - Log.e("FDroid", "Error updating repository " + repo.address + ": " + e.getMessage()); - Log.e("FDroid", Log.getStackTraceString(e)); + return; } } - - boolean success = true; - if (!changes) { - Log.d("FDroid", "Not checking app details or compatibility, " + - "because all repos were up to date."); - } else { - sendStatus(STATUS_INFO, getString(R.string.status_checking_compatibility)); - - DB db = DB.getDB(); - try { - - // Need to flag things we're keeping despite having received - // no data about during the update. (i.e. stuff from a repo - // that we know is unchanged due to the etag) - for (long keep : keeprepos) { - for (DB.App app : apps) { - boolean keepapp = false; - for (Apk apk : app.apks) { - if (apk.repo == keep) { - keepapp = true; - break; - } - } - if (keepapp) { - DB.App app_k = null; - for (DB.App app2 : apps) { - if (app2.id.equals(app.id)) { - app_k = app2; - break; - } - } - if (app_k == null) { - updatingApps.add(app); - app_k = app; - } - app_k.updated = true; - db.populateDetails(app_k, keep); - for (Apk apk : app.apks) - if (apk.repo == keep) - apk.updated = true; - } - } - } - - db.beginUpdate(apps); - for (DB.App app : updatingApps) { - db.updateApplication(app); - } - db.endUpdate(); - } catch (Exception ex) { - db.cancelUpdate(); - Log.e("FDroid", "Exception during update processing:\n" - + Log.getStackTraceString(ex)); - errmsg = "Exception during processing - " + ex.getMessage(); - success = false; - } finally { - DB.releaseDB(); - } - } - - if (success && changes) { - ((FDroidApp) getApplication()).invalidateAllApps(); - if (notify) { - apps = ((FDroidApp) getApplication()).getApps(); - updates = getNumUpdates(apps); - } - if (notify && updates > 0) - showAppUpdatesNotification(updates); - } - - if (success) { - if (changes) { - sendStatus(STATUS_COMPLETE_WITH_CHANGES); - } else { - sendStatus(STATUS_COMPLETE_AND_SAME); - } - } else { - if (TextUtils.isEmpty(errmsg)) - errmsg = "Unknown error"; - sendStatus(STATUS_ERROR, errmsg); - } - - return errmsg; } private void showAppUpdatesNotification(int updates) throws Exception { @@ -447,6 +479,209 @@ public class UpdateService extends IntentService implements ProgressListener { nm.notify(1, builder.build()); } + private List getKnownAppIds(List apps) { + List knownAppIds = new ArrayList(); + if (apps.size() > AppProvider.MAX_APPS_TO_QUERY) { + int middle = apps.size() / 2; + List apps1 = apps.subList(0, middle); + List apps2 = apps.subList(middle, apps.size()); + knownAppIds.addAll(getKnownAppIds(apps1)); + knownAppIds.addAll(getKnownAppIds(apps2)); + } else { + knownAppIds.addAll(getKnownAppIdsFromProvider(apps)); + } + return knownAppIds; + } + + /** + * Looks in the database to see which apps we already know about. Only + * returns ids of apps that are in the database if they are in the "apps" + * array. + */ + private List getKnownAppIdsFromProvider(List apps) { + + Uri uri = AppProvider.getContentUri(apps); + String[] fields = new String[] { AppProvider.DataColumns.APP_ID }; + Cursor cursor = getContentResolver().query(uri, fields, null, null, null); + + int knownIdCount = cursor != null ? cursor.getCount() : 0; + List knownIds = new ArrayList(knownIdCount); + if (knownIdCount > 0) { + cursor.moveToFirst(); + while (!cursor.isAfterLast()) { + knownIds.add(cursor.getString(0)); + cursor.moveToNext(); + } + } + + return knownIds; + } + + /** + * If you call this with too many apks, then it will likely hit limit of + * parameters allowed for sqlite3 query. Rather, you should use + * {@link org.fdroid.fdroid.UpdateService#getKnownApks(java.util.List)} + * instead, which will only call this with the right number of apks at + * a time. + * @see org.fdroid.fdroid.UpdateService#getKnownAppIds(java.util.List) + */ + private List getKnownApksFromProvider(List apks) { + String[] fields = { + ApkProvider.DataColumns.APK_ID, + ApkProvider.DataColumns.VERSION, + ApkProvider.DataColumns.VERSION_CODE + }; + return ApkProvider.Helper.knownApks(getContentResolver(), apks, fields); + } + + private void updateOrInsertApps(List appsToUpdate, int totalUpdateCount, int currentCount) { + + ArrayList operations = new ArrayList(); + List knownAppIds = getKnownAppIds(appsToUpdate); + for (App a : appsToUpdate) { + boolean known = false; + for (String knownId : knownAppIds) { + if (knownId.equals(a.id)) { + known = true; + break; + } + } + + if (known) { + operations.add(updateExistingApp(a)); + } else { + operations.add(insertNewApp(a)); + } + } + + Log.d("FDroid", "Updating/inserting " + operations.size() + " apps."); + try { + executeBatchWithStatus(AppProvider.getAuthority(), operations, currentCount, totalUpdateCount); + } catch (RemoteException e) { + Log.e("FDroid", e.getMessage()); + } catch (OperationApplicationException e) { + Log.e("FDroid", e.getMessage()); + } + } + + private void executeBatchWithStatus(String providerAuthority, + ArrayList operations, + int currentCount, + int totalUpdateCount) + throws RemoteException, OperationApplicationException { + int i = 0; + while (i < operations.size()) { + int count = Math.min(operations.size() - i, 100); + ArrayList o = new ArrayList(operations.subList(i, i + count)); + sendStatus(STATUS_INFO, getString( + R.string.status_inserting, + (int)((double)(currentCount + i) / totalUpdateCount * 100))); + getContentResolver().applyBatch(providerAuthority, o); + i += 100; + } + } + + /** + * Return list of apps from "fromApks" which are already in the database. + */ + private List getKnownApks(List apks) { + List knownApks = new ArrayList(); + if (apks.size() > ApkProvider.MAX_APKS_TO_QUERY) { + int middle = apks.size() / 2; + List apks1 = apks.subList(0, middle); + List apks2 = apks.subList(middle, apks.size()); + knownApks.addAll(getKnownApks(apks1)); + knownApks.addAll(getKnownApks(apks2)); + } else { + knownApks.addAll(getKnownApksFromProvider(apks)); + } + return knownApks; + } + + private void updateOrInsertApks(List apksToUpdate, int totalApksAppsCount, int currentCount) { + + ArrayList operations = new ArrayList(); + + List knownApks = getKnownApks(apksToUpdate); + for (Apk apk : apksToUpdate) { + boolean known = false; + for (Apk knownApk : knownApks) { + if (knownApk.id.equals(apk.id) && knownApk.version.equals(knownApk.version)) { + known = true; + break; + } + } + + if (known) { + operations.add(updateExistingApk(apk)); + } else { + operations.add(insertNewApk(apk)); + } + } + + Log.d("FDroid", "Updating/inserting " + operations.size() + " apks."); + try { + executeBatchWithStatus(ApkProvider.getAuthority(), operations, currentCount, totalApksAppsCount); + } catch (RemoteException e) { + Log.e("FDroid", e.getMessage()); + } catch (OperationApplicationException e) { + Log.e("FDroid", e.getMessage()); + } + } + + private ContentProviderOperation updateExistingApk(Apk apk) { + Uri uri = ApkProvider.getContentUri(apk); + ContentValues values = apk.toContentValues(); + return ContentProviderOperation.newUpdate(uri).withValues(values).build(); + } + + private ContentProviderOperation insertNewApk(Apk apk) { + ContentValues values = apk.toContentValues(); + Uri uri = ApkProvider.getContentUri(); + return ContentProviderOperation.newInsert(uri).withValues(values).build(); + } + + private ContentProviderOperation updateExistingApp(App app) { + Uri uri = AppProvider.getContentUri(app); + ContentValues values = app.toContentValues(); + return ContentProviderOperation.newUpdate(uri).withValues(values).build(); + } + + private ContentProviderOperation insertNewApp(App app) { + ContentValues values = app.toContentValues(); + Uri uri = AppProvider.getContentUri(); + return ContentProviderOperation.newInsert(uri).withValues(values).build(); + } + + /** + * If a repo was updated (i.e. it is in use, and the index has changed + * since last time we did an update), then we want to remove any apks that + * belong to the repo which are not in the current list of apks that were + * retrieved. + */ + private void removeApksNoLongerInRepo(List appsToUpdate, + List updatedRepos) { + for (Repo repo : updatedRepos) { + Log.d("FDroid", "Removing apks no longer in repo " + repo.address); + // TODO: Implement + } + + } + + private void removeApksFromRepos(List repos) { + for (Repo repo : repos) { + Log.d("FDroid", "Removing apks from repo " + repo.address); + Uri uri = ApkProvider.getRepoUri(repo.getId()); + getContentResolver().delete(uri, null, null); + } + } + + private void removeAppsWithoutApks() { + Log.d("FDroid", "Removing aps that don't have any apks"); + getContentResolver().delete(AppProvider.getNoApksUri(), null, null); + } + + /** * Received progress event from the RepoXMLHandler. It could be progress * downloading from the repo, or perhaps processing the info from the repo. diff --git a/src/org/fdroid/fdroid/Utils.java b/src/org/fdroid/fdroid/Utils.java index 767b30048..97f4e4a92 100644 --- a/src/org/fdroid/fdroid/Utils.java +++ b/src/org/fdroid/fdroid/Utils.java @@ -20,22 +20,60 @@ package org.fdroid.fdroid; import android.content.Context; +import android.content.pm.PackageInfo; +import android.text.TextUtils; +import android.util.DisplayMetrics; +import android.util.Log; import com.nostra13.universalimageloader.utils.StorageUtils; -import java.io.*; +import java.io.BufferedReader; +import java.io.Closeable; +import java.io.File; +import java.io.FileReader; +import java.io.InputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.security.cert.Certificate; +import java.security.cert.CertificateEncodingException; import java.text.SimpleDateFormat; -import java.util.Locale; +import java.security.MessageDigest; +import java.util.*; + +import org.fdroid.fdroid.data.Repo; public final class Utils { public static final int BUFFER_SIZE = 4096; + // The date format used for storing dates (e.g. lastupdated, added) in the + // database. + public static final SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd", Locale.ENGLISH); + private static final String[] FRIENDLY_SIZE_FORMAT = { "%.0f B", "%.0f KiB", "%.1f MiB", "%.2f GiB" }; public static final SimpleDateFormat LOG_DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.ENGLISH); + public static String getIconsDir(Context context) { + DisplayMetrics metrics = context.getResources().getDisplayMetrics(); + String iconsDir; + if (metrics.densityDpi >= 640) { + iconsDir = "/icons-640/"; + } else if (metrics.densityDpi >= 480) { + iconsDir = "/icons-480/"; + } else if (metrics.densityDpi >= 320) { + iconsDir = "/icons-320/"; + } else if (metrics.densityDpi >= 240) { + iconsDir = "/icons-240/"; + } else if (metrics.densityDpi >= 160) { + iconsDir = "/icons-160/"; + } else { + iconsDir = "/icons-120/"; + } + return iconsDir; + } + public static void copy(InputStream input, OutputStream output) throws IOException { copy(input, output, null, null); @@ -158,4 +196,145 @@ 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; + else + return calcFingerprint(Hasher.unhex(keyHexString)); + } + + public static String calcFingerprint(Certificate cert) { + try { + return calcFingerprint(cert.getEncoded()); + } catch (CertificateEncodingException e) { + return null; + } + } + + public static String calcFingerprint(byte[] key) { + String ret = null; + try { + // keytool -list -v gives you the SHA-256 fingerprint + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + digest.update(key); + byte[] fingerprint = digest.digest(); + Formatter formatter = new Formatter(new StringBuilder()); + for (int i = 1; i < fingerprint.length; i++) { + formatter.format("%02X", fingerprint[i]); + } + ret = formatter.toString(); + formatter.close(); + } catch (Exception e) { + Log.w("FDroid", "Unable to get certificate fingerprint.\n" + + Log.getStackTraceString(e)); + } + return ret; + } + + public static class CommaSeparatedList implements Iterable { + private String value; + + private CommaSeparatedList(String list) { + value = list; + } + + public static CommaSeparatedList make(List list) { + if (list == null || list.size() == 0) + return null; + else { + StringBuilder sb = new StringBuilder(); + for(int i = 0; i < list.size(); i ++) { + if (i > 0) { + sb.append(','); + } + sb.append(list.get(i)); + } + return new CommaSeparatedList(sb.toString()); + } + } + + public static CommaSeparatedList make(String list) { + if (list == null || list.length() == 0) + return null; + else + return new CommaSeparatedList(list); + } + + public static String str(CommaSeparatedList instance) { + return (instance == null ? null : instance.toString()); + } + + @Override + public String toString() { + return value; + } + + public String toPrettyString() { + return value.replaceAll(",", ", "); + } + + @Override + public Iterator iterator() { + TextUtils.SimpleStringSplitter splitter = new TextUtils.SimpleStringSplitter(','); + splitter.setString(value); + return splitter.iterator(); + } + + public boolean contains(String v) { + for (String s : this) { + if (s.equals(v)) + return true; + } + return false; + } + } + + 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/Apk.java b/src/org/fdroid/fdroid/data/Apk.java index cf0c0a0f7..9f6b305f1 100644 --- a/src/org/fdroid/fdroid/data/Apk.java +++ b/src/org/fdroid/fdroid/data/Apk.java @@ -1,37 +1,27 @@ package org.fdroid.fdroid.data; import android.content.ContentValues; -import android.content.Context; -import android.content.SharedPreferences; -import android.content.pm.FeatureInfo; -import android.content.pm.PackageManager; import android.database.Cursor; -import android.preference.PreferenceManager; -import android.util.Log; -import org.fdroid.fdroid.DB; -import org.fdroid.fdroid.compat.Compatibility; -import org.fdroid.fdroid.compat.SupportedArchitectures; +import org.fdroid.fdroid.Utils; -import java.util.Date; -import java.util.Set; -import java.util.HashSet; +import java.util.*; -public class Apk { +public class Apk extends ValueObject implements Comparable { public String id; public String version; public int vercode; - public int detail_size; // Size in bytes - 0 means we don't know! + public int size; // Size in bytes - 0 means we don't know! public long repo; // ID of the repo it comes from - public String detail_hash; - public String detail_hashType; + public String hash; + public String hashType; public int minSdkVersion; // 0 if unknown public Date added; - public DB.CommaSeparatedList detail_permissions; // null if empty or + public Utils.CommaSeparatedList permissions; // null if empty or // unknown - public DB.CommaSeparatedList features; // null if empty or unknown + public Utils.CommaSeparatedList features; // null if empty or unknown - public DB.CommaSeparatedList nativecode; // null if empty or unknown + public Utils.CommaSeparatedList nativecode; // null if empty or unknown // ID (md5 sum of public key) of signature. Might be null, in the // transition to this field existing. @@ -52,30 +42,33 @@ public class Apk { public int repoVersion; public String repoAddress; - public DB.CommaSeparatedList incompatible_reasons; + public Utils.CommaSeparatedList incompatible_reasons; public Apk() { updated = false; - detail_size = 0; + size = 0; added = null; repo = 0; - detail_hash = null; - detail_hashType = null; - detail_permissions = null; + hash = null; + hashType = null; + permissions = null; compatible = false; } public Apk(Cursor cursor) { + + checkCursorPosition(cursor); + for(int i = 0; i < cursor.getColumnCount(); i ++ ) { String column = cursor.getColumnName(i); if (column.equals(ApkProvider.DataColumns.HASH)) { - detail_hash = cursor.getString(i); + hash = cursor.getString(i); } else if (column.equals(ApkProvider.DataColumns.HASH_TYPE)) { - detail_hashType = cursor.getString(i); + hashType = cursor.getString(i); } else if (column.equals(ApkProvider.DataColumns.ADDED_DATE)) { added = ValueObject.toDate(cursor.getString(i)); } else if (column.equals(ApkProvider.DataColumns.FEATURES)) { - features = DB.CommaSeparatedList.make(cursor.getString(i)); + features = Utils.CommaSeparatedList.make(cursor.getString(i)); } else if (column.equals(ApkProvider.DataColumns.APK_ID)) { id = cursor.getString(i); } else if (column.equals(ApkProvider.DataColumns.IS_COMPATIBLE)) { @@ -85,15 +78,17 @@ public class Apk { } else if (column.equals(ApkProvider.DataColumns.NAME)) { apkName = cursor.getString(i); } else if (column.equals(ApkProvider.DataColumns.PERMISSIONS)) { - detail_permissions = DB.CommaSeparatedList.make(cursor.getString(i)); + permissions = Utils.CommaSeparatedList.make(cursor.getString(i)); } else if (column.equals(ApkProvider.DataColumns.NATIVE_CODE)) { - nativecode = DB.CommaSeparatedList.make(cursor.getString(i)); + nativecode = Utils.CommaSeparatedList.make(cursor.getString(i)); + } else if (column.equals(ApkProvider.DataColumns.INCOMPATIBLE_REASONS)) { + incompatible_reasons = Utils.CommaSeparatedList.make(cursor.getString(i)); } else if (column.equals(ApkProvider.DataColumns.REPO_ID)) { repo = cursor.getInt(i); } else if (column.equals(ApkProvider.DataColumns.SIGNATURE)) { sig = cursor.getString(i); } else if (column.equals(ApkProvider.DataColumns.SIZE)) { - detail_size = cursor.getInt(i); + size = cursor.getInt(i); } else if (column.equals(ApkProvider.DataColumns.SOURCE_NAME)) { srcname = cursor.getString(i); } else if (column.equals(ApkProvider.DataColumns.VERSION)) { @@ -108,109 +103,36 @@ public class Apk { } } + @Override + public String toString() { + return id + " (version " + vercode + ")"; + } + public ContentValues toContentValues() { ContentValues values = new ContentValues(); values.put(ApkProvider.DataColumns.APK_ID, id); values.put(ApkProvider.DataColumns.VERSION, version); values.put(ApkProvider.DataColumns.VERSION_CODE, vercode); values.put(ApkProvider.DataColumns.REPO_ID, repo); - values.put(ApkProvider.DataColumns.HASH, detail_hash); - values.put(ApkProvider.DataColumns.HASH_TYPE, detail_hashType); + values.put(ApkProvider.DataColumns.HASH, hash); + values.put(ApkProvider.DataColumns.HASH_TYPE, hashType); values.put(ApkProvider.DataColumns.SIGNATURE, sig); values.put(ApkProvider.DataColumns.SOURCE_NAME, srcname); - values.put(ApkProvider.DataColumns.SIZE, detail_size); + values.put(ApkProvider.DataColumns.SIZE, size); values.put(ApkProvider.DataColumns.NAME, apkName); values.put(ApkProvider.DataColumns.MIN_SDK_VERSION, minSdkVersion); - values.put(ApkProvider.DataColumns.ADDED_DATE, - added == null ? "" : DB.DATE_FORMAT.format(added)); - values.put(ApkProvider.DataColumns.PERMISSIONS, - DB.CommaSeparatedList.str(detail_permissions)); - values.put(ApkProvider.DataColumns.FEATURES, DB.CommaSeparatedList.str(features)); - values.put(ApkProvider.DataColumns.NATIVE_CODE, DB.CommaSeparatedList.str(nativecode)); + values.put(ApkProvider.DataColumns.ADDED_DATE, added == null ? "" : Utils.DATE_FORMAT.format(added)); + values.put(ApkProvider.DataColumns.PERMISSIONS, Utils.CommaSeparatedList.str(permissions)); + values.put(ApkProvider.DataColumns.FEATURES, Utils.CommaSeparatedList.str(features)); + values.put(ApkProvider.DataColumns.NATIVE_CODE, Utils.CommaSeparatedList.str(nativecode)); + values.put(ApkProvider.DataColumns.INCOMPATIBLE_REASONS, Utils.CommaSeparatedList.str(incompatible_reasons)); values.put(ApkProvider.DataColumns.IS_COMPATIBLE, compatible ? 1 : 0); return values; } - // Call isCompatible(apk) on an instance of this class to - // check if an APK is compatible with the user's device. - public static class CompatibilityChecker extends Compatibility { - - private Set features; - private Set cpuAbis; - private String cpuAbisDesc; - private boolean ignoreTouchscreen; - - public CompatibilityChecker(Context ctx) { - - SharedPreferences prefs = PreferenceManager - .getDefaultSharedPreferences(ctx); - ignoreTouchscreen = prefs - .getBoolean("ignoreTouchscreen", false); - - PackageManager pm = ctx.getPackageManager(); - StringBuilder logMsg = new StringBuilder(); - logMsg.append("Available device features:"); - features = new HashSet(); - if (pm != null) { - for (FeatureInfo fi : pm.getSystemAvailableFeatures()) { - features.add(fi.name); - logMsg.append('\n'); - logMsg.append(fi.name); - } - } - - cpuAbis = SupportedArchitectures.getAbis(); - - StringBuilder builder = new StringBuilder(); - boolean first = true; - for (String abi : cpuAbis) { - if (first) first = false; - else builder.append(", "); - builder.append(abi); - } - cpuAbisDesc = builder.toString(); - builder = null; - - Log.d("FDroid", logMsg.toString()); - } - - private boolean compatibleApi(DB.CommaSeparatedList nativecode) { - if (nativecode == null) return true; - for (String abi : nativecode) { - if (cpuAbis.contains(abi)) { - return true; - } - } - return false; - } - - public boolean isCompatible(Apk apk) { - if (!hasApi(apk.minSdkVersion)) { - apk.incompatible_reasons = DB.CommaSeparatedList.make(String.valueOf(apk.minSdkVersion)); - return false; - } - if (apk.features != null) { - for (String feat : apk.features) { - if (ignoreTouchscreen - && feat.equals("android.hardware.touchscreen")) { - // Don't check it! - } else if (!features.contains(feat)) { - apk.incompatible_reasons = DB.CommaSeparatedList.make(feat); - Log.d("FDroid", apk.id + " vercode " + apk.vercode - + " is incompatible based on lack of " - + feat); - return false; - } - } - } - if (!compatibleApi(apk.nativecode)) { - apk.incompatible_reasons = apk.nativecode; - Log.d("FDroid", apk.id + " vercode " + apk.vercode - + " only supports " + DB.CommaSeparatedList.str(apk.nativecode) - + " while your architectures are " + cpuAbisDesc); - return false; - } - return true; - } + @Override + public int compareTo(Apk apk) { + return Integer.valueOf(vercode).compareTo(apk.vercode); } + } diff --git a/src/org/fdroid/fdroid/data/ApkProvider.java b/src/org/fdroid/fdroid/data/ApkProvider.java index b1fc75f30..a4b16b39c 100644 --- a/src/org/fdroid/fdroid/data/ApkProvider.java +++ b/src/org/fdroid/fdroid/data/ApkProvider.java @@ -8,12 +8,19 @@ import android.database.Cursor; import android.net.Uri; import android.provider.BaseColumns; import android.util.Log; -import org.fdroid.fdroid.DB; import java.util.*; public class ApkProvider extends FDroidProvider { + /** + * SQLite has a maximum of 999 parameters in a query. Each apk we add + * requires two (id and vercode) so we can only query half of that. Then, + * we may want to add additional constraints, so we give our self some + * room by saying only 450 apks can be queried at once. + */ + public static final int MAX_APKS_TO_QUERY = 450; + public static final class Helper { private Helper() {} @@ -75,10 +82,10 @@ public class ApkProvider extends FDroidProvider { Uri uri = getContentUri(); String[] args = { Long.toString(repo.getId()) }; String selection = DataColumns.REPO_ID + " = ?"; - resolver.delete(uri, selection + " = ?", args); + int count = resolver.delete(uri, selection, args); } - public static void deleteApksByApp(Context context, DB.App app) { + public static void deleteApksByApp(Context context, App app) { ContentResolver resolver = context.getContentResolver(); Uri uri = getContentUri(); String[] args = { app.id }; @@ -95,6 +102,7 @@ public class ApkProvider extends FDroidProvider { Uri uri = getContentUri(id, versionCode); Cursor cursor = resolver.query(uri, projection, null, null, null); if (cursor != null && cursor.getCount() > 0) { + cursor.moveToFirst(); return new Apk(cursor); } else { return null; @@ -106,6 +114,29 @@ public class ApkProvider extends FDroidProvider { Uri uri = getContentUri(id, versionCode); resolver.delete(uri, null, null); } + + public static List findByApp(ContentResolver resolver, String appId) { + return findByApp(resolver, appId, ApkProvider.DataColumns.ALL); + } + + public static List findByApp(ContentResolver resolver, + String appId, String[] projection) { + Uri uri = getAppUri(appId); + String sort = ApkProvider.DataColumns.VERSION_CODE + " DESC"; + Cursor cursor = resolver.query(uri, projection, null, null, sort); + return cursorToList(cursor); + } + + /** + * Returns apks in the database, which have the same id and version as + * one of the apks in the "apks" argument. + */ + public static List knownApks(ContentResolver resolver, + List apks, String[] fields) { + Uri uri = getContentUri(apks); + Cursor cursor = resolver.query(uri, fields, null, null, null); + return cursorToList(cursor); + } } public interface DataColumns extends BaseColumns { @@ -126,6 +157,7 @@ public class ApkProvider extends FDroidProvider { public static String HASH_TYPE = "hashType"; public static String ADDED_DATE = "added"; public static String IS_COMPATIBLE = "compatible"; + public static String INCOMPATIBLE_REASONS = "incompatibleReasons"; public static String REPO_VERSION = "repoVersion"; public static String REPO_ADDRESS = "repoAddress"; @@ -133,12 +165,19 @@ public class ApkProvider extends FDroidProvider { _ID, APK_ID, VERSION, REPO_ID, HASH, VERSION_CODE, NAME, SIZE, SIGNATURE, SOURCE_NAME, MIN_SDK_VERSION, PERMISSIONS, FEATURES, NATIVE_CODE, HASH_TYPE, ADDED_DATE, IS_COMPATIBLE, - - REPO_VERSION, REPO_ADDRESS + REPO_VERSION, REPO_ADDRESS, INCOMPATIBLE_REASONS }; } + private static final int CODE_APP = CODE_SINGLE + 1; + private static final int CODE_REPO = CODE_APP + 1; + private static final int CODE_APKS = CODE_REPO + 1; + private static final String PROVIDER_NAME = "ApkProvider"; + private static final String PATH_APK = "apk"; + private static final String PATH_APKS = "apks"; + private static final String PATH_APP = "app"; + private static final String PATH_REPO = "repo"; private static final UriMatcher matcher = new UriMatcher(-1); @@ -148,22 +187,65 @@ public class ApkProvider extends FDroidProvider { REPO_FIELDS.put(DataColumns.REPO_VERSION, RepoProvider.DataColumns.VERSION); REPO_FIELDS.put(DataColumns.REPO_ADDRESS, RepoProvider.DataColumns.ADDRESS); - matcher.addURI(AUTHORITY + "." + PROVIDER_NAME, null, CODE_LIST); - matcher.addURI(AUTHORITY + "." + PROVIDER_NAME, "/*/#", CODE_SINGLE); + matcher.addURI(getAuthority(), PATH_REPO + "/#", CODE_REPO); + matcher.addURI(getAuthority(), PATH_APK + "/#/*", CODE_SINGLE); + matcher.addURI(getAuthority(), PATH_APKS + "/*", CODE_APKS); + matcher.addURI(getAuthority(), PATH_APP + "/*", CODE_APP); + matcher.addURI(getAuthority(), null, CODE_LIST); + } + + public static String getAuthority() { + return AUTHORITY + "." + PROVIDER_NAME; } public static Uri getContentUri() { - return Uri.parse("content://" + AUTHORITY + "." + PROVIDER_NAME); + return Uri.parse("content://" + getAuthority()); + } + + public static Uri getAppUri(String appId) { + return getContentUri() + .buildUpon() + .appendPath(PATH_APP) + .appendPath(appId) + .build(); + } + + public static Uri getRepoUri(long repoId) { + return getContentUri() + .buildUpon() + .appendPath(PATH_REPO) + .appendPath(Long.toString(repoId)) + .build(); + } + + public static Uri getContentUri(Apk apk) { + return getContentUri(apk.id, apk.vercode); } public static Uri getContentUri(String id, int versionCode) { return getContentUri() .buildUpon() + .appendPath(PATH_APK) .appendPath(Integer.toString(versionCode)) .appendPath(id) .build(); } + public static Uri getContentUri(List apks) { + StringBuilder builder = new StringBuilder(); + for (int i = 0; i < apks.size(); i ++) { + if (i != 0) { + builder.append(','); + } + Apk a = apks.get(i); + builder.append(a.id).append(':').append(a.vercode); + } + return getContentUri().buildUpon() + .appendPath(PATH_APKS) + .appendPath(builder.toString()) + .build(); + } + @Override protected String getTableName() { return DBHelper.TABLE_APK; @@ -257,29 +339,70 @@ public class ApkProvider extends FDroidProvider { } } - private String appendPrimaryKeyToSelection(String selection) { - return (selection == null ? "" : selection + " AND ") + " id = ? and vercode = ?"; + private QuerySelection queryApp(String appId) { + String selection = " id = ? "; + String[] args = new String[] { appId }; + return new QuerySelection(selection, args); } - private String[] appendPrimaryKeyToArgs(Uri uri, String[] selectionArgs) { - List args = new ArrayList(selectionArgs.length + 2); - for (String arg : args) { - args.add(arg); + private QuerySelection querySingle(Uri uri) { + String selection = " vercode = ? and id = ? "; + String[] args = new String[] { + // First (0th) path segment is the word "apk", + // and we are not interested in it. + uri.getPathSegments().get(1), + uri.getPathSegments().get(2) + }; + return new QuerySelection(selection, args); + } + + private QuerySelection queryRepo(long repoId) { + String selection = " repo = ? "; + String[] args = new String[]{ Long.toString(repoId) }; + return new QuerySelection(selection, args); + } + + private QuerySelection queryApks(String apkKeys) { + String[] apkDetails = apkKeys.split(","); + String[] args = new String[apkDetails.length * 2]; + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < apkDetails.length; i ++) { + String[] parts = apkDetails[i].split(":"); + String id = parts[0]; + String verCode = parts[1]; + args[i * 2] = id; + args[i * 2 + 1] = verCode; + if (i != 0) { + sb.append(" OR "); + } + sb.append(" ( id = ? AND vercode = ? ) "); } - args.addAll(uri.getPathSegments()); - return (String[])args.toArray(); + return new QuerySelection(sb.toString(), args); } @Override public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) { + QuerySelection query = new QuerySelection(selection, selectionArgs); + switch (matcher.match(uri)) { case CODE_LIST: break; case CODE_SINGLE: - selection = appendPrimaryKeyToSelection(selection); - selectionArgs = appendPrimaryKeyToArgs(uri, selectionArgs); + query = query.add(querySingle(uri)); + break; + + case CODE_APP: + query = query.add(queryApp(uri.getLastPathSegment())); + break; + + case CODE_APKS: + query = query.add(queryApks(uri.getLastPathSegment())); + break; + + case CODE_REPO: + query = query.add(queryRepo(Long.parseLong(uri.getLastPathSegment()))); break; default: @@ -287,14 +410,14 @@ public class ApkProvider extends FDroidProvider { throw new UnsupportedOperationException("Invalid URI for apk content provider: " + uri); } - QueryBuilder query = new QueryBuilder(); + QueryBuilder queryBuilder = new QueryBuilder(); for (String field : projection) { - query.addField(field); + queryBuilder.addField(field); } - query.addSelection(selection); - query.addOrderBy(sortOrder); + queryBuilder.addSelection(query.getSelection()); + queryBuilder.addOrderBy(sortOrder); - Cursor cursor = read().rawQuery(query.toString(), selectionArgs); + Cursor cursor = read().rawQuery(queryBuilder.toString(), query.getArgs()); cursor.setNotificationUri(getContext().getContentResolver(), uri); return cursor; } @@ -313,10 +436,11 @@ public class ApkProvider extends FDroidProvider { @Override public Uri insert(Uri uri, ContentValues values) { - removeRepoFields(values); long id = write().insertOrThrow(getTableName(), null, values); - getContext().getContentResolver().notifyChange(uri, null); + if (!isApplyingBatch()) { + getContext().getContentResolver().notifyChange(uri, null); + } return getContentUri( values.getAsString(DataColumns.APK_ID), values.getAsInteger(DataColumns.VERSION_CODE)); @@ -326,14 +450,19 @@ public class ApkProvider extends FDroidProvider { @Override public int delete(Uri uri, String where, String[] whereArgs) { + QuerySelection query = new QuerySelection(where, whereArgs); + switch (matcher.match(uri)) { case CODE_LIST: // Don't support deleting of multiple apks yet. return 0; + case CODE_REPO: + query = query.add(queryRepo(Long.parseLong(uri.getLastPathSegment()))); + break; + case CODE_SINGLE: - where = appendPrimaryKeyToSelection(where); - whereArgs = appendPrimaryKeyToArgs(uri, whereArgs); + query = query.add(querySingle(uri)); break; default: @@ -341,7 +470,7 @@ public class ApkProvider extends FDroidProvider { throw new UnsupportedOperationException("Invalid URI for apk content provider: " + uri); } - int rowsAffected = write().delete(getTableName(), where, whereArgs); + int rowsAffected = write().delete(getTableName(), query.getSelection(), query.getArgs()); getContext().getContentResolver().notifyChange(uri, null); return rowsAffected; @@ -350,19 +479,22 @@ public class ApkProvider extends FDroidProvider { @Override public int update(Uri uri, ContentValues values, String where, String[] whereArgs) { + QuerySelection query = new QuerySelection(where, whereArgs); + switch (matcher.match(uri)) { case CODE_LIST: return 0; case CODE_SINGLE: - where = appendPrimaryKeyToSelection(where); - whereArgs = appendPrimaryKeyToArgs(uri, whereArgs); + query = query.add(querySingle(uri)); break; } removeRepoFields(values); - int numRows = write().update(getTableName(), values, where, whereArgs); - getContext().getContentResolver().notifyChange(uri, null); + int numRows = write().update(getTableName(), values, query.getSelection(), query.getArgs()); + if (!isApplyingBatch()) { + getContext().getContentResolver().notifyChange(uri, null); + } return numRows; } diff --git a/src/org/fdroid/fdroid/data/App.java b/src/org/fdroid/fdroid/data/App.java new file mode 100644 index 000000000..cec9b452f --- /dev/null +++ b/src/org/fdroid/fdroid/data/App.java @@ -0,0 +1,230 @@ +package org.fdroid.fdroid.data; + +import android.content.ContentValues; +import android.content.Context; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageInfo; +import android.database.Cursor; +import org.fdroid.fdroid.AppFilter; +import org.fdroid.fdroid.Utils; + +import java.util.Date; +import java.util.Map; + +public class App extends ValueObject implements Comparable { + + // True if compatible with the device (i.e. if at least one apk is) + public boolean compatible; + + public String id = "unknown"; + public String name = "Unknown"; + public String summary = "Unknown application"; + public String icon; + + public String description; + + public String license = "Unknown"; + + public String webURL; + + public String trackerURL; + + public String sourceURL; + + public String donateURL; + + public String bitcoinAddr; + + public String litecoinAddr; + + public String dogecoinAddr; + + public String flattrID; + + public String curVersion; + public int curVercode; + public Date added; + public Date lastUpdated; + + // List of categories (as defined in the metadata + // documentation) or null if there aren't any. + public Utils.CommaSeparatedList categories; + + // List of anti-features (as defined in the metadata + // documentation) or null if there aren't any. + public Utils.CommaSeparatedList antiFeatures; + + // List of special requirements (such as root privileges) or + // null if there aren't any. + public Utils.CommaSeparatedList requirements; + + // True if all updates for this app are to be ignored + public boolean ignoreAllUpdates; + + // True if the current update for this app is to be ignored + public int ignoreThisUpdate; + + // Used internally for tracking during repo updates. + public boolean updated; + + public String iconUrl; + + @Override + public int compareTo(App app) { + return name.compareToIgnoreCase(app.name); + } + + public App() { + + } + + public App(Cursor cursor) { + + checkCursorPosition(cursor); + + for(int i = 0; i < cursor.getColumnCount(); i ++ ) { + String column = cursor.getColumnName(i); + if (column.equals(AppProvider.DataColumns.IS_COMPATIBLE)) { + compatible = cursor.getInt(i) == 1; + } else if (column.equals(AppProvider.DataColumns.APP_ID)) { + id = cursor.getString(i); + } else if (column.equals(AppProvider.DataColumns.NAME)) { + name = cursor.getString(i); + } else if (column.equals(AppProvider.DataColumns.SUMMARY)) { + summary = cursor.getString(i); + } else if (column.equals(AppProvider.DataColumns.ICON)) { + icon = cursor.getString(i); + } else if (column.equals(AppProvider.DataColumns.DESCRIPTION)) { + description = cursor.getString(i); + } else if (column.equals(AppProvider.DataColumns.LICENSE)) { + license = cursor.getString(i); + } else if (column.equals(AppProvider.DataColumns.WEB_URL)) { + webURL = cursor.getString(i); + } else if (column.equals(AppProvider.DataColumns.TRACKER_URL)) { + trackerURL = cursor.getString(i); + } else if (column.equals(AppProvider.DataColumns.SOURCE_URL)) { + sourceURL = cursor.getString(i); + } else if (column.equals(AppProvider.DataColumns.DONATE_URL)) { + donateURL = cursor.getString(i); + } else if (column.equals(AppProvider.DataColumns.BITCOIN_ADDR)) { + bitcoinAddr = cursor.getString(i); + } else if (column.equals(AppProvider.DataColumns.LITECOIN_ADDR)) { + litecoinAddr = cursor.getString(i); + } else if (column.equals(AppProvider.DataColumns.DOGECOIN_ADDR)) { + dogecoinAddr = cursor.getString(i); + } else if (column.equals(AppProvider.DataColumns.FLATTR_ID)) { + flattrID = cursor.getString(i); + } else if (column.equals(AppProvider.DataColumns.CURRENT_VERSION)) { + curVersion = cursor.getString(i); + } else if (column.equals(AppProvider.DataColumns.CURRENT_VERSION_CODE)) { + curVercode = cursor.getInt(i); + } else if (column.equals(AppProvider.DataColumns.ADDED)) { + added = ValueObject.toDate(cursor.getString(i)); + } else if (column.equals(AppProvider.DataColumns.LAST_UPDATED)) { + lastUpdated = ValueObject.toDate(cursor.getString(i)); + } else if (column.equals(AppProvider.DataColumns.CATEGORIES)) { + categories = Utils.CommaSeparatedList.make(cursor.getString(i)); + } else if (column.equals(AppProvider.DataColumns.ANTI_FEATURES)) { + antiFeatures = Utils.CommaSeparatedList.make(cursor.getString(i)); + } else if (column.equals(AppProvider.DataColumns.REQUIREMENTS)) { + requirements = Utils.CommaSeparatedList.make(cursor.getString(i)); + } else if (column.equals(AppProvider.DataColumns.IGNORE_ALLUPDATES)) { + ignoreAllUpdates = cursor.getInt(i) == 1; + } else if (column.equals(AppProvider.DataColumns.IGNORE_THISUPDATE)) { + ignoreThisUpdate = cursor.getInt(i); + } else if (column.equals(AppProvider.DataColumns.ICON_URL)) { + iconUrl = cursor.getString(i); + } + } + } + + public ContentValues toContentValues() { + + ContentValues values = new ContentValues(); + values.put(AppProvider.DataColumns.APP_ID, id); + values.put(AppProvider.DataColumns.NAME, name); + values.put(AppProvider.DataColumns.SUMMARY, summary); + values.put(AppProvider.DataColumns.ICON, icon); + values.put(AppProvider.DataColumns.ICON_URL, iconUrl); + values.put(AppProvider.DataColumns.DESCRIPTION, description); + values.put(AppProvider.DataColumns.LICENSE, license); + values.put(AppProvider.DataColumns.WEB_URL, webURL); + values.put(AppProvider.DataColumns.TRACKER_URL, trackerURL); + values.put(AppProvider.DataColumns.SOURCE_URL, sourceURL); + values.put(AppProvider.DataColumns.DONATE_URL, donateURL); + values.put(AppProvider.DataColumns.BITCOIN_ADDR, bitcoinAddr); + values.put(AppProvider.DataColumns.LITECOIN_ADDR, litecoinAddr); + values.put(AppProvider.DataColumns.DOGECOIN_ADDR, dogecoinAddr); + values.put(AppProvider.DataColumns.FLATTR_ID, flattrID); + values.put(AppProvider.DataColumns.ADDED, added == null ? "" : Utils.DATE_FORMAT.format(added)); + values.put(AppProvider.DataColumns.LAST_UPDATED, added == null ? "" : Utils.DATE_FORMAT.format(lastUpdated)); + values.put(AppProvider.DataColumns.CURRENT_VERSION, curVersion); + values.put(AppProvider.DataColumns.CURRENT_VERSION_CODE, curVercode); + values.put(AppProvider.DataColumns.CATEGORIES, Utils.CommaSeparatedList.str(categories)); + values.put(AppProvider.DataColumns.ANTI_FEATURES, Utils.CommaSeparatedList.str(antiFeatures)); + values.put(AppProvider.DataColumns.REQUIREMENTS, Utils.CommaSeparatedList.str(requirements)); + values.put(AppProvider.DataColumns.IS_COMPATIBLE, compatible ? 1 : 0); + values.put(AppProvider.DataColumns.IGNORE_ALLUPDATES, ignoreAllUpdates ? 1 : 0); + values.put(AppProvider.DataColumns.IGNORE_THISUPDATE, ignoreThisUpdate); + values.put(AppProvider.DataColumns.ICON_URL, iconUrl); + + 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; + } + + /** + * True if there are new versions (apks) available + */ + public boolean hasUpdates(Context context) { + boolean updates = false; + if (curVercode > 0) { + int installedVerCode = getInstalledVerCode(context); + updates = (installedVerCode > 0 && installedVerCode < curVercode); + } + 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); + boolean wantsUpdate = !ignoreAllUpdates && ignoreThisUpdate < curVercode; + return canUpdate && wantsUpdate && !isFiltered(); + } + + // Whether the app is filtered or not based on AntiFeatures and root + // permission (set in the Settings page) + public boolean isFiltered() { + return new AppFilter().filter(this); + } +} diff --git a/src/org/fdroid/fdroid/data/AppProvider.java b/src/org/fdroid/fdroid/data/AppProvider.java new file mode 100644 index 000000000..e09b2c5a7 --- /dev/null +++ b/src/org/fdroid/fdroid/data/AppProvider.java @@ -0,0 +1,481 @@ +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; +import org.fdroid.fdroid.Preferences; +import org.fdroid.fdroid.R; +import org.fdroid.fdroid.Utils; + +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 { + + private Helper() {} + + public static List all(ContentResolver resolver) { + return all(resolver, DataColumns.ALL); + } + + public static List all(ContentResolver resolver, String[] projection) { + Uri uri = AppProvider.getContentUri(); + Cursor cursor = resolver.query(uri, projection, null, null, null); + return cursorToList(cursor); + } + + private static List cursorToList(Cursor cursor) { + List apps = new ArrayList(); + if (cursor != null) { + cursor.moveToFirst(); + while (!cursor.isAfterLast()) { + apps.add(new App(cursor)); + cursor.moveToNext(); + } + cursor.close(); + } + return apps; + } + + public static String getCategoryAll(Context context) { + return context.getString(R.string.category_all); + } + + public static String getCategoryWhatsNew(Context context) { + return context.getString(R.string.category_whatsnew); + } + + public static String getCategoryRecentlyUpdated(Context context) { + return context.getString(R.string.category_recentlyupdated); + } + + public static List categories(Context context) { + ContentResolver resolver = context.getContentResolver(); + Uri uri = getContentUri(); + String[] projection = { "DISTINCT " + DataColumns.CATEGORIES }; + Cursor cursor = resolver.query(uri, projection, null, null, null ); + Set categorySet = new HashSet(); + if (cursor != null) { + cursor.moveToFirst(); + while (!cursor.isAfterLast()) { + for( String s : Utils.CommaSeparatedList.make(cursor.getString(0))) { + categorySet.add(s); + } + cursor.moveToNext(); + } + } + List categories = new ArrayList(categorySet); + Collections.sort(categories); + + // Populate the category list with the real categories, and the + // locally generated meta-categories for "All", "What's New" and + // "Recently Updated"... + categories.add(0, getCategoryRecentlyUpdated(context)); + categories.add(0, getCategoryWhatsNew(context)); + categories.add(0, getCategoryAll(context)); + + return categories; + } + + public static App findById(ContentResolver resolver, String appId) { + return findById(resolver, appId, DataColumns.ALL); + } + + public static App findById(ContentResolver resolver, String appId, + String[] projection) { + Uri uri = getContentUri(appId); + Cursor cursor = resolver.query(uri, projection, null, null, null); + if (cursor != null && cursor.getCount() > 0) { + cursor.moveToFirst(); + return new App(cursor); + } else { + return null; + } + } + + public static void deleteAppsWithNoApks(ContentResolver resolver) { + } + } + + public interface DataColumns { + + public static final String _ID = "rowid as _id"; + public static final String _COUNT = "_count"; + public static final String IS_COMPATIBLE = "compatible"; + public static final String APP_ID = "id"; + public static final String NAME = "name"; + public static final String SUMMARY = "summary"; + public static final String ICON = "icon"; + public static final String DESCRIPTION = "description"; + public static final String LICENSE = "license"; + public static final String WEB_URL = "webURL"; + public static final String TRACKER_URL = "trackerURL"; + public static final String SOURCE_URL = "sourceURL"; + public static final String DONATE_URL = "donateURL"; + public static final String BITCOIN_ADDR = "bitcoinAddr"; + public static final String LITECOIN_ADDR = "litecoinAddr"; + public static final String DOGECOIN_ADDR = "dogecoinAddr"; + public static final String FLATTR_ID = "flattrID"; + public static final String CURRENT_VERSION = "curVersion"; + public static final String CURRENT_VERSION_CODE = "curVercode"; + public static final String CURRENT_APK = null; + public static final String ADDED = "added"; + public static final String LAST_UPDATED = "lastUpdated"; + public static final String INSTALLED_VERSION = null; + public static final String INSTALLED_VERCODE = null; + public static final String USER_INSTALLED = null; + public static final String CATEGORIES = "categories"; + public static final String ANTI_FEATURES = "antiFeatures"; + public static final String REQUIREMENTS = "requirements"; + public static final String FILTERED = null; + public static final String HAS_UPDATES = null; + public static final String TO_UPDATE = null; + public static final String IGNORE_ALLUPDATES = "ignoreAllUpdates"; + public static final String IGNORE_THISUPDATE = "ignoreThisUpdate"; + public static final String ICON_URL = "iconUrl"; + public static final String UPDATED = null; + public static final String APKS = null; + + public static String[] ALL = { + IS_COMPATIBLE, APP_ID, NAME, SUMMARY, ICON, DESCRIPTION, + LICENSE, WEB_URL, TRACKER_URL, SOURCE_URL, DONATE_URL, + BITCOIN_ADDR, LITECOIN_ADDR, DOGECOIN_ADDR, FLATTR_ID, + CURRENT_VERSION, CURRENT_VERSION_CODE, ADDED, LAST_UPDATED, + CATEGORIES, ANTI_FEATURES, REQUIREMENTS, IGNORE_ALLUPDATES, + IGNORE_THISUPDATE, ICON_URL + }; + } + + private static final String PROVIDER_NAME = "AppProvider"; + + private static final UriMatcher matcher = new UriMatcher(-1); + + private static final String PATH_INSTALLED = "installed"; + private static final String PATH_CAN_UPDATE = "canUpdate"; + private static final String PATH_SEARCH = "search"; + private static final String PATH_NO_APKS = "noApks"; + private static final String PATH_APPS = "apps"; + private static final String PATH_RECENTLY_UPDATED = "recentlyUpdated"; + private static final String PATH_NEWLY_ADDED = "newlyAdded"; + private static final String PATH_CATEGORY = "category"; + + private static final int CAN_UPDATE = CODE_SINGLE + 1; + private static final int INSTALLED = CAN_UPDATE + 1; + private static final int SEARCH = INSTALLED + 1; + private static final int NO_APKS = SEARCH + 1; + private static final int APPS = NO_APKS + 1; + private static final int RECENTLY_UPDATED = APPS + 1; + private static final int NEWLY_ADDED = RECENTLY_UPDATED + 1; + private static final int CATEGORY = NEWLY_ADDED + 1; + + static { + matcher.addURI(getAuthority(), null, CODE_LIST); + matcher.addURI(getAuthority(), PATH_RECENTLY_UPDATED, RECENTLY_UPDATED); + matcher.addURI(getAuthority(), PATH_NEWLY_ADDED, NEWLY_ADDED); + matcher.addURI(getAuthority(), PATH_CATEGORY + "/*", CATEGORY); + matcher.addURI(getAuthority(), PATH_SEARCH + "/*", SEARCH); + matcher.addURI(getAuthority(), PATH_CAN_UPDATE, CAN_UPDATE); + matcher.addURI(getAuthority(), PATH_INSTALLED, INSTALLED); + matcher.addURI(getAuthority(), PATH_NO_APKS, NO_APKS); + matcher.addURI(getAuthority(), PATH_APPS + "/*", APPS); + matcher.addURI(getAuthority(), "*", CODE_SINGLE); + } + + public static Uri getContentUri() { + return Uri.parse("content://" + getAuthority()); + } + + public static Uri getRecentlyUpdatedUri() { + return Uri.withAppendedPath(getContentUri(), PATH_RECENTLY_UPDATED); + } + + public static Uri getNewlyAddedUri() { + return Uri.withAppendedPath(getContentUri(), PATH_NEWLY_ADDED); + } + + public static Uri getCategoryUri(String category) { + return getContentUri().buildUpon() + .appendPath(PATH_CATEGORY) + .appendPath(category) + .build(); + } + + public static Uri getNoApksUri() { + return Uri.withAppendedPath(getContentUri(), PATH_NO_APKS); + } + + public static Uri getInstalledUri() { + return Uri.withAppendedPath(getContentUri(), PATH_INSTALLED); + } + + public static Uri getCanUpdateUri() { + return Uri.withAppendedPath(getContentUri(), PATH_CAN_UPDATE); + } + + public static Uri getContentUri(List apps) { + StringBuilder builder = new StringBuilder(); + for (int i = 0; i < apps.size(); i ++) { + if (i != 0) { + builder.append(','); + } + builder.append(apps.get(i).id); + } + return getContentUri().buildUpon() + .appendPath(PATH_APPS) + .appendPath(builder.toString()) + .build(); + } + + public static Uri getContentUri(App app) { + return getContentUri(app.id); + } + + public static Uri getContentUri(String appId) { + return Uri.withAppendedPath(getContentUri(), appId); + } + + public static Uri getSearchUri(String query) { + return getContentUri().buildUpon() + .appendPath(PATH_SEARCH) + .appendPath(query) + .build(); + } + + @Override + protected String getTableName() { + return DBHelper.TABLE_APP; + } + + @Override + protected String getProviderName() { + return "AppProvider"; + } + + public static String getAuthority() { + return AUTHORITY + "." + PROVIDER_NAME; + } + + protected UriMatcher getMatcher() { + return matcher; + } + + private QuerySelection queryCanUpdate() { + Map installedApps = Utils.getInstalledApps(getContext()); + + String ignoreCurrent = " ignoreThisUpdate != curVercode "; + String ignoreAll = " 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 ( ") + .append(AppProvider.DataColumns.APP_ID) + .append(" = ? AND ") + .append(DataColumns.CURRENT_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); + } + + 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 ") + .append(AppProvider.DataColumns.APP_ID) + .append(" = ? "); + selectionArgs[i] = entry.getKey(); + i ++; + } + where.append(" ) "); + + return new QuerySelection(where.toString(), selectionArgs); + } + + private QuerySelection querySearch(String keywords) { + keywords = "%" + keywords + "%"; + String selection = + "id like ? OR " + + "name like ? OR " + + "summary like ? OR " + + "description like ? "; + String[] args = new String[] { keywords, keywords, keywords, keywords}; + return new QuerySelection(selection, args); + } + + private QuerySelection queryNewlyAdded() { + String selection = "added > ?"; + String[] args = new String[] { + Utils.DATE_FORMAT.format(Preferences.get().calcMaxHistory()) + }; + return new QuerySelection(selection, args); + } + + private QuerySelection queryRecentlyUpdated() { + String selection = "added != lastUpdated AND lastUpdated > ?"; + String[] args = new String[] { + Utils.DATE_FORMAT.format(Preferences.get().calcMaxHistory()) + }; + return new QuerySelection(selection, args); + } + + private QuerySelection queryCategory(String category) { + // TODO: In the future, add a new table for categories, + // so we can join onto it. + String selection = + " categories = ? OR " + // Only category e.g. "internet" + " categories LIKE ? OR " + // First category e.g. "internet,%" + " categories LIKE ? OR " + // Last category e.g. "%,internet" + " categories LIKE ? "; // One of many categories e.g. "%,internet,%" + String[] args = new String[] { + category, + category + ",%", + "%," + category, + "%," + category + ",%", + }; + return new QuerySelection(selection, args); + } + + private QuerySelection queryNoApks() { + String selection = "(SELECT COUNT(*) FROM fdroid_apk WHERE fdroid_apk.id = fdroid_app.id) = 0"; + return new QuerySelection(selection); + } + + private QuerySelection queryApps(String appIds) { + String[] args = appIds.split(","); + String selection = "id IN (" + generateQuestionMarksForInClause(args.length) + ")"; + return new QuerySelection(selection, args); + } + + @Override + public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) { + QuerySelection query = new QuerySelection(selection, selectionArgs); + switch (matcher.match(uri)) { + case CODE_LIST: + break; + + case CODE_SINGLE: + query = query.add( + DataColumns.APP_ID + " = ?", + new String[] { uri.getLastPathSegment() } ); + break; + + case CAN_UPDATE: + query = query.add(queryCanUpdate()); + break; + + case INSTALLED: + query = query.add(queryInstalled()); + break; + + case SEARCH: + query = query.add(querySearch(uri.getLastPathSegment())); + break; + + case NO_APKS: + query = query.add(queryNoApks()); + break; + + case APPS: + query = query.add(queryApps(uri.getLastPathSegment())); + break; + + case CATEGORY: + query = query.add(queryCategory(uri.getLastPathSegment())); + break; + + case RECENTLY_UPDATED: + sortOrder = DataColumns.LAST_UPDATED + " DESC"; + query = query.add(queryRecentlyUpdated()); + break; + + case NEWLY_ADDED: + sortOrder = DataColumns.ADDED + " DESC"; + query = query.add(queryNewlyAdded()); + break; + + default: + Log.e("FDroid", "Invalid URI for app content provider: " + uri); + throw new UnsupportedOperationException("Invalid URI for app content provider: " + uri); + } + + for (String field : projection) { + if (field.equals(DataColumns._COUNT)) { + projection = new String[] { "COUNT(*) AS " + DataColumns._COUNT }; + break; + } + } + + Cursor cursor = read().query(getTableName(), projection, query.getSelection(), + query.getArgs(), null, null, sortOrder); + cursor.setNotificationUri(getContext().getContentResolver(), uri); + return cursor; + } + + @Override + public int delete(Uri uri, String where, String[] whereArgs) { + + QuerySelection query = new QuerySelection(where, whereArgs); + switch (matcher.match(uri)) { + + case NO_APKS: + query = query.add(queryNoApks()); + break; + + default: + throw new UnsupportedOperationException("Can't delete yet"); + + } + + int count = write().delete(getTableName(), query.getSelection(), query.getArgs()); + getContext().getContentResolver().notifyChange(uri, null); + return count; + } + + @Override + public Uri insert(Uri uri, ContentValues values) { + long id = write().insertOrThrow(getTableName(), null, values); + if (!isApplyingBatch()) { + getContext().getContentResolver().notifyChange(uri, null); + } + return getContentUri(values.getAsString(DataColumns.APP_ID)); + } + + @Override + public int update(Uri uri, ContentValues values, String where, String[] whereArgs) { + QuerySelection query = new QuerySelection(where, whereArgs); + switch (matcher.match(uri)) { + + case CODE_SINGLE: + query = query.add(new QuerySelection("id = ?", new String[] { uri.getLastPathSegment()})); + break; + + default: + throw new UnsupportedOperationException("Update not supported for '" + uri + "'."); + + } + int count = write().update(getTableName(), values, query.getSelection(), query.getArgs()); + if (!isApplyingBatch()) { + getContext().getContentResolver().notifyChange(uri, null); + } + return count; + } + +} diff --git a/src/org/fdroid/fdroid/data/DBHelper.java b/src/org/fdroid/fdroid/data/DBHelper.java index 58807514c..df872d2c9 100644 --- a/src/org/fdroid/fdroid/data/DBHelper.java +++ b/src/org/fdroid/fdroid/data/DBHelper.java @@ -2,12 +2,13 @@ package org.fdroid.fdroid.data; import android.content.ContentValues; import android.content.Context; +import android.content.SharedPreferences; import android.database.Cursor; import android.database.sqlite.SQLiteDatabase; import android.database.sqlite.SQLiteOpenHelper; +import android.preference.PreferenceManager; import android.util.Log; -import org.fdroid.fdroid.DB; -import org.fdroid.fdroid.R; +import org.fdroid.fdroid.*; import java.util.ArrayList; import java.util.List; @@ -23,6 +24,7 @@ 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, " @@ -50,26 +52,41 @@ public class DBHelper extends SQLiteOpenHelper { + "hashType string, " + "added string, " + "compatible int not null, " + + "incompatibleReasons text, " + "primary key(id, vercode)" + ");"; - private static final String CREATE_TABLE_APP = "create table " + DB.TABLE_APP - + " ( " + "id text not null, " + "name text not null, " - + "summary text not null, " + "icon text, " - + "description text not null, " + "license text not null, " - + "webURL text, " + "trackerURL text, " + "sourceURL text, " - + "curVersion text," + "curVercode integer," - + "antiFeatures string," + "donateURL string," - + "bitcoinAddr string," + "litecoinAddr string," + public static final String TABLE_APP = "fdroid_app"; + private static final String CREATE_TABLE_APP = "CREATE TABLE " + TABLE_APP + + " ( " + + "id text not null, " + + "name text not null, " + + "summary text not null, " + + "icon text, " + + "description text not null, " + + "license text not null, " + + "webURL text, " + + "trackerURL text, " + + "sourceURL text, " + + "curVersion text," + + "curVercode integer," + + "antiFeatures string," + + "donateURL string," + + "bitcoinAddr string," + + "litecoinAddr string," + "dogecoinAddr string," - + "flattrID string," + "requirements string," - + "categories string," + "added string," - + "lastUpdated string," + "compatible int not null," + + "flattrID string," + + "requirements string," + + "categories string," + + "added string," + + "lastUpdated string," + + "compatible int not null," + "ignoreAllUpdates int not null," + "ignoreThisUpdate int not null," + + "iconUrl text, " + "primary key(id));"; - private static final int DB_VERSION = 37; + private static final int DB_VERSION = 39; private Context context; @@ -80,18 +97,20 @@ public class DBHelper extends SQLiteOpenHelper { private void populateRepoNames(SQLiteDatabase db, int oldVersion) { if (oldVersion < 37) { + Log.i("FDroid", "Populating repo names from the url"); String[] columns = { "address", "_id" }; Cursor cursor = db.query(TABLE_REPO, columns, "name IS NULL OR name = ''", null, null, null, null); - cursor.moveToFirst(); if (cursor.getCount() > 0) { cursor.moveToFirst(); while (!cursor.isAfterLast()) { String address = cursor.getString(0); long id = cursor.getInt(1); ContentValues values = new ContentValues(1); - values.put("name", Repo.addressToName(address)); + String name = Repo.addressToName(address); + values.put("name", name); String[] args = { Long.toString( id ) }; + Log.i("FDroid", "Setting repo name to '" + name + "' for repo " + address); db.update(TABLE_REPO, values, "_id = ?", args); cursor.moveToNext(); } @@ -103,7 +122,6 @@ public class DBHelper extends SQLiteOpenHelper { if (oldVersion < 36) { Log.d("FDroid", "Renaming " + TABLE_REPO + ".id to _id"); - db.beginTransaction(); try { @@ -166,7 +184,7 @@ public class DBHelper extends SQLiteOpenHelper { context.getString(R.string.default_repo_description)); values.put("version", 0); String pubkey = context.getString(R.string.default_repo_pubkey); - String fingerprint = DB.calcFingerprint(pubkey); + String fingerprint = Utils.calcFingerprint(pubkey); values.put("pubkey", pubkey); values.put("fingerprint", fingerprint); values.put("maxage", 0); @@ -296,7 +314,7 @@ public class DBHelper extends SQLiteOpenHelper { c.close(); for (Repo repo : oldrepos) { ContentValues values = new ContentValues(); - values.put("fingerprint", DB.calcFingerprint(repo.pubkey)); + values.put("fingerprint", Utils.calcFingerprint(repo.pubkey)); db.update(TABLE_REPO, values, "address = ?", new String[] { repo.address }); } } @@ -316,6 +334,7 @@ public class DBHelper extends SQLiteOpenHelper { private void addLastUpdatedToRepo(SQLiteDatabase db, int oldVersion) { if (oldVersion < 35 && !columnExists(db, TABLE_REPO, "lastUpdated")) { + Log.i("FDroid", "Adding lastUpdated column to " + TABLE_REPO); db.execSQL("Alter table " + TABLE_REPO + " add column lastUpdated string"); } } @@ -323,7 +342,7 @@ public class DBHelper extends SQLiteOpenHelper { private void resetTransient(SQLiteDatabase db) { context.getSharedPreferences("FDroid", Context.MODE_PRIVATE).edit() .putBoolean("triedEmptyUpdate", false).commit(); - db.execSQL("drop table " + DB.TABLE_APP); + db.execSQL("drop table " + TABLE_APP); db.execSQL("drop table " + TABLE_APK); db.execSQL("update " + TABLE_REPO + " set lastetag = NULL"); createAppApk(db); @@ -331,7 +350,7 @@ public class DBHelper extends SQLiteOpenHelper { private static void createAppApk(SQLiteDatabase db) { db.execSQL(CREATE_TABLE_APP); - db.execSQL("create index app_id on " + DB.TABLE_APP + " (id);"); + db.execSQL("create index app_id on " + TABLE_APP + " (id);"); db.execSQL(CREATE_TABLE_APK); db.execSQL("create index apk_vercode on " + TABLE_APK + " (vercode);"); db.execSQL("create index apk_id on " + TABLE_APK + " (id);"); diff --git a/src/org/fdroid/fdroid/data/FDroidProvider.java b/src/org/fdroid/fdroid/data/FDroidProvider.java index cd8308bd2..3e0c4b316 100644 --- a/src/org/fdroid/fdroid/data/FDroidProvider.java +++ b/src/org/fdroid/fdroid/data/FDroidProvider.java @@ -1,11 +1,12 @@ package org.fdroid.fdroid.data; -import android.content.ContentProvider; -import android.content.UriMatcher; +import android.content.*; import android.database.sqlite.SQLiteDatabase; import android.net.Uri; -abstract class FDroidProvider extends ContentProvider { +import java.util.ArrayList; + +public abstract class FDroidProvider extends ContentProvider { public static final String AUTHORITY = "org.fdroid.fdroid.data"; @@ -14,10 +15,39 @@ abstract class FDroidProvider extends ContentProvider { private DBHelper dbHelper; + private boolean isApplyingBatch = false; + abstract protected String getTableName(); abstract protected String 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, + * every single time we do something during many operations. + * Based on http://stackoverflow.com/a/15886915. + * @return + */ + protected final boolean isApplyingBatch() { + return this.isApplyingBatch; + } + + @Override + public ContentProviderResult[] applyBatch(ArrayList operations) + throws OperationApplicationException { + ContentProviderResult[] result = null; + isApplyingBatch = true; + write().beginTransaction(); + try { + result = super.applyBatch(operations); + write().setTransactionSuccessful(); + } finally { + write().endTransaction(); + isApplyingBatch = false; + } + return result; + } + @Override public boolean onCreate() { dbHelper = new DBHelper(getContext()); @@ -55,5 +85,15 @@ abstract class FDroidProvider extends ContentProvider { abstract protected UriMatcher getMatcher(); + protected String generateQuestionMarksForInClause(int num) { + StringBuilder sb = new StringBuilder(num * 2); + for (int i = 0; i < num; i ++) { + if (i != 0) { + sb.append(','); + } + sb.append('?'); + } + return sb.toString(); + } } diff --git a/src/org/fdroid/fdroid/data/QuerySelection.java b/src/org/fdroid/fdroid/data/QuerySelection.java new file mode 100644 index 000000000..7c22283f2 --- /dev/null +++ b/src/org/fdroid/fdroid/data/QuerySelection.java @@ -0,0 +1,79 @@ +package org.fdroid.fdroid.data; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Helper class used by sublasses of ContentProvider to make the constraints + * required for a given content URI (e.g. all apps that belong to a repo) + * easily appendable to the constraints which are passed into, e.g. the query() + * method in the content provider. + */ +public class QuerySelection { + + private final String[] args; + private final String selection; + + public QuerySelection(String selection) { + this.selection = selection; + this.args = null; + } + + public QuerySelection(String selection, String[] args) { + this.args = args; + this.selection = selection; + } + + public QuerySelection(String selection, List args) { + this.args = new String[ args.size() ]; + args.toArray( this.args ); + this.selection = selection; + } + + public String[] getArgs() { + return args; + } + + public String getSelection() { + return selection; + } + + public boolean hasSelection() { + return selection != null && selection.length() > 0; + } + + public boolean hasArgs() { + return args != null && args.length > 0; + } + + public QuerySelection add(String selection, String[] args) { + return add(new QuerySelection(selection, args)); + } + + public QuerySelection add(QuerySelection query) { + String s = null; + if (this.hasSelection() && query.hasSelection()) { + s = " (" + this.selection + ") AND (" + query.getSelection() + ") "; + } else if (this.hasSelection()) { + s = this.selection; + } else if (query.hasSelection() ) { + s = query.selection; + } + + int thisNumArgs = this.hasArgs() ? this.args.length : 0; + int queryNumArgs = query.hasArgs() ? query.args.length : 0; + List a = new ArrayList(thisNumArgs + queryNumArgs); + + if (this.hasArgs()) { + Collections.addAll(a, this.args); + } + + if (query.hasArgs()) { + Collections.addAll(a, query.getArgs()); + } + + return new QuerySelection(s, a); + } + +} diff --git a/src/org/fdroid/fdroid/data/Repo.java b/src/org/fdroid/fdroid/data/Repo.java index 051c634ff..d5bc3bc56 100644 --- a/src/org/fdroid/fdroid/data/Repo.java +++ b/src/org/fdroid/fdroid/data/Repo.java @@ -3,14 +3,16 @@ package org.fdroid.fdroid.data; import android.content.ContentValues; import android.database.Cursor; import android.util.Log; -import org.fdroid.fdroid.DB; +import org.fdroid.fdroid.Utils; import java.net.MalformedURLException; import java.net.URL; import java.text.ParseException; import java.util.Date; -public class Repo extends ValueObject{ +public class Repo extends ValueObject { + + public static final int VERSION_DENSITY_SPECIFIC_ICONS = 11; private long id; @@ -31,6 +33,9 @@ public class Repo extends ValueObject{ } public Repo(Cursor cursor) { + + checkCursorPosition(cursor); + for(int i = 0; i < cursor.getColumnCount(); i ++ ) { String column = cursor.getColumnName(i); if (column.equals(RepoProvider.DataColumns._ID)) { @@ -132,7 +137,7 @@ public class Repo extends ValueObject{ String dateString = values.getAsString(RepoProvider.DataColumns.LAST_UPDATED); if (dateString != null) { try { - lastUpdated = DB.DATE_FORMAT.parse(dateString); + lastUpdated = Utils.DATE_FORMAT.parse(dateString); } catch (ParseException e) { Log.e("FDroid", "Error parsing date " + dateString); } diff --git a/src/org/fdroid/fdroid/data/RepoProvider.java b/src/org/fdroid/fdroid/data/RepoProvider.java index 6466af81f..8f9199f5c 100644 --- a/src/org/fdroid/fdroid/data/RepoProvider.java +++ b/src/org/fdroid/fdroid/data/RepoProvider.java @@ -6,8 +6,8 @@ import android.net.Uri; import android.provider.BaseColumns; import android.text.TextUtils; import android.util.Log; -import org.fdroid.fdroid.DB; import org.fdroid.fdroid.FDroidApp; +import org.fdroid.fdroid.Utils; import java.util.ArrayList; import java.util.List; @@ -15,6 +15,7 @@ import java.util.List; public class RepoProvider extends FDroidProvider { public static final class Helper { + public static final String TAG = "RepoProvider.Helper"; private Helper() {} @@ -105,7 +106,7 @@ public class RepoProvider extends FDroidProvider { */ if (values.containsKey(DataColumns.PUBLIC_KEY)) { String publicKey = values.getAsString(DataColumns.PUBLIC_KEY); - String calcedFingerprint = DB.calcFingerprint(publicKey); + String calcedFingerprint = Utils.calcFingerprint(publicKey); if (values.containsKey(DataColumns.FINGERPRINT)) { String fingerprint = values.getAsString(DataColumns.FINGERPRINT); if (!TextUtils.isEmpty(publicKey)) { @@ -153,15 +154,16 @@ public class RepoProvider extends FDroidProvider { resolver.delete(uri, null, null); } - public static void purgeApps(Repo repo, FDroidApp app) { - // TODO: Once we have content providers for apps and apks, use them - // to do this... - DB db = DB.getDB(); - try { - db.purgeApps(repo, app); - } finally { - DB.releaseDB(); - } + public static void purgeApps(Context context, Repo repo, FDroidApp app) { + Uri apkUri = ApkProvider.getRepoUri(repo.getId()); + int apkCount = context.getContentResolver().delete(apkUri, null, null); + Log.d("FDroid", "Removed " + apkCount + " apks from repo " + repo.name); + + Uri appUri = AppProvider.getNoApksUri(); + int appCount = context.getContentResolver().delete(appUri, null, null); + Log.d("Log", "Removed " + appCount + " apps with no apks."); + + app.invalidateAllApps(); } public static int countAppsForRepo(ContentResolver resolver, diff --git a/src/org/fdroid/fdroid/data/ValueObject.java b/src/org/fdroid/fdroid/data/ValueObject.java index fb936cc77..c17958d86 100644 --- a/src/org/fdroid/fdroid/data/ValueObject.java +++ b/src/org/fdroid/fdroid/data/ValueObject.java @@ -1,18 +1,27 @@ package org.fdroid.fdroid.data; +import android.database.Cursor; import android.util.Log; -import org.fdroid.fdroid.DB; +import org.fdroid.fdroid.Utils; import java.text.ParseException; import java.util.Date; abstract class ValueObject { + protected void checkCursorPosition(Cursor cursor) throws IllegalArgumentException { + if (cursor.getPosition() == -1) { + throw new IllegalArgumentException( + "Cursor position is -1. " + + "Did you forget to moveToFirst() or move() before passing to the value object?"); + } + } + static Date toDate(String string) { Date date = null; if (string != null) { try { - date = DB.DATE_FORMAT.parse(string); + date = Utils.DATE_FORMAT.parse(string); } catch (ParseException e) { Log.e("FDroid", "Error parsing date " + string); } diff --git a/src/org/fdroid/fdroid/updater/RepoUpdater.java b/src/org/fdroid/fdroid/updater/RepoUpdater.java index 0ab99ba3d..b5c17fb07 100644 --- a/src/org/fdroid/fdroid/updater/RepoUpdater.java +++ b/src/org/fdroid/fdroid/updater/RepoUpdater.java @@ -4,10 +4,11 @@ import android.content.ContentValues; import android.content.Context; import android.os.Bundle; import android.util.Log; -import org.fdroid.fdroid.DB; import org.fdroid.fdroid.ProgressListener; import org.fdroid.fdroid.RepoXMLHandler; import org.fdroid.fdroid.Utils; +import org.fdroid.fdroid.data.Apk; +import org.fdroid.fdroid.data.App; import org.fdroid.fdroid.data.Repo; import org.fdroid.fdroid.data.RepoProvider; import org.fdroid.fdroid.net.Downloader; @@ -40,7 +41,8 @@ abstract public class RepoUpdater { protected final Context context; protected final Repo repo; - protected final List apps = new ArrayList(); + private List apps = new ArrayList(); + private List apks = new ArrayList(); protected boolean hasChanged = false; protected ProgressListener progressListener; @@ -57,10 +59,14 @@ abstract public class RepoUpdater { return hasChanged; } - public List getApps() { + public List getApps() { return apps; } + public List getApks() { + return apks; + } + public boolean isInteractive() { return progressListener != null; } @@ -173,7 +179,7 @@ abstract public class RepoUpdater { // Process the index... SAXParser parser = SAXParserFactory.newInstance().newSAXParser(); XMLReader reader = parser.getXMLReader(); - RepoXMLHandler handler = new RepoXMLHandler(repo, apps, progressListener); + RepoXMLHandler handler = new RepoXMLHandler(repo, progressListener); if (isInteractive()) { // Only bother spending the time to count the expected apps @@ -186,6 +192,8 @@ abstract public class RepoUpdater { new BufferedReader(new FileReader(indexFile))); reader.parse(is); + apps = handler.getApps(); + apks = handler.getApks(); updateRepo(handler, downloader.getETag()); } } catch (SAXException e) { @@ -216,7 +224,7 @@ abstract public class RepoUpdater { ContentValues values = new ContentValues(); - values.put(RepoProvider.DataColumns.LAST_UPDATED, DB.DATE_FORMAT.format(new Date())); + values.put(RepoProvider.DataColumns.LAST_UPDATED, Utils.DATE_FORMAT.format(new Date())); if (repo.lastetag == null || !repo.lastetag.equals(etag)) { values.put(RepoProvider.DataColumns.LAST_ETAG, etag); diff --git a/src/org/fdroid/fdroid/updater/SignedRepoUpdater.java b/src/org/fdroid/fdroid/updater/SignedRepoUpdater.java index d8925c304..ce22fd0c3 100644 --- a/src/org/fdroid/fdroid/updater/SignedRepoUpdater.java +++ b/src/org/fdroid/fdroid/updater/SignedRepoUpdater.java @@ -2,7 +2,6 @@ package org.fdroid.fdroid.updater; import android.content.Context; import android.util.Log; -import org.fdroid.fdroid.DB; import org.fdroid.fdroid.Hasher; import org.fdroid.fdroid.R; import org.fdroid.fdroid.Utils; @@ -30,7 +29,7 @@ public class SignedRepoUpdater extends RepoUpdater { boolean match = false; for (Certificate cert : certs) { String certdata = Hasher.hex(cert); - if (repo.pubkey == null && repo.fingerprint.equals(DB.calcFingerprint(cert))) { + if (repo.pubkey == null && repo.fingerprint.equals(Utils.calcFingerprint(cert))) { repo.pubkey = certdata; } if (repo.pubkey != null && repo.pubkey.equals(certdata)) { diff --git a/src/org/fdroid/fdroid/updater/UnsignedRepoUpdater.java b/src/org/fdroid/fdroid/updater/UnsignedRepoUpdater.java index ec9b0712b..2dac81e23 100644 --- a/src/org/fdroid/fdroid/updater/UnsignedRepoUpdater.java +++ b/src/org/fdroid/fdroid/updater/UnsignedRepoUpdater.java @@ -2,9 +2,7 @@ package org.fdroid.fdroid.updater; import android.content.Context; import android.util.Log; -import org.fdroid.fdroid.DB; import org.fdroid.fdroid.data.Repo; -import org.fdroid.fdroid.net.Downloader; import java.io.File; diff --git a/src/org/fdroid/fdroid/views/AppListAdapter.java b/src/org/fdroid/fdroid/views/AppListAdapter.java index d1ad13000..3665ec53e 100644 --- a/src/org/fdroid/fdroid/views/AppListAdapter.java +++ b/src/org/fdroid/fdroid/views/AppListAdapter.java @@ -1,50 +1,60 @@ package org.fdroid.fdroid.views; -import java.util.ArrayList; -import java.util.List; - import android.content.Context; -import android.graphics.Typeface; +import android.content.pm.PackageInfo; +import android.database.Cursor; +import android.graphics.Bitmap; +import android.support.v4.widget.CursorAdapter; +import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; -import android.widget.*; -import android.text.SpannableString; -import android.text.style.StyleSpan; -import android.graphics.Bitmap; - -import org.fdroid.fdroid.DB; -import org.fdroid.fdroid.Preferences; -import org.fdroid.fdroid.R; -import org.fdroid.fdroid.compat.LayoutCompat; - -import com.nostra13.universalimageloader.core.display.FadeInBitmapDisplayer; +import android.widget.ImageView; +import android.widget.RelativeLayout; +import android.widget.TextView; import com.nostra13.universalimageloader.core.DisplayImageOptions; import com.nostra13.universalimageloader.core.ImageLoader; import com.nostra13.universalimageloader.core.assist.ImageScaleType; +import com.nostra13.universalimageloader.core.display.FadeInBitmapDisplayer; +import org.fdroid.fdroid.Preferences; +import org.fdroid.fdroid.R; +import org.fdroid.fdroid.data.App; -abstract public class AppListAdapter extends BaseAdapter { +abstract public class AppListAdapter extends CursorAdapter { - private List items = new ArrayList(); private Context mContext; private LayoutInflater mInflater; private DisplayImageOptions displayImageOptions; - public AppListAdapter(Context context) { + public AppListAdapter(Context context, Cursor c) { + super(context, c); + init(context); + } + + public AppListAdapter(Context context, Cursor c, boolean autoRequery) { + super(context, c, autoRequery); + init(context); + } + + public AppListAdapter(Context context, Cursor c, int flags) { + super(context, c, flags); + init(context); + } + + private void init(Context context) { mContext = context; mInflater = (LayoutInflater) mContext.getSystemService( Context.LAYOUT_INFLATER_SERVICE); - displayImageOptions = new DisplayImageOptions.Builder() - .cacheInMemory(true) - .cacheOnDisc(true) - .imageScaleType(ImageScaleType.NONE) - .resetViewBeforeLoading(true) - .showImageOnLoading(R.drawable.ic_repo_app_default) - .showImageForEmptyUri(R.drawable.ic_repo_app_default) - .displayer(new FadeInBitmapDisplayer(200, true, true, false)) - .bitmapConfig(Bitmap.Config.RGB_565) - .build(); + .cacheInMemory(true) + .cacheOnDisc(true) + .imageScaleType(ImageScaleType.NONE) + .resetViewBeforeLoading(true) + .showImageOnLoading(R.drawable.ic_repo_app_default) + .showImageForEmptyUri(R.drawable.ic_repo_app_default) + .displayer(new FadeInBitmapDisplayer(200, true, true, false)) + .bitmapConfig(Bitmap.Config.RGB_565) + .build(); } @@ -52,33 +62,6 @@ abstract public class AppListAdapter extends BaseAdapter { abstract protected boolean showStatusInstalled(); - public void addItem(DB.App app) { - items.add(app); - } - - public void addItems(List apps) { - items.addAll(apps); - } - - public void clear() { - items.clear(); - } - - @Override - public int getCount() { - return items.size(); - } - - @Override - public Object getItem(int position) { - return items.get(position); - } - - @Override - public long getItemId(int position) { - return position; - } - private static class ViewHolder { TextView name; TextView summary; @@ -88,26 +71,32 @@ abstract public class AppListAdapter extends BaseAdapter { } @Override - public View getView(int position, View convertView, ViewGroup parent) { + public View newView(Context context, Cursor cursor, ViewGroup parent) { + View view = mInflater.inflate(R.layout.applistitem, null); + + ViewHolder holder = new ViewHolder(); + holder.name = (TextView) view.findViewById(R.id.name); + holder.summary = (TextView) view.findViewById(R.id.summary); + holder.status = (TextView) view.findViewById(R.id.status); + holder.license = (TextView) view.findViewById(R.id.license); + holder.icon = (ImageView) view.findViewById(R.id.icon); + view.setTag(holder); + + setupView(context, view, cursor, holder); + + return view; + } + + @Override + public void bindView(View view, Context context, Cursor cursor) { + ViewHolder holder = (ViewHolder)view.getTag(); + setupView(context, view, cursor, holder); + } + + private void setupView(Context context, View view, Cursor cursor, ViewHolder holder) { + final App app = new App(cursor); boolean compact = Preferences.get().hasCompactLayout(); - DB.App app = items.get(position); - ViewHolder holder; - - if (convertView == null) { - convertView = mInflater.inflate(R.layout.applistitem, null); - - holder = new ViewHolder(); - holder.name = (TextView) convertView.findViewById(R.id.name); - holder.summary = (TextView) convertView.findViewById(R.id.summary); - holder.status = (TextView) convertView.findViewById(R.id.status); - holder.license = (TextView) convertView.findViewById(R.id.license); - holder.icon = (ImageView) convertView.findViewById(R.id.icon); - - convertView.setTag(holder); - } else { - holder = (ViewHolder) convertView.getTag(); - } holder.name.setText(app.name); holder.summary.setText(app.summary); @@ -121,47 +110,50 @@ abstract public class AppListAdapter extends BaseAdapter { // Disable it all if it isn't compatible... View[] views = { - convertView, + view, holder.status, holder.summary, holder.license, holder.name }; - for (View view : views) { - view.setEnabled(app.compatible && !app.filtered); + for (View v : views) { + v.setEnabled(app.compatible && !app.isFiltered()); } - - return convertView; } - private String ellipsize(String input, int maxLength) { + private String ellipsize(String input, int maxLength) { if (input == null || input.length() < maxLength+1) { return input; } return input.substring(0, maxLength) + "…"; } - private String getVersionInfo(DB.App app) { + private String getVersionInfo(App app) { - if (app.curApk == null) { + if (app.curVercode <= 0) { return null; } - if (app.installedVersion == null) { - return ellipsize(app.curApk.version, 12); + PackageInfo installedInfo = app.getInstalledInfo(mContext); + + if (installedInfo == null) { + return ellipsize(app.curVersion, 12); } - if (app.toUpdate && showStatusUpdate()) { - return ellipsize(app.installedVersion, 8) + - " → " + ellipsize(app.curApk.version, 8); + String installedVersionString = installedInfo.versionName; + int installedVersionCode = installedInfo.versionCode; + + if (app.canAndWantToUpdate(mContext) && showStatusUpdate()) { + return ellipsize(installedVersionString, 8) + + " → " + ellipsize(app.curVersion, 8); } - if (app.installedVerCode > 0 && showStatusInstalled()) { - return ellipsize(app.installedVersion, 12) + " ✔"; + if (installedVersionCode > 0 && showStatusInstalled()) { + return ellipsize(installedVersionString, 12) + " ✔"; } - return app.installedVersion; + return installedVersionString; } private void layoutIcon(ImageView icon, boolean compact) { diff --git a/src/org/fdroid/fdroid/views/AppListFragmentPageAdapter.java b/src/org/fdroid/fdroid/views/AppListFragmentPageAdapter.java index 2df2e7133..b05c0e940 100644 --- a/src/org/fdroid/fdroid/views/AppListFragmentPageAdapter.java +++ b/src/org/fdroid/fdroid/views/AppListFragmentPageAdapter.java @@ -1,10 +1,13 @@ package org.fdroid.fdroid.views; +import android.database.Cursor; +import android.net.Uri; import android.support.v4.app.Fragment; import android.support.v4.app.FragmentPagerAdapter; import org.fdroid.fdroid.FDroid; import org.fdroid.fdroid.R; +import org.fdroid.fdroid.data.AppProvider; import org.fdroid.fdroid.views.fragments.AvailableAppsFragment; import org.fdroid.fdroid.views.fragments.CanUpdateAppsFragment; import org.fdroid.fdroid.views.fragments.InstalledAppsFragment; @@ -19,7 +22,20 @@ public class AppListFragmentPageAdapter extends FragmentPagerAdapter { public AppListFragmentPageAdapter(FDroid parent) { super(parent.getSupportFragmentManager()); - this.parent = parent; + this.parent = parent; + } + + private String getUpdateTabTitle() { + Uri uri = AppProvider.getCanUpdateUri(); + String[] projection = new String[] { AppProvider.DataColumns._COUNT }; + Cursor cursor = parent.getContentResolver().query(uri, projection, null, null, null); + String suffix = ""; + if (cursor != null && cursor.getCount() == 1) { + cursor.moveToFirst(); + int count = cursor.getInt(0); + suffix = " (" + count + ")"; + } + return parent.getString(R.string.tab_updates) + suffix; } @Override @@ -46,8 +62,7 @@ public class AppListFragmentPageAdapter extends FragmentPagerAdapter { case 1: return parent.getString(R.string.inst); case 2: - return parent.getString(R.string.tab_updates) + " (" - + parent.getManager().getCanUpdateAdapter().getCount() + ")"; + return getUpdateTabTitle(); default: return ""; } diff --git a/src/org/fdroid/fdroid/views/AvailableAppListAdapter.java b/src/org/fdroid/fdroid/views/AvailableAppListAdapter.java index 2b74b2d88..0a1b8e5f8 100644 --- a/src/org/fdroid/fdroid/views/AvailableAppListAdapter.java +++ b/src/org/fdroid/fdroid/views/AvailableAppListAdapter.java @@ -1,10 +1,20 @@ package org.fdroid.fdroid.views; import android.content.Context; +import android.database.Cursor; public class AvailableAppListAdapter extends AppListAdapter { - public AvailableAppListAdapter(Context context) { - super(context); + + public AvailableAppListAdapter(Context context, Cursor c) { + super(context, c); + } + + public AvailableAppListAdapter(Context context, Cursor c, boolean autoRequery) { + super(context, c, autoRequery); + } + + public AvailableAppListAdapter(Context context, Cursor c, int flags) { + super(context, c, flags); } @Override diff --git a/src/org/fdroid/fdroid/views/CanUpdateAppListAdapter.java b/src/org/fdroid/fdroid/views/CanUpdateAppListAdapter.java index c3dcd91ae..5d5034e0c 100644 --- a/src/org/fdroid/fdroid/views/CanUpdateAppListAdapter.java +++ b/src/org/fdroid/fdroid/views/CanUpdateAppListAdapter.java @@ -1,10 +1,20 @@ package org.fdroid.fdroid.views; import android.content.Context; +import android.database.Cursor; public class CanUpdateAppListAdapter extends AppListAdapter { - public CanUpdateAppListAdapter(Context context) { - super(context); + + public CanUpdateAppListAdapter(Context context, Cursor c) { + super(context, c); + } + + public CanUpdateAppListAdapter(Context context, Cursor c, boolean autoRequery) { + super(context, c, autoRequery); + } + + public CanUpdateAppListAdapter(Context context, Cursor c, int flags) { + super(context, c, flags); } @Override diff --git a/src/org/fdroid/fdroid/views/InstalledAppListAdapter.java b/src/org/fdroid/fdroid/views/InstalledAppListAdapter.java index bb3138e2d..1224458fc 100644 --- a/src/org/fdroid/fdroid/views/InstalledAppListAdapter.java +++ b/src/org/fdroid/fdroid/views/InstalledAppListAdapter.java @@ -1,10 +1,20 @@ package org.fdroid.fdroid.views; import android.content.Context; +import android.database.Cursor; public class InstalledAppListAdapter extends AppListAdapter { - public InstalledAppListAdapter(Context context) { - super(context); + + public InstalledAppListAdapter(Context context, Cursor c) { + super(context, c); + } + + public InstalledAppListAdapter(Context context, Cursor c, boolean autoRequery) { + super(context, c, autoRequery); + } + + public InstalledAppListAdapter(Context context, Cursor c, int flags) { + super(context, c, flags); } @Override diff --git a/src/org/fdroid/fdroid/views/RepoDetailsActivity.java b/src/org/fdroid/fdroid/views/RepoDetailsActivity.java index 2ec73b7b7..eff4a73f6 100644 --- a/src/org/fdroid/fdroid/views/RepoDetailsActivity.java +++ b/src/org/fdroid/fdroid/views/RepoDetailsActivity.java @@ -8,6 +8,7 @@ import android.os.Bundle; import android.support.v4.app.FragmentActivity; import android.text.TextUtils; +import org.fdroid.fdroid.FDroidApp; import org.fdroid.fdroid.compat.ActionBarCompat; import org.fdroid.fdroid.data.Repo; import org.fdroid.fdroid.data.RepoProvider; @@ -20,6 +21,9 @@ public class RepoDetailsActivity extends FragmentActivity { @Override protected void onCreate(Bundle savedInstanceState) { + + ((FDroidApp) getApplication()).applyTheme(this); + super.onCreate(savedInstanceState); long repoId = getIntent().getLongExtra(RepoDetailsFragment.ARG_REPO_ID, 0); diff --git a/src/org/fdroid/fdroid/views/fragments/AppListFragment.java b/src/org/fdroid/fdroid/views/fragments/AppListFragment.java index 98ee1d9b4..523c271e7 100644 --- a/src/org/fdroid/fdroid/views/fragments/AppListFragment.java +++ b/src/org/fdroid/fdroid/views/fragments/AppListFragment.java @@ -1,31 +1,114 @@ package org.fdroid.fdroid.views.fragments; import android.app.Activity; +import android.content.Context; import android.content.Intent; +import android.content.SharedPreferences; +import android.database.Cursor; +import android.net.Uri; import android.os.Bundle; +import android.preference.PreferenceManager; +import android.support.v4.app.ListFragment; +import android.support.v4.app.LoaderManager; +import android.support.v4.content.CursorLoader; +import android.support.v4.content.Loader; +import android.util.Log; import android.view.View; import android.view.ViewGroup; import android.widget.AdapterView; import android.widget.ListView; -import android.support.v4.app.Fragment; +import com.nostra13.universalimageloader.core.ImageLoader; +import com.nostra13.universalimageloader.core.listener.PauseOnScrollListener; import org.fdroid.fdroid.*; +import org.fdroid.fdroid.data.App; +import org.fdroid.fdroid.data.AppProvider; import org.fdroid.fdroid.views.AppListAdapter; -import org.fdroid.fdroid.views.AppListView; -abstract class AppListFragment extends Fragment implements AdapterView.OnItemClickListener, Preferences.ChangeListener { +abstract public class AppListFragment extends ListFragment implements + AdapterView.OnItemClickListener, + Preferences.ChangeListener, + LoaderManager.LoaderCallbacks { - protected FDroid parent; + public static final String[] APP_PROJECTION = { + AppProvider.DataColumns._ID, + AppProvider.DataColumns.APP_ID, + AppProvider.DataColumns.NAME, + AppProvider.DataColumns.SUMMARY, + AppProvider.DataColumns.IS_COMPATIBLE, + AppProvider.DataColumns.LICENSE, + AppProvider.DataColumns.ICON, + AppProvider.DataColumns.ICON_URL, + AppProvider.DataColumns.CURRENT_VERSION, + AppProvider.DataColumns.CURRENT_VERSION_CODE, + AppProvider.DataColumns.REQUIREMENTS, // Needed for filtering apps that require root. + }; + + public static final String APP_SORT = AppProvider.DataColumns.NAME; + + protected AppListAdapter appAdapter; protected abstract AppListAdapter getAppListAdapter(); protected abstract String getFromTitle(); + protected abstract Uri getDataUri(); + + @Override + public void onActivityCreated(Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + + // Can't do this in the onCreate view, because "onCreateView" which + // returns the list view is "called between onCreate and + // onActivityCreated" according to the docs. + getListView().setFastScrollEnabled(true); + getListView().setOnItemClickListener(this); + getListView().setOnScrollListener(new PauseOnScrollListener(ImageLoader.getInstance(), false, true)); + } + + @Override + public void onResume() { + super.onResume(); + + //Starts a new or restarts an existing Loader in this manager + getLoaderManager().restartLoader(0, null, this); + } + @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); Preferences.get().registerCompactLayoutChangeListener(this); + + appAdapter = getAppListAdapter(); + + if (appAdapter.getCount() == 0) { + updateEmptyRepos(); + } + + setListAdapter(appAdapter); + } + + /** + * The first time the app is run, we will have an empty app list. + * If this is the case, we will attempt to update with the default repo. + * However, if we have tried this at least once, then don't try to do + * it automatically again, because the repos or internet connection may + * be bad. + */ + public boolean updateEmptyRepos() { + final String TRIED_EMPTY_UPDATE = "triedEmptyUpdate"; + SharedPreferences prefs = getActivity().getPreferences(Context.MODE_PRIVATE); + boolean hasTriedEmptyUpdate = prefs.getBoolean(TRIED_EMPTY_UPDATE, false); + if (!hasTriedEmptyUpdate) { + Log.d("FDroid", "Empty app list, and we haven't done an update yet. Forcing repo update."); + prefs.edit().putBoolean(TRIED_EMPTY_UPDATE, true).commit(); + UpdateService.updateNow(getActivity()); + return true; + } else { + Log.d("FDroid", "Empty app list, but it looks like we've had an update previously. Will not force repo update."); + return false; + } } @Override @@ -34,48 +117,9 @@ abstract class AppListFragment extends Fragment implements AdapterView.OnItemCli Preferences.get().unregisterCompactLayoutChangeListener(this); } - @Override - public void onAttach(Activity activity) { - super.onAttach(activity); - try { - parent = (FDroid)activity; - } catch (ClassCastException e) { - // I know fragments are meant to be activity agnostic, but I can't - // think of a better way to share the one application list between - // all three app list fragments. - throw new RuntimeException( - "AppListFragment can only be attached to FDroid activity. " + - "Here it was attached to a " + activity.getClass() ); - } - } - - public AppListManager getAppListManager() { - return parent.getManager(); - } - - protected AppListView createPlainAppList() { - AppListView view = new AppListView(getActivity()); - ListView list = createAppListView(); - view.addView( - list, - new ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.WRAP_CONTENT)); - view.setAppList(list); - return view; - } - - protected ListView createAppListView() { - ListView list = new ListView(getActivity()); - list.setFastScrollEnabled(true); - list.setOnItemClickListener(this); - list.setAdapter(getAppListAdapter()); - return list; - } - @Override public void onItemClick(AdapterView parent, View view, int position, long id) { - final DB.App app = (DB.App)getAppListAdapter().getItem(position); + final App app = new App((Cursor)getListView().getItemAtPosition(position)); Intent intent = new Intent(getActivity(), AppDetails.class); intent.putExtra("appid", app.id); intent.putExtra("from", getFromTitle()); @@ -86,4 +130,22 @@ abstract class AppListFragment extends Fragment implements AdapterView.OnItemCli public void onPreferenceChange() { getAppListAdapter().notifyDataSetChanged(); } + + @Override + public void onLoadFinished(Loader loader, Cursor data) { + appAdapter.swapCursor(data); + } + + @Override + public void onLoaderReset(Loader loader) { + appAdapter.swapCursor(null); + } + + @Override + public Loader onCreateLoader(int id, Bundle args) { + Uri uri = getDataUri(); + return new CursorLoader( + getActivity(), uri, APP_PROJECTION, null, null, APP_SORT); + } + } diff --git a/src/org/fdroid/fdroid/views/fragments/AvailableAppsFragment.java b/src/org/fdroid/fdroid/views/fragments/AvailableAppsFragment.java index e8a50410a..3bb104b65 100644 --- a/src/org/fdroid/fdroid/views/fragments/AvailableAppsFragment.java +++ b/src/org/fdroid/fdroid/views/fragments/AvailableAppsFragment.java @@ -1,28 +1,74 @@ package org.fdroid.fdroid.views.fragments; +import android.database.Cursor; +import android.net.Uri; import android.os.Bundle; +import android.support.v4.app.LoaderManager; +import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.*; - -import org.fdroid.fdroid.views.AppListAdapter; +import org.fdroid.fdroid.Preferences; import org.fdroid.fdroid.R; -import org.fdroid.fdroid.views.AppListView; +import org.fdroid.fdroid.data.AppProvider; +import org.fdroid.fdroid.views.AppListAdapter; +import org.fdroid.fdroid.views.AvailableAppListAdapter; -public class AvailableAppsFragment extends AppListFragment implements AdapterView.OnItemSelectedListener { +import java.util.List; + +public class AvailableAppsFragment extends AppListFragment implements + LoaderManager.LoaderCallbacks { + + private String currentCategory = null; + private AppListAdapter adapter = null; + + @Override + protected String getFromTitle() { + return "Available"; + } + + protected AppListAdapter getAppListAdapter() { + if (adapter == null) { + final AppListAdapter a = new AvailableAppListAdapter(getActivity(), null); + Preferences.get().registerUpdateHistoryListener(new Preferences.ChangeListener() { + @Override + public void onPreferenceChange() { + a.notifyDataSetChanged(); + } + }); + adapter = a; + } + return adapter; + } @Override public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { - AppListView view = new AppListView(getActivity()); + LinearLayout view = new LinearLayout(getActivity()); view.setOrientation(LinearLayout.VERTICAL); + final List categories = AppProvider.Helper.categories(getActivity()); + Spinner spinner = new Spinner(getActivity()); // Giving it an ID lets the default save/restore state // functionality do its stuff. spinner.setId(R.id.categorySpinner); - spinner.setAdapter(getAppListManager().getCategoriesAdapter()); - spinner.setOnItemSelectedListener(this); + spinner.setAdapter(new ArrayAdapter(getActivity(), android.R.layout.simple_list_item_1, categories)); + spinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { + @Override + public void onItemSelected(AdapterView parent, View view, int pos, long id) { + currentCategory = categories.get(pos); + Log.d("FDroid", "Category '" + currentCategory + "' selected."); + getLoaderManager().restartLoader(0, null, AvailableAppsFragment.this); + } + @Override + public void onNothingSelected(AdapterView parent) { + currentCategory = null; + Log.d("FDroid", "Select empty category."); + getLoaderManager().restartLoader(0, null, AvailableAppsFragment.this); + } + }); + spinner.setPadding( 0, 0, 0, 0 ); view.addView( spinner, @@ -30,8 +76,10 @@ public class AvailableAppsFragment extends AppListFragment implements AdapterVie LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT)); - ListView list = createAppListView(); - view.setAppList(list); + ListView list = new ListView(getActivity()); + list.setId(android.R.id.list); + list.setFastScrollEnabled(true); + list.setOnItemClickListener(this); view.addView( list, new ViewGroup.LayoutParams( @@ -42,24 +90,14 @@ public class AvailableAppsFragment extends AppListFragment implements AdapterVie } @Override - public void onItemSelected(AdapterView parent, View view, int pos, - long id) { - String category = parent.getItemAtPosition(pos).toString(); - getAppListManager().setCurrentCategory(category); - } - - @Override - public void onNothingSelected(AdapterView parent) { - - } - - @Override - protected AppListAdapter getAppListAdapter() { - return getAppListManager().getAvailableAdapter(); - } - - @Override - protected String getFromTitle() { - return getAppListManager().getCurrentCategory(); + protected Uri getDataUri() { + if (currentCategory == null || currentCategory.equals(AppProvider.Helper.getCategoryAll(getActivity()))) + return AppProvider.getContentUri(); + else if (currentCategory.equals(AppProvider.Helper.getCategoryRecentlyUpdated(getActivity()))) + return AppProvider.getRecentlyUpdatedUri(); + else if (currentCategory.equals(AppProvider.Helper.getCategoryWhatsNew(getActivity()))) + return AppProvider.getNewlyAddedUri(); + else + return AppProvider.getCategoryUri(currentCategory); } } diff --git a/src/org/fdroid/fdroid/views/fragments/CanUpdateAppsFragment.java b/src/org/fdroid/fdroid/views/fragments/CanUpdateAppsFragment.java index 1b3ebe9a3..0241b3657 100644 --- a/src/org/fdroid/fdroid/views/fragments/CanUpdateAppsFragment.java +++ b/src/org/fdroid/fdroid/views/fragments/CanUpdateAppsFragment.java @@ -1,27 +1,30 @@ package org.fdroid.fdroid.views.fragments; +import android.database.Cursor; +import android.net.Uri; import android.os.Bundle; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; - +import android.support.v4.content.CursorLoader; +import android.support.v4.content.Loader; import org.fdroid.fdroid.R; +import org.fdroid.fdroid.data.AppProvider; import org.fdroid.fdroid.views.AppListAdapter; +import org.fdroid.fdroid.views.CanUpdateAppListAdapter; public class CanUpdateAppsFragment extends AppListFragment { - @Override - public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { - return createPlainAppList(); - } - @Override protected AppListAdapter getAppListAdapter() { - return getAppListManager().getCanUpdateAdapter(); + return new CanUpdateAppListAdapter(getActivity(), null); } @Override protected String getFromTitle() { - return parent.getString(R.string.tab_updates); + return getString(R.string.tab_updates); } + + @Override + protected Uri getDataUri() { + return AppProvider.getCanUpdateUri(); + } + } diff --git a/src/org/fdroid/fdroid/views/fragments/InstalledAppsFragment.java b/src/org/fdroid/fdroid/views/fragments/InstalledAppsFragment.java index b27bec6b1..dbcdf5cbb 100644 --- a/src/org/fdroid/fdroid/views/fragments/InstalledAppsFragment.java +++ b/src/org/fdroid/fdroid/views/fragments/InstalledAppsFragment.java @@ -1,27 +1,30 @@ package org.fdroid.fdroid.views.fragments; +import android.database.Cursor; +import android.net.Uri; import android.os.Bundle; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; - +import android.support.v4.content.CursorLoader; +import android.support.v4.content.Loader; import org.fdroid.fdroid.R; +import org.fdroid.fdroid.data.AppProvider; import org.fdroid.fdroid.views.AppListAdapter; +import org.fdroid.fdroid.views.InstalledAppListAdapter; public class InstalledAppsFragment extends AppListFragment { - @Override - public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { - return createPlainAppList(); - } - @Override protected AppListAdapter getAppListAdapter() { - return getAppListManager().getInstalledAdapter(); + return new InstalledAppListAdapter(getActivity(), null); } @Override protected String getFromTitle() { - return parent.getString(R.string.inst); + return getString(R.string.inst); } + + @Override + protected Uri getDataUri() { + return AppProvider.getInstalledUri(); + } + } diff --git a/test/.gitignore b/test/.gitignore new file mode 100644 index 000000000..24e19e4db --- /dev/null +++ b/test/.gitignore @@ -0,0 +1,10 @@ +/local.properties +/.classpath +/bin/ +/gen/ +/build/ +/.gradle/ +*~ +/.idea/ +/*.iml +out diff --git a/test/AndroidManifest.xml b/test/AndroidManifest.xml new file mode 100644 index 000000000..3562f038e --- /dev/null +++ b/test/AndroidManifest.xml @@ -0,0 +1,21 @@ + + + + + + + + + + diff --git a/test/ant.properties b/test/ant.properties new file mode 100644 index 000000000..7d28fd099 --- /dev/null +++ b/test/ant.properties @@ -0,0 +1,18 @@ +# This file is used to override default values used by the Ant build system. +# +# This file must be checked into Version Control Systems, as it is +# integral to the build system of your project. + +# This file is only used by the Ant script. + +# You can use this to override default values such as +# 'source.dir' for the location of your java source folder and +# 'out.dir' for the location of your output folder. + +# You can also use it define how the release builds are signed by declaring +# the following properties: +# 'key.store' for the location of your keystore and +# 'key.alias' for the name of the key to use. +# The password will be asked during the build when you use the 'release' target. + +tested.project.dir=/home/pete/code/fdroid/client diff --git a/test/build.xml b/test/build.xml new file mode 100644 index 000000000..acf244066 --- /dev/null +++ b/test/build.xml @@ -0,0 +1,92 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/proguard-project.txt b/test/proguard-project.txt new file mode 100644 index 000000000..f2fe1559a --- /dev/null +++ b/test/proguard-project.txt @@ -0,0 +1,20 @@ +# To enable ProGuard in your project, edit project.properties +# to define the proguard.config property as described in that file. +# +# Add project specific ProGuard rules here. +# By default, the flags in this file are appended to flags specified +# in ${sdk.dir}/tools/proguard/proguard-android.txt +# You can edit the include path and order by changing the ProGuard +# include property in project.properties. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# Add any project specific keep options here: + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} diff --git a/tests/project.properties b/test/project.properties similarity index 96% rename from tests/project.properties rename to test/project.properties index 1f896ec2b..4ab125693 100644 --- a/tests/project.properties +++ b/test/project.properties @@ -11,4 +11,4 @@ #proguard.config=${sdk.dir}/tools/proguard/proguard-android.txt:proguard-project.txt # Project target. -target=android-4 +target=android-19 diff --git a/test/src/android/test/ProviderTestCase2MockContext.java b/test/src/android/test/ProviderTestCase2MockContext.java new file mode 100644 index 000000000..f0b64122e --- /dev/null +++ b/test/src/android/test/ProviderTestCase2MockContext.java @@ -0,0 +1,228 @@ +/* + * Copyright (C) 2007 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package android.test; + +import android.content.ContentProvider; +import android.content.ContentResolver; +import android.content.Context; +import android.content.res.Resources; +import android.test.mock.MockContext; +import android.test.mock.MockContentResolver; +import android.database.DatabaseUtils; + +import java.io.File; + +/** + * This test case class provides a framework for testing a single + * {@link ContentProvider} and for testing your app code with an + * isolated content provider. Instead of using the system map of + * providers that is based on the manifests of other applications, the test + * case creates its own internal map. It then uses this map to resolve providers + * given an authority. This allows you to inject test providers and to null out + * providers that you do not want to use. + *

+ * This test case also sets up the following mock objects: + *

+ *
    + *
  • + * An {@link android.test.IsolatedContext} that stubs out Context methods that might + * affect the rest of the running system, while allowing tests to do real file and + * database work. + *
  • + *
  • + * A {@link android.test.mock.MockContentResolver} that provides the functionality of a + * regular content resolver, but uses {@link IsolatedContext}. It stubs out + * {@link ContentResolver#notifyChange(Uri, ContentObserver, boolean)} to + * prevent the test from affecting the running system. + *
  • + *
  • + * An instance of the provider under test, running in an {@link IsolatedContext}. + *
  • + *
+ *

+ * This framework is set up automatically by the base class' {@link #setUp()} method. If you + * override this method, you must call the super method as the first statement in + * your override. + *

+ *

+ * In order for their tests to be run, concrete subclasses must provide their own + * constructor with no arguments. This constructor must call + * {@link #ProviderTestCase2MockContext(Class, String)} as its first operation. + *

+ * For more information on content provider testing, please see + * Content Provider Testing. + */ +public abstract class ProviderTestCase2MockContext extends AndroidTestCase { + + Class mProviderClass; + String mProviderAuthority; + + private IsolatedContext mProviderContext; + private MockContentResolver mResolver; + + private class MockContext2 extends MockContext { + + @Override + public Resources getResources() { + return getContext().getResources(); + } + + @Override + public File getDir(String name, int mode) { + // name the directory so the directory will be separated from + // one created through the regular Context + return getContext().getDir("mockcontext2_" + name, mode); + } + + @Override + public Context getApplicationContext() { + return this; + } + } + /** + * Constructor. + * + * @param providerClass The class name of the provider under test + * @param providerAuthority The provider's authority string + */ + public ProviderTestCase2MockContext(Class providerClass, String providerAuthority) { + mProviderClass = providerClass; + mProviderAuthority = providerAuthority; + } + + private T mProvider; + + /** + * Returns the content provider created by this class in the {@link #setUp()} method. + * @return T An instance of the provider class given as a parameter to the test case class. + */ + public T getProvider() { + return mProvider; + } + + abstract protected Context createMockContext(Context delegate); + + /** + * Sets up the environment for the test fixture. + *

+ * Creates a new + * {@link android.test.mock.MockContentResolver}, a new IsolatedContext + * that isolates the provider's file operations, and a new instance of + * the provider under test within the isolated environment. + *

+ * + * @throws Exception + */ + @Override + protected void setUp() throws Exception { + super.setUp(); + + mResolver = new MockContentResolver(); + final String filenamePrefix = "test."; + RenamingDelegatingContext targetContextWrapper = new + RenamingDelegatingContext( + createMockContext(new MockContext2()), // The context that most methods are delegated to + getContext(), // The context that file methods are delegated to + filenamePrefix); + mProviderContext = new IsolatedContext(mResolver, targetContextWrapper); + + mProvider = mProviderClass.newInstance(); + mProvider.attachInfo(mProviderContext, null); + assertNotNull(mProvider); + mResolver.addProvider(mProviderAuthority, getProvider()); + } + + /** + * Tears down the environment for the test fixture. + *

+ * Calls {@link android.content.ContentProvider#shutdown()} on the + * {@link android.content.ContentProvider} represented by mProvider. + */ + @Override + protected void tearDown() throws Exception { + mProvider.shutdown(); + super.tearDown(); + } + + /** + * Gets the {@link MockContentResolver} created by this class during initialization. You + * must use the methods of this resolver to access the provider under test. + * + * @return A {@link MockContentResolver} instance. + */ + public MockContentResolver getMockContentResolver() { + return mResolver; + } + + /** + * Gets the {@link IsolatedContext} created by this class during initialization. + * @return The {@link IsolatedContext} instance + */ + public IsolatedContext getMockContext() { + return mProviderContext; + } + + /** + *

+ * Creates a new content provider of the same type as that passed to the test case class, + * with an authority name set to the authority parameter, and using an SQLite database as + * the underlying data source. The SQL statement parameter is used to create the database. + * This method also creates a new {@link MockContentResolver} and adds the provider to it. + *

+ *

+ * Both the new provider and the new resolver are put into an {@link IsolatedContext} + * that uses the targetContext parameter for file operations and a {@link MockContext} + * for everything else. The IsolatedContext prepends the filenamePrefix parameter to + * file, database, and directory names. + *

+ *

+ * This is a convenience method for creating a "mock" provider that can contain test data. + *

+ * + * @param targetContext The context to use as the basis of the IsolatedContext + * @param filenamePrefix A string that is prepended to file, database, and directory names + * @param providerClass The type of the provider being tested + * @param authority The authority string to associated with the test provider + * @param databaseName The name assigned to the database + * @param databaseVersion The version assigned to the database + * @param sql A string containing the SQL statements that are needed to create the desired + * database and its tables. The format is the same as that generated by the + * sqlite3 tool's .dump command. + * @return ContentResolver A new {@link MockContentResolver} linked to the provider + * + * @throws IllegalAccessException + * @throws InstantiationException + */ + public static ContentResolver newResolverWithContentProviderFromSql( + Context targetContext, String filenamePrefix, Class providerClass, String authority, + String databaseName, int databaseVersion, String sql) + throws IllegalAccessException, InstantiationException { + MockContentResolver resolver = new MockContentResolver(); + RenamingDelegatingContext targetContextWrapper = new RenamingDelegatingContext( + new MockContext(), // The context that most methods are delegated to + targetContext, // The context that file methods are delegated to + filenamePrefix); + Context context = new IsolatedContext(resolver, targetContextWrapper); + DatabaseUtils.createDbFromSqlStatements(context, databaseName, databaseVersion, sql); + + T provider = providerClass.newInstance(); + provider.attachInfo(context, null); + resolver.addProvider(authority, provider); + + return resolver; + } +} diff --git a/test/src/mock/MockContextEmptyComponents.java b/test/src/mock/MockContextEmptyComponents.java new file mode 100644 index 000000000..eb962bbe9 --- /dev/null +++ b/test/src/mock/MockContextEmptyComponents.java @@ -0,0 +1,14 @@ +package mock; + +/** + * As more components are required to test different parts of F-Droid, we can + * create them and add them here (and accessors to the parent class). + */ +public class MockContextEmptyComponents extends MockContextSwappableComponents { + + public MockContextEmptyComponents() { + setPackageManager(new MockEmptyPackageManager()); + setResources(new MockEmptyResources()); + } + +} diff --git a/test/src/mock/MockContextSwappableComponents.java b/test/src/mock/MockContextSwappableComponents.java new file mode 100644 index 000000000..7fcbbf971 --- /dev/null +++ b/test/src/mock/MockContextSwappableComponents.java @@ -0,0 +1,32 @@ +package mock; + +import android.content.pm.PackageManager; +import android.content.res.Resources; +import android.test.mock.MockContext; + +public class MockContextSwappableComponents extends MockContext { + + private PackageManager packageManager; + + private Resources resources; + + public MockContextSwappableComponents setPackageManager(PackageManager pm) { + packageManager = pm; + return this; + } + + public MockContextSwappableComponents setResources(Resources resources) { + this.resources = resources; + return this; + } + + @Override + public PackageManager getPackageManager() { + return packageManager; + } + + @Override + public Resources getResources() { + return resources; + } +} diff --git a/test/src/mock/MockEmptyPackageManager.java b/test/src/mock/MockEmptyPackageManager.java new file mode 100644 index 000000000..39fdee310 --- /dev/null +++ b/test/src/mock/MockEmptyPackageManager.java @@ -0,0 +1,16 @@ +package mock; + +import android.content.pm.PackageInfo; +import android.test.mock.MockPackageManager; + +import java.util.ArrayList; +import java.util.List; + +public class MockEmptyPackageManager extends MockPackageManager { + + @Override + public List getInstalledPackages(int flags) { + return new ArrayList(); + } + +} diff --git a/test/src/mock/MockEmptyResources.java b/test/src/mock/MockEmptyResources.java new file mode 100644 index 000000000..fdc06e47f --- /dev/null +++ b/test/src/mock/MockEmptyResources.java @@ -0,0 +1,12 @@ +package mock; + +import android.test.mock.MockResources; + +public class MockEmptyResources extends MockResources { + + @Override + public String getString(int id) { + return ""; + } + +} diff --git a/test/src/mock/MockInstallablePackageManager.java b/test/src/mock/MockInstallablePackageManager.java new file mode 100644 index 000000000..bd47d86f4 --- /dev/null +++ b/test/src/mock/MockInstallablePackageManager.java @@ -0,0 +1,26 @@ +package mock; + +import android.content.pm.PackageInfo; +import android.test.mock.MockPackageManager; + +import java.util.ArrayList; +import java.util.List; + +public class MockInstallablePackageManager extends MockPackageManager { + + private List info = new ArrayList(); + + @Override + public List getInstalledPackages(int flags) { + return info; + } + + 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); + } + +} diff --git a/test/src/org/fdroid/fdroid/AppProviderTest.java b/test/src/org/fdroid/fdroid/AppProviderTest.java new file mode 100644 index 000000000..c1ccb7b9c --- /dev/null +++ b/test/src/org/fdroid/fdroid/AppProviderTest.java @@ -0,0 +1,138 @@ +package org.fdroid.fdroid; + +import android.content.ContentValues; +import android.content.pm.PackageInfo; +import android.database.Cursor; +import android.net.Uri; +import android.provider.ContactsContract; +import mock.MockInstallablePackageManager; +import org.fdroid.fdroid.data.ApkProvider; +import org.fdroid.fdroid.data.App; +import org.fdroid.fdroid.data.AppProvider; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +public class AppProviderTest extends FDroidProviderTest { + + public AppProviderTest() { + super(AppProvider.class, AppProvider.getAuthority()); + } + + protected String[] getMinimalProjection() { + return new String[] { + AppProvider.DataColumns.APP_ID, + AppProvider.DataColumns.NAME + }; + } + + public void testUris() { + assertInvalidUri(AppProvider.getAuthority()); + assertInvalidUri(ApkProvider.getContentUri()); + + assertValidUri(AppProvider.getContentUri()); + assertValidUri(AppProvider.getSearchUri("'searching!'")); + assertValidUri(AppProvider.getNoApksUri()); + assertValidUri(AppProvider.getInstalledUri()); + assertValidUri(AppProvider.getCanUpdateUri()); + + App app = new App(); + app.id = "org.fdroid.fdroid"; + + List apps = new ArrayList(1); + apps.add(app); + + assertValidUri(AppProvider.getContentUri(app)); + assertValidUri(AppProvider.getContentUri(apps)); + assertValidUri(AppProvider.getContentUri("org.fdroid.fdroid")); + } + + public void testQuery() { + Cursor cursor = queryAllApps(); + assertNotNull(cursor); + } + + public void testInstalled() { + + Utils.clearInstalledApksCache(); + + MockInstallablePackageManager pm = new MockInstallablePackageManager(); + getSwappableContext().setPackageManager(pm); + + for (int i = 0; i < 100; i ++) { + insertApp("com.example.test." + i, "Test app " + i); + } + + assertAppCount(100, AppProvider.getContentUri()); + assertAppCount(0, AppProvider.getInstalledUri()); + + for (int i = 10; i < 20; i ++) { + pm.install("com.example.test." + i, i, "v1"); + } + + assertAppCount(10, AppProvider.getInstalledUri()); + } + + private void assertAppCount(int expectedCount, Uri uri) { + Cursor cursor = getProvider().query(uri, getMinimalProjection(), null, null, null); + assertNotNull(cursor); + assertEquals(expectedCount, cursor.getCount()); + } + + public void testInsert() { + + // Start with an empty database... + Cursor cursor = queryAllApps(); + assertNotNull(cursor); + assertEquals(0, cursor.getCount()); + + // Insert a new record... + insertApp("org.fdroid.fdroid", "F-Droid"); + cursor = queryAllApps(); + assertNotNull(cursor); + assertEquals(1, cursor.getCount()); + + // We intentionally throw an IllegalArgumentException if you haven't + // yet called cursor.move*()... + try { + new App(cursor); + fail(); + } catch (IllegalArgumentException e) { + // Success! + } catch (Exception e) { + fail(); + } + + // And now we should be able to recover these values from the app + // value object (because the queryAllApps() helper asks for NAME and + // APP_ID. + cursor.moveToFirst(); + App app = new App(cursor); + assertEquals("org.fdroid.fdroid", app.id); + assertEquals("F-Droid", app.name); + } + + private Cursor queryAllApps() { + return getProvider().query(AppProvider.getContentUri(), getMinimalProjection(), null, null, null); + } + + private void insertApp(String id, String name) { + ContentValues values = new ContentValues(2); + values.put(AppProvider.DataColumns.APP_ID, id); + values.put(AppProvider.DataColumns.NAME, name); + + // Required fields (NOT NULL in the database). + values.put(AppProvider.DataColumns.SUMMARY, "test summary"); + values.put(AppProvider.DataColumns.DESCRIPTION, "test description"); + values.put(AppProvider.DataColumns.LICENSE, "GPL?"); + values.put(AppProvider.DataColumns.IS_COMPATIBLE, 1); + values.put(AppProvider.DataColumns.IGNORE_ALLUPDATES, 0); + values.put(AppProvider.DataColumns.IGNORE_THISUPDATE, 0); + + Uri uri = AppProvider.getContentUri(); + + getProvider().insert(uri, values); + } + +} diff --git a/test/src/org/fdroid/fdroid/FDroidProviderTest.java b/test/src/org/fdroid/fdroid/FDroidProviderTest.java new file mode 100644 index 000000000..eaaa84e57 --- /dev/null +++ b/test/src/org/fdroid/fdroid/FDroidProviderTest.java @@ -0,0 +1,73 @@ +package org.fdroid.fdroid; + +import android.annotation.TargetApi; +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.os.Build; +import android.provider.ContactsContract; +import android.test.ProviderTestCase2MockContext; +import mock.MockContextEmptyComponents; +import mock.MockContextSwappableComponents; +import org.fdroid.fdroid.data.FDroidProvider; +import org.fdroid.fdroid.mock.MockInstalledApkCache; + +public abstract class FDroidProviderTest extends ProviderTestCase2MockContext { + + private MockContextSwappableComponents swappableContext; + + public FDroidProviderTest(Class providerClass, String providerAuthority) { + super(providerClass, providerAuthority); + } + + @Override + public void setUp() throws Exception { + super.setUp(); + Utils.setupInstalledApkCache(new MockInstalledApkCache()); + } + + @TargetApi(Build.VERSION_CODES.ECLAIR) + public void testObviouslyInvalidUris() { + assertInvalidUri("http://www.google.com"); + assertInvalidUri(ContactsContract.AUTHORITY_URI); + assertInvalidUri("junk"); + } + + @Override + protected Context createMockContext(Context delegate) { + swappableContext = new MockContextEmptyComponents(); + return swappableContext; + } + + public MockContextSwappableComponents getSwappableContext() { + return swappableContext; + } + + protected void assertInvalidUri(String uri) { + assertInvalidUri(Uri.parse(uri)); + } + + protected void assertValidUri(String uri) { + assertValidUri(Uri.parse(uri)); + } + + protected void assertInvalidUri(Uri uri) { + try { + getProvider().query(uri, getMinimalProjection(), null, null, null); + fail(); + } catch (UnsupportedOperationException e) {} + } + + protected void assertValidUri(Uri uri) { + Cursor cursor = getProvider().query(uri, getMinimalProjection(), null, null, null); + assertNotNull(cursor); + } + + /** + * Many queries need at least some sort of projection in order to produce + * valid SQL. As such, we also need to know about that, so we can provide + * helper functions that revolve around the contnet provider under test. + */ + protected abstract String[] getMinimalProjection(); + +} diff --git a/test/src/org/fdroid/fdroid/FDroidTest.java b/test/src/org/fdroid/fdroid/FDroidTest.java new file mode 100644 index 000000000..07d9f3ee3 --- /dev/null +++ b/test/src/org/fdroid/fdroid/FDroidTest.java @@ -0,0 +1,14 @@ +package org.fdroid.fdroid; + +import android.annotation.TargetApi; +import android.os.Build; +import android.test.ActivityInstrumentationTestCase2; + +@TargetApi(Build.VERSION_CODES.CUPCAKE) +public class FDroidTest extends ActivityInstrumentationTestCase2 { + + public FDroidTest() { + super("org.fdroid.fdroid", FDroid.class); + } + +} diff --git a/test/src/org/fdroid/fdroid/mock/MockInstalledApkCache.java b/test/src/org/fdroid/fdroid/mock/MockInstalledApkCache.java new file mode 100644 index 000000000..acac7557f --- /dev/null +++ b/test/src/org/fdroid/fdroid/mock/MockInstalledApkCache.java @@ -0,0 +1,16 @@ +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); + } + +} diff --git a/tests/gen/org/fdroid/fdroid/tests/BuildConfig.java b/tests/gen/org/fdroid/fdroid/tests/BuildConfig.java deleted file mode 100644 index 2892c52fb..000000000 --- a/tests/gen/org/fdroid/fdroid/tests/BuildConfig.java +++ /dev/null @@ -1,8 +0,0 @@ -/*___Generated_by_IDEA___*/ - -/** Automatically generated file. DO NOT MODIFY */ -package org.fdroid.fdroid.tests; - -public final class BuildConfig { - public final static boolean DEBUG = true; -} \ No newline at end of file diff --git a/tests/gen/org/fdroid/fdroid/tests/Manifest.java b/tests/gen/org/fdroid/fdroid/tests/Manifest.java deleted file mode 100644 index 15e60435f..000000000 --- a/tests/gen/org/fdroid/fdroid/tests/Manifest.java +++ /dev/null @@ -1,7 +0,0 @@ -/*___Generated_by_IDEA___*/ - -package org.fdroid.fdroid.tests; - -/* This stub is for using by IDE only. It is NOT the Manifest class actually packed into APK */ -public final class Manifest { -} \ No newline at end of file diff --git a/tests/gen/org/fdroid/fdroid/tests/R.java b/tests/gen/org/fdroid/fdroid/tests/R.java deleted file mode 100644 index 40f6d3574..000000000 --- a/tests/gen/org/fdroid/fdroid/tests/R.java +++ /dev/null @@ -1,7 +0,0 @@ -/*___Generated_by_IDEA___*/ - -package org.fdroid.fdroid.tests; - -/* This stub is for using by IDE only. It is NOT the R class actually packed into APK */ -public final class R { -} \ No newline at end of file diff --git a/tests/local.properties b/tests/local.properties deleted file mode 100644 index 12a01149a..000000000 --- a/tests/local.properties +++ /dev/null @@ -1,10 +0,0 @@ -# This file is automatically generated by Android Tools. -# Do not modify this file -- YOUR CHANGES WILL BE ERASED! -# -# This file must *NOT* be checked into Version Control Systems, -# as it contains information specific to your local configuration. - -# location of the SDK. This is only used by Ant -# For customization when using a Version Control System, please read the -# header note. -sdk.dir=/opt/android-sdk