Merge branch 'feature/refactor-downloaders-async' of https://gitlab.com/pserwylo/fdroidclient

This commit is contained in:
Daniel Martí 2014-05-27 19:20:10 +02:00
commit a08963f0e5
13 changed files with 1066 additions and 610 deletions

2
.gitignore vendored
View File

@ -6,6 +6,6 @@
/build.xml /build.xml
*~ *~
/.idea/ /.idea/
/*.iml *.iml
out out
/.settings/ /.settings/

View File

@ -19,31 +19,24 @@
package org.fdroid.fdroid; package org.fdroid.fdroid;
import android.content.*; import android.app.Activity;
import android.widget.*;
import org.fdroid.fdroid.data.*;
import org.fdroid.fdroid.installer.Installer;
import org.fdroid.fdroid.installer.Installer.AndroidNotCompatibleException;
import org.fdroid.fdroid.installer.Installer.InstallerCallback;
import org.xml.sax.XMLReader;
import android.app.AlertDialog; import android.app.AlertDialog;
import android.app.ListActivity; import android.app.ListActivity;
import android.app.ProgressDialog; import android.app.ProgressDialog;
import android.bluetooth.BluetoothAdapter; import android.bluetooth.BluetoothAdapter;
import android.content.*;
import android.content.pm.PackageInfo;
import android.content.pm.PackageManager;
import android.content.pm.PackageManager.NameNotFoundException;
import android.content.pm.Signature;
import android.database.ContentObserver;
import android.graphics.Bitmap;
import android.net.Uri; import android.net.Uri;
import android.os.Bundle; import android.os.Bundle;
import android.os.Handler; import android.os.Handler;
import android.os.Message;
import android.preference.PreferenceManager; import android.preference.PreferenceManager;
import android.support.v4.app.NavUtils; import android.support.v4.app.NavUtils;
import android.support.v4.view.MenuItemCompat; import android.support.v4.view.MenuItemCompat;
import android.content.pm.PackageManager;
import android.content.pm.PackageInfo;
import android.content.pm.Signature;
import android.content.pm.PackageManager.NameNotFoundException;
import android.database.ContentObserver;
import android.text.Editable; import android.text.Editable;
import android.text.Html; import android.text.Html;
import android.text.Html.TagHandler; import android.text.Html.TagHandler;
@ -58,24 +51,34 @@ import android.view.SubMenu;
import android.view.View; import android.view.View;
import android.view.ViewGroup; import android.view.ViewGroup;
import android.view.Window; import android.view.Window;
import android.graphics.Bitmap; import android.widget.ArrayAdapter;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.ListView;
import android.widget.TextView;
import android.widget.Toast;
import com.nostra13.universalimageloader.core.DisplayImageOptions; import com.nostra13.universalimageloader.core.DisplayImageOptions;
import com.nostra13.universalimageloader.core.ImageLoader; import com.nostra13.universalimageloader.core.ImageLoader;
import com.nostra13.universalimageloader.core.assist.ImageScaleType; import com.nostra13.universalimageloader.core.assist.ImageScaleType;
import org.fdroid.fdroid.Utils.CommaSeparatedList; import org.fdroid.fdroid.Utils.CommaSeparatedList;
import org.fdroid.fdroid.compat.ActionBarCompat; import org.fdroid.fdroid.compat.ActionBarCompat;
import org.fdroid.fdroid.compat.MenuManager; import org.fdroid.fdroid.compat.MenuManager;
import org.fdroid.fdroid.compat.PackageManagerCompat; import org.fdroid.fdroid.compat.PackageManagerCompat;
import org.fdroid.fdroid.data.*;
import org.fdroid.fdroid.installer.Installer;
import org.fdroid.fdroid.installer.Installer.AndroidNotCompatibleException;
import org.fdroid.fdroid.installer.Installer.InstallerCallback;
import org.fdroid.fdroid.net.ApkDownloader;
import org.fdroid.fdroid.net.Downloader;
import org.xml.sax.XMLReader;
import java.io.File; import java.io.File;
import java.security.NoSuchAlgorithmException; import java.security.NoSuchAlgorithmException;
import java.util.Iterator; import java.util.Iterator;
import java.util.List; import java.util.List;
public class AppDetails extends ListActivity { public class AppDetails extends ListActivity implements ProgressListener {
private static final String TAG = "AppDetails"; private static final String TAG = "org.fdroid.fdroid.AppDetails";
public static final int REQUEST_ENABLE_BLUETOOTH = 2; public static final int REQUEST_ENABLE_BLUETOOTH = 2;
@ -84,6 +87,7 @@ public class AppDetails extends ListActivity {
private FDroidApp fdroidApp; private FDroidApp fdroidApp;
private ApkListAdapter adapter; private ApkListAdapter adapter;
private ProgressDialog progressDialog;
private static class ViewHolder { private static class ViewHolder {
TextView version; TextView version;
@ -98,7 +102,7 @@ public class AppDetails extends ListActivity {
// observer to update view when package has been installed/deleted // observer to update view when package has been installed/deleted
AppObserver myAppObserver; AppObserver myAppObserver;
class AppObserver extends ContentObserver { class AppObserver extends ContentObserver {
public AppObserver(Handler handler) { public AppObserver(Handler handler) {
super(handler); super(handler);
} }
@ -110,7 +114,7 @@ public class AppDetails extends ListActivity {
@Override @Override
public void onChange(boolean selfChange, Uri uri) { public void onChange(boolean selfChange, Uri uri) {
if (!reset()) { if (!reset(app.id)) {
AppDetails.this.finish(); AppDetails.this.finish();
return; return;
} }
@ -267,10 +271,8 @@ public class AppDetails extends ListActivity {
private static final int SEND_VIA_BLUETOOTH = Menu.FIRST + 15; private static final int SEND_VIA_BLUETOOTH = Menu.FIRST + 15;
private App app; private App app;
private String appid;
private PackageManager mPm; private PackageManager mPm;
private DownloadHandler downloadHandler; private ApkDownloader downloadHandler;
private boolean stateRetained;
private boolean startingIgnoreAll; private boolean startingIgnoreAll;
private int startingIgnoreThis; private int startingIgnoreThis;
@ -282,6 +284,68 @@ public class AppDetails extends ListActivity {
private DisplayImageOptions displayImageOptions; private DisplayImageOptions displayImageOptions;
private Installer installer; private Installer installer;
/**
* Stores relevant data that we want to keep track of when destroying the activity
* with the expectation of it being recreated straight away (e.g. after an
* orientation change). One of the major things is that we want the download thread
* to stay active, but for it not to trigger any UI stuff (e.g. progress dialogs)
* between the activity being destroyed and recreated.
*/
private static class ConfigurationChangeHelper {
public ApkDownloader downloader;
public App app;
public ConfigurationChangeHelper(ApkDownloader downloader, App app) {
this.downloader = downloader;
this.app = app;
}
}
private boolean inProcessOfChangingConfiguration = false;
/**
* Attempt to extract the appId from the intent which launched this activity.
* Various different intents could cause us to show this activity, such as:
* <ul>
* <li>market://details?id=[app_id]</li>
* <li>https://f-droid.org/app/[app_id]</li>
* <li>fdroid.app:[app_id]</li>
* </ul>
* @return May return null, if we couldn't find the appId. In this case, you will
* probably want to do something drastic like finish the activity and show some
* feedback to the user (this method will <em>not</em> do that, it will just return
* null).
*/
private String getAppIdFromIntent() {
Intent i = getIntent();
Uri data = i.getData();
String appId = null;
if (data != null) {
if (data.isHierarchical()) {
if (data.getHost() != null && data.getHost().equals("details")) {
// market://details?id=app.id
appId = data.getQueryParameter("id");
} else {
// https://f-droid.org/app/app.id
appId = data.getLastPathSegment();
if (appId != null && appId.equals("app")) {
appId = null;
}
}
} else {
// fdroid.app:app.id
appId = data.getEncodedSchemeSpecificPart();
}
Log.d("FDroid", "AppDetails launched from link, for '" + appId + "'");
} else if (!i.hasExtra(EXTRA_APPID)) {
Log.e("FDroid", "No application ID in AppDetails!?");
} else {
appId = i.getStringExtra(EXTRA_APPID);
}
return appId;
}
@Override @Override
protected void onCreate(Bundle savedInstanceState) { protected void onCreate(Bundle savedInstanceState) {
requestWindowFeature(Window.FEATURE_INDETERMINATE_PROGRESS); requestWindowFeature(Window.FEATURE_INDETERMINATE_PROGRESS);
@ -307,43 +371,27 @@ public class AppDetails extends ListActivity {
// for reason why. // for reason why.
ActionBarCompat.create(this).setDisplayHomeAsUpEnabled(true); ActionBarCompat.create(this).setDisplayHomeAsUpEnabled(true);
Intent i = getIntent(); if (getIntent().hasExtra(EXTRA_FROM)) {
Uri data = i.getData(); setTitle(getIntent().getStringExtra(EXTRA_FROM));
if (data != null) {
if (data.isHierarchical()) {
if (data.getHost() != null && data.getHost().equals("details")) {
// market://details?id=app.id
appid = data.getQueryParameter("id");
} else {
// https://f-droid.org/app/app.id
appid = data.getLastPathSegment();
if (appid != null && appid.equals("app")) appid = null;
}
} else {
// fdroid.app:app.id
appid = data.getEncodedSchemeSpecificPart();
}
Log.d("FDroid", "AppDetails launched from link, for '" + appid + "'");
} else if (!i.hasExtra(EXTRA_APPID)) {
Log.d("FDroid", "No application ID in AppDetails!?");
} else {
appid = i.getStringExtra(EXTRA_APPID);
}
if (i.hasExtra(EXTRA_FROM)) {
setTitle(i.getStringExtra(EXTRA_FROM));
} }
mPm = getPackageManager(); mPm = getPackageManager();
installer = Installer.getActivityInstaller(this, mPm, installer = Installer.getActivityInstaller(this, mPm,
myInstallerCallback); myInstallerCallback);
// Get the preferences we're going to use in this Activity... // Get the preferences we're going to use in this Activity...
AppDetails old = (AppDetails) getLastNonConfigurationInstance(); ConfigurationChangeHelper previousData = (ConfigurationChangeHelper)getLastNonConfigurationInstance();
if (old != null) { if (previousData != null) {
copyState(old); Log.d(TAG, "Recreating view after configuration change.");
downloadHandler = previousData.downloader;
if (downloadHandler != null) {
Log.d(TAG, "Download was in progress before the configuration change, so we will start to listen to its events again.");
}
app = previousData.app;
setApp(app);
} else { } else {
if (!reset()) { if (!reset(getAppIdFromIntent())) {
finish(); finish();
return; return;
} }
@ -377,7 +425,6 @@ public class AppDetails extends ListActivity {
@Override @Override
protected void onResume() { protected void onResume() {
Log.d(TAG, "onresume");
super.onResume(); super.onResume();
// register observer to know when install status changes // register observer to know when install status changes
@ -386,17 +433,45 @@ public class AppDetails extends ListActivity {
AppProvider.getContentUri(app.id), AppProvider.getContentUri(app.id),
true, true,
myAppObserver); myAppObserver);
if (downloadHandler != null) {
if (!reset()) { if (downloadHandler.isComplete()) {
finish(); downloadCompleteInstallApk();
return; } else {
downloadHandler.setProgressListener(this);
// Show the progress dialog, if for no other reason than to prevent them attempting
// to download again (i.e. we force them to touch 'cancel' before they can access
// the rest of the activity).
Log.d(TAG, "Showing dialog to user after resuming app details view, because a download was previously in progress");
updateProgressDialog();
}
} }
updateViews(); updateViews();
MenuManager.create(this).invalidateOptionsMenu(); MenuManager.create(this).invalidateOptionsMenu();
}
/**
* Remove progress listener, suppress progress dialog, set downloadHandler to null.
*/
private void cleanUpFinishedDownload() {
if (downloadHandler != null) { if (downloadHandler != null) {
downloadHandler.startUpdates(); downloadHandler.removeProgressListener();
removeProgressDialog();
downloadHandler = null;
}
}
/**
* Once the download completes successfully, call this method to start the install process
* with the file that was downloaded.
*/
private void downloadCompleteInstallApk() {
if (downloadHandler != null) {
assert downloadHandler.isComplete();
installApk(downloadHandler.localFile(), downloadHandler.getApk().id);
cleanUpFinishedDownload();
} }
} }
@ -405,13 +480,18 @@ public class AppDetails extends ListActivity {
if (myAppObserver != null) { if (myAppObserver != null) {
getContentResolver().unregisterContentObserver(myAppObserver); getContentResolver().unregisterContentObserver(myAppObserver);
} }
if (downloadHandler != null) {
downloadHandler.stopUpdates();
}
if (app != null && (app.ignoreAllUpdates != startingIgnoreAll if (app != null && (app.ignoreAllUpdates != startingIgnoreAll
|| app.ignoreThisUpdate != startingIgnoreThis)) { || app.ignoreThisUpdate != startingIgnoreThis)) {
Log.d(TAG, "Updating 'ignore updates', as it has changed since we started the activity...");
setIgnoreUpdates(app.id, app.ignoreAllUpdates, app.ignoreThisUpdate); setIgnoreUpdates(app.id, app.ignoreAllUpdates, app.ignoreThisUpdate);
} }
if (downloadHandler != null) {
downloadHandler.removeProgressListener();
}
removeProgressDialog();
super.onPause(); super.onPause();
} }
@ -430,65 +510,73 @@ public class AppDetails extends ListActivity {
@Override @Override
public Object onRetainNonConfigurationInstance() { public Object onRetainNonConfigurationInstance() {
stateRetained = true; inProcessOfChangingConfiguration = true;
return this; return new ConfigurationChangeHelper(downloadHandler, app);
} }
@Override @Override
protected void onDestroy() { protected void onDestroy() {
if (downloadHandler != null) { if (downloadHandler != null) {
if (!stateRetained) if (!inProcessOfChangingConfiguration) {
downloadHandler.cancel(); downloadHandler.cancel();
downloadHandler.destroy(); cleanUpFinishedDownload();
}
} }
inProcessOfChangingConfiguration = false;
super.onDestroy(); super.onDestroy();
} }
// Copy all relevant state from an old instance. This is used in private void removeProgressDialog() {
// place of reset(), so it must initialize all fields normally set if (progressDialog != null) {
// there. progressDialog.dismiss();
private void copyState(AppDetails old) { progressDialog = null;
if (old.downloadHandler != null) }
downloadHandler = new DownloadHandler(old.downloadHandler);
app = old.app;
mInstalledSignature = old.mInstalledSignature;
mInstalledSigID = old.mInstalledSigID;
} }
// Reset the display and list contents. Used when entering the activity, and // Reset the display and list contents. Used when entering the activity, and
// also when something has been installed/uninstalled. // also when something has been installed/uninstalled.
// Return true if the app was found, false otherwise. // Return true if the app was found, false otherwise.
private boolean reset() { private boolean reset(String appId) {
Log.d("FDroid", "Getting application details for " + appid); Log.d("FDroid", "Getting application details for " + appId);
app = null; App newApp = null;
if (appid != null && appid.length() > 0) { if (appId != null && appId.length() > 0) {
app = AppProvider.Helper.findById(getContentResolver(), appid); newApp = AppProvider.Helper.findById(getContentResolver(), appId);
} }
if (app == null) { setApp(newApp);
Toast toast = Toast.makeText(this,
getString(R.string.no_such_app), Toast.LENGTH_LONG); return this.app != null;
toast.show(); }
/**
* If passed null, this will show a message to the user ("Could not find app ..." or something
* like that) and then finish the activity.
*/
private void setApp(App newApp) {
if (newApp == null) {
Toast.makeText(this, getString(R.string.no_such_app), Toast.LENGTH_LONG).show();
finish(); finish();
return false; return;
} }
app = newApp;
startingIgnoreAll = app.ignoreAllUpdates; startingIgnoreAll = app.ignoreAllUpdates;
startingIgnoreThis = app.ignoreThisUpdate; startingIgnoreThis = app.ignoreThisUpdate;
// Get the signature of the installed package... // Get the signature of the installed package...
mInstalledSignature = null; mInstalledSignature = null;
mInstalledSigID = null; mInstalledSigID = null;
if (app.isInstalled()) { if (app.isInstalled()) {
PackageManager pm = getBaseContext().getPackageManager(); PackageManager pm = getPackageManager();
try { try {
PackageInfo pi = pm.getPackageInfo(appid, PackageInfo pi = pm.getPackageInfo(app.id, PackageManager.GET_SIGNATURES);
PackageManager.GET_SIGNATURES);
mInstalledSignature = pi.signatures[0]; mInstalledSignature = pi.signatures[0];
Hasher hash = new Hasher("MD5", mInstalledSignature Hasher hash = new Hasher("MD5", mInstalledSignature.toCharsString().getBytes());
.toCharsString().getBytes());
mInstalledSigID = hash.getHash(); mInstalledSigID = hash.getHash();
} catch (NameNotFoundException e) { } catch (NameNotFoundException e) {
Log.d("FDroid", "Failed to get installed signature"); Log.d("FDroid", "Failed to get installed signature");
@ -497,7 +585,6 @@ public class AppDetails extends ListActivity {
mInstalledSignature = null; mInstalledSignature = null;
} }
} }
return true;
} }
private void startViews() { private void startViews() {
@ -511,10 +598,10 @@ public class AppDetails extends ListActivity {
headerView.removeAllViews(); headerView.removeAllViews();
if (landparent != null) { if (landparent != null) {
landparent.addView(infoView); landparent.addView(infoView);
Log.d("FDroid", "Setting landparent infoview"); Log.d("FDroid", "Setting up landscape view");
} else { } else {
headerView.addView(infoView); headerView.addView(infoView);
Log.d("FDroid", "Setting header infoview"); Log.d("FDroid", "Setting up portrait view");
} }
// Set the icon... // Set the icon...
@ -610,8 +697,7 @@ public class AppDetails extends ListActivity {
if (permissionName.equals("ACCESS_SUPERUSER")) { if (permissionName.equals("ACCESS_SUPERUSER")) {
sb.append("\t• Full permissions to all device features and storage\n"); sb.append("\t• Full permissions to all device features and storage\n");
} else { } else {
Log.d("FDroid", "Permission not yet available: " Log.d("FDroid", "Permission not yet available: " + permissionName);
+permissionName);
} }
} }
} }
@ -912,6 +998,7 @@ public class AppDetails extends ListActivity {
// Install the version of this app denoted by 'app.curApk'. // Install the version of this app denoted by 'app.curApk'.
private void install(final Apk apk) { private void install(final Apk apk) {
final Activity activity = this;
String [] projection = { RepoProvider.DataColumns.ADDRESS }; String [] projection = { RepoProvider.DataColumns.ADDRESS };
Repo repo = RepoProvider.Helper.findById(this, apk.repo, projection); Repo repo = RepoProvider.Helper.findById(this, apk.repo, projection);
if (repo == null || repo.address == null) { if (repo == null || repo.address == null) {
@ -926,10 +1013,8 @@ public class AppDetails extends ListActivity {
new DialogInterface.OnClickListener() { new DialogInterface.OnClickListener() {
@Override @Override
public void onClick(DialogInterface dialog, public void onClick(DialogInterface dialog,
int whichButton) { int whichButton) {
downloadHandler = new DownloadHandler(apk, startDownload(apk, repoaddress);
repoaddress, Utils
.getApkCacheDir(getBaseContext()));
} }
}); });
ask_alrt.setNegativeButton(getString(R.string.no), ask_alrt.setNegativeButton(getString(R.string.no),
@ -958,9 +1043,17 @@ public class AppDetails extends ListActivity {
alert.show(); alert.show();
return; return;
} }
downloadHandler = new DownloadHandler(apk, repoaddress, startDownload(apk, repoaddress);
Utils.getApkCacheDir(getBaseContext()));
} }
private void startDownload(Apk apk, String repoAddress) {
downloadHandler = new ApkDownloader(apk, repoAddress, Utils.getApkCacheDir(getBaseContext()));
downloadHandler.setProgressListener(this);
if (downloadHandler.download()) {
updateProgressDialog();
}
}
private void installApk(File file, String packageName) { private void installApk(File file, String packageName) {
setProgressBarIndeterminateVisibility(true); setProgressBarIndeterminateVisibility(true);
@ -989,10 +1082,6 @@ public class AppDetails extends ListActivity {
@Override @Override
public void run() { public void run() {
if (operation == Installer.InstallerCallback.OPERATION_INSTALL) { if (operation == Installer.InstallerCallback.OPERATION_INSTALL) {
if (downloadHandler != null) {
downloadHandler = null;
}
PackageManagerCompat.setInstaller(mPm, app.id); PackageManagerCompat.setInstaller(mPm, app.id);
} }
@ -1039,137 +1128,115 @@ public class AppDetails extends ListActivity {
shareIntent.setType("text/plain"); shareIntent.setType("text/plain");
shareIntent.putExtra(Intent.EXTRA_SUBJECT, app.name); shareIntent.putExtra(Intent.EXTRA_SUBJECT, app.name);
shareIntent.putExtra(Intent.EXTRA_TEXT, app.name+" ("+app.summary+") - https://f-droid.org/app/"+app.id); shareIntent.putExtra(Intent.EXTRA_TEXT, app.name + " (" + app.summary + ") - https://f-droid.org/app/" + app.id);
startActivity(Intent.createChooser(shareIntent, getString(R.string.menu_share))); startActivity(Intent.createChooser(shareIntent, getString(R.string.menu_share)));
} }
private ProgressDialog createProgressDialog(String file, int p, int max) { private ProgressDialog getProgressDialog(String file) {
final ProgressDialog pd = new ProgressDialog(this); if (progressDialog == null) {
pd.setProgressStyle(ProgressDialog.STYLE_HORIZONTAL); final ProgressDialog pd = new ProgressDialog(this);
pd.setMessage(getString(R.string.download_server) + ":\n " + file); pd.setProgressStyle(ProgressDialog.STYLE_HORIZONTAL);
pd.setMax(max); pd.setMessage(getString(R.string.download_server) + ":\n " + file);
pd.setProgress(p); pd.setCancelable(true);
pd.setCancelable(true); pd.setCanceledOnTouchOutside(false);
pd.setCanceledOnTouchOutside(false);
pd.setOnCancelListener(new DialogInterface.OnCancelListener() { // The indeterminate-ness will get overridden on the first progress event we receive.
@Override pd.setIndeterminate(true);
public void onCancel(DialogInterface dialog) {
downloadHandler.cancel(); pd.setOnCancelListener(new DialogInterface.OnCancelListener() {
} @Override
}); public void onCancel(DialogInterface dialog) {
pd.setButton(DialogInterface.BUTTON_NEUTRAL, Log.d(TAG, "User clicked 'cancel' on download, attempting to interrupt download thread.");
getString(R.string.cancel), if (downloadHandler != null) {
new DialogInterface.OnClickListener() { downloadHandler.cancel();
@Override cleanUpFinishedDownload();
public void onClick(DialogInterface dialog, int which) { } else {
pd.cancel(); Log.e(TAG, "Tried to cancel, but the downloadHandler doesn't exist.");
} }
}); progressDialog = null;
pd.show(); Toast.makeText(AppDetails.this, getString(R.string.download_cancelled), Toast.LENGTH_LONG).show();
return pd; }
});
pd.setButton(DialogInterface.BUTTON_NEUTRAL,
getString(R.string.cancel),
new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialog, int which) {
pd.cancel();
}
}
);
progressDialog = pd;
}
return progressDialog;
} }
// Handler used to update the progress dialog while downloading. /**
private class DownloadHandler extends Handler { * Looks at the current <code>downloadHandler</code> and finds it's size and progress.
private Downloader download; * This is in comparison to {@link org.fdroid.fdroid.AppDetails#updateProgressDialog(int, int)},
private ProgressDialog pd; * which is used when you have the details from a freshly received
private boolean updating; * {@link org.fdroid.fdroid.ProgressListener.Event}.
private String id; */
private void updateProgressDialog() {
public DownloadHandler(Apk apk, String repoaddress, File destdir) { if (downloadHandler != null) {
id = apk.id; updateProgressDialog(downloadHandler.getProgress(), downloadHandler.getTotalSize());
download = new Downloader(apk, repoaddress, destdir);
download.start();
startUpdates();
} }
}
public DownloadHandler(DownloadHandler oldHandler) { private void updateProgressDialog(int progress, int total) {
if (oldHandler != null) { if (downloadHandler != null) {
download = oldHandler.download; ProgressDialog pd = getProgressDialog(downloadHandler.getRemoteAddress());
if (total > 0) {
pd.setIndeterminate(false);
pd.setProgress(progress);
pd.setMax(total);
} else {
pd.setIndeterminate(true);
pd.setProgress(progress);
pd.setMax(0);
} }
startUpdates(); if (!pd.isShowing()) {
} Log.d(TAG, "Showing progress dialog for download.");
pd.show();
public boolean updateProgress() {
boolean finished = false;
switch (download.getStatus()) {
case RUNNING:
if (pd == null) {
pd = createProgressDialog(download.remoteFile(),
download.getProgress(), download.getMax());
} else {
pd.setProgress(download.getProgress());
}
break;
case ERROR:
if (pd != null)
pd.dismiss();
String text;
if (download.getErrorType() == Downloader.Error.CORRUPT)
text = getString(R.string.corrupt_download);
else
text = download.getErrorMessage();
Toast.makeText(AppDetails.this, text, Toast.LENGTH_LONG).show();
finished = true;
break;
case DONE:
if (pd != null)
pd.dismiss();
installApk(download.localFile(), id);
finished = true;
break;
case CANCELLED:
Toast.makeText(AppDetails.this,
getString(R.string.download_cancelled),
Toast.LENGTH_SHORT).show();
finished = true;
break;
default:
break;
}
return finished;
}
public void startUpdates() {
if (!updating) {
updating = true;
sendEmptyMessage(0);
} }
} }
}
public void stopUpdates() { @Override
updating = false; public void onProgress(Event event) {
removeMessages(0); if (downloadHandler == null || !downloadHandler.isEventFromThis(event)) {
// Choose not to respond to events from previous downloaders.
// We don't even care if we receive "cancelled" events or the like, because
// we dealt with cancellations in the onCancel listener of the dialog,
// rather than waiting to receive the event here. We try and be careful in
// the download thread to make sure that we check for cancellations before
// sending events, but it is not possible to be perfect, because the interruption
// which triggers the download can happen after the check to see if
Log.d(TAG, "Discarding downloader event \"" + event.type + "\" as it is from an old (probably cancelled) downloader.");
return;
} }
public void cancel() { boolean finished = false;
if (download != null) if (event.type.equals(Downloader.EVENT_PROGRESS)) {
download.interrupt(); updateProgressDialog(event.progress, event.total);
} } else if (event.type.equals(ApkDownloader.EVENT_ERROR)) {
final String text;
public void destroy() { if (event.getData().getInt(ApkDownloader.EVENT_DATA_ERROR_TYPE) == ApkDownloader.ERROR_HASH_MISMATCH)
// The dialog can't be dismissed when it's not displayed, text = getString(R.string.corrupt_download);
// so do it when the activity is being destroyed.
if (pd != null) {
pd.dismiss();
pd = null;
}
// Cancel any scheduled updates so that we don't
// accidentally recreate the progress dialog.
stopUpdates();
}
// Repeatedly run updateProgress() until it's finished.
@Override
public void handleMessage(Message msg) {
if (download == null)
return;
boolean finished = updateProgress();
if (finished)
download = null;
else else
sendMessageDelayed(obtainMessage(), 50); text = getString(R.string.details_notinstalled);
// this must be on the main UI thread
Toast.makeText(this, text, Toast.LENGTH_LONG).show();
finished = true;
} else if (event.type.equals(ApkDownloader.EVENT_APK_DOWNLOAD_COMPLETE)) {
downloadCompleteInstallApk();
finished = true;
}
if (finished) {
removeProgressDialog();
downloadHandler = null;
} }
} }
@ -1183,7 +1250,7 @@ public class AppDetails extends ListActivity {
switch (requestCode) { switch (requestCode) {
case REQUEST_ENABLE_BLUETOOTH: case REQUEST_ENABLE_BLUETOOTH:
fdroidApp.sendViaBluetooth(this, resultCode, app.id); fdroidApp.sendViaBluetooth(this, resultCode, app.id);
break; break;
} }
} }
} }

View File

@ -1,195 +0,0 @@
/*
* Copyright (C) 2010-2012 Ciaran Gultnieks <ciaran@ciarang.com>
* Copyright (C) 2011 Henrik Tunedal <tunedal@gmail.com>
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
* as published by the Free Software Foundation; either version 3
* of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
* MA 02110-1301, USA.
*/
package org.fdroid.fdroid;
import java.io.File;
import java.io.FileOutputStream;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.URL;
import android.util.Log;
import org.fdroid.fdroid.data.Apk;
public class Downloader extends Thread {
private Apk curapk;
private String repoaddress;
private String filename;
private File destdir;
private File localfile;
public static enum Status {
STARTING, RUNNING, ERROR, DONE, CANCELLED
}
public static enum Error {
CORRUPT, UNKNOWN
}
private Status status = Status.STARTING;
private Error error;
private int progress;
private int max;
private String errorMessage;
// Constructor - creates a Downloader to download the given Apk,
// which must have its detail populated.
Downloader(Apk apk, String repoaddress, File destdir) {
curapk = apk;
this.repoaddress = repoaddress;
this.destdir = destdir;
}
public synchronized Status getStatus() {
return status;
}
// Current progress and maximum value for progress dialog
public synchronized int getProgress() {
return progress;
}
public synchronized int getMax() {
return max;
}
// Error code and error message, only valid if status is ERROR
public synchronized Error getErrorType() {
return error;
}
public synchronized String getErrorMessage() {
return errorMessage;
}
// The URL being downloaded or path to a cached file
public synchronized String remoteFile() {
return filename;
}
// The downloaded APK. Valid only when getStatus() has returned STATUS.DONE.
public File localFile() {
return localfile;
}
// The APK being downloaded
public synchronized Apk getApk() {
return curapk;
}
@Override
public void run() {
InputStream input = null;
OutputStream output = null;
String apkname = curapk.apkName;
localfile = new File(destdir, apkname);
try {
// 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.hashType, localfile);
if (hash.match(curapk.hash)) {
Log.d("FDroid", "Using cached apk at " + localfile);
synchronized (this) {
progress = 1;
max = 1;
status = Status.DONE;
return;
}
} else {
Log.d("FDroid", "Not using cached apk at " + localfile);
localfile.delete();
}
}
// If we haven't got the apk locally, we'll have to download it...
String remotefile;
remotefile = repoaddress + "/" + apkname.replace(" ", "%20");
Log.d("FDroid", "Downloading apk from " + remotefile);
synchronized (this) {
filename = remotefile;
progress = 0;
max = curapk.size;
status = Status.RUNNING;
}
input = new URL(remotefile).openStream();
output = new FileOutputStream(localfile);
byte data[] = new byte[Utils.BUFFER_SIZE];
while (true) {
if (isInterrupted()) {
Log.d("FDroid", "Download cancelled!");
break;
}
int count = input.read(data);
if (count == -1) {
break;
}
output.write(data, 0, count);
synchronized (this) {
progress += count;
}
}
if (isInterrupted()) {
localfile.delete();
synchronized (this) {
status = Status.CANCELLED;
}
return;
}
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.hash);
// No point keeping a bad file, whether we're
// caching or not.
localfile.delete();
error = Error.CORRUPT;
errorMessage = null;
status = Status.ERROR;
return;
}
}
} catch (Exception e) {
Log.e("FDroid", "Download failed:\n" + Log.getStackTraceString(e));
synchronized (this) {
localfile.delete();
error = Error.UNKNOWN;
errorMessage = e.toString();
status = Status.ERROR;
return;
}
} finally {
Utils.closeQuietly(output);
Utils.closeQuietly(input);
}
Log.d("FDroid", "Download finished: " + localfile);
synchronized (this) {
status = Status.DONE;
}
}
}

View File

@ -1,8 +1,10 @@
package org.fdroid.fdroid; package org.fdroid.fdroid;
import android.os.Bundle; import android.os.Bundle;
import android.os.Parcel; import android.os.Parcel;
import android.os.Parcelable; import android.os.Parcelable;
import android.text.TextUtils;
public interface ProgressListener { public interface ProgressListener {
@ -15,7 +17,7 @@ public interface ProgressListener {
public static final int NO_VALUE = Integer.MIN_VALUE; public static final int NO_VALUE = Integer.MIN_VALUE;
public final int type; public final String type;
public final Bundle data; public final Bundle data;
// These two are not final, so that you can create a template Event, // These two are not final, so that you can create a template Event,
@ -25,31 +27,19 @@ public interface ProgressListener {
public int progress; public int progress;
public int total; public int total;
public Event(int type) { public Event(String type) {
this(type, NO_VALUE, NO_VALUE, null); this(type, NO_VALUE, NO_VALUE, null);
} }
public Event(int type, Bundle data) { public Event(String type, Bundle data) {
this(type, NO_VALUE, NO_VALUE, data); this(type, NO_VALUE, NO_VALUE, data);
} }
public Event(int type, int progress) { public Event(String type, int progress, int total, Bundle data) {
this(type, progress, NO_VALUE, null);
}
public Event(int type, int progress, Bundle data) {
this(type, NO_VALUE, NO_VALUE, data);
}
public Event(int type, int progress, int total) {
this(type, progress, total, null);
}
public Event(int type, int progress, int total, Bundle data) {
this.type = type; this.type = type;
this.progress = progress; this.progress = progress;
this.total = total; this.total = total;
this.data = data == null ? new Bundle() : data; this.data = (data == null) ? new Bundle() : data;
} }
@Override @Override
@ -59,7 +49,7 @@ public interface ProgressListener {
@Override @Override
public void writeToParcel(Parcel dest, int flags) { public void writeToParcel(Parcel dest, int flags) {
dest.writeInt(type); dest.writeString(type);
dest.writeInt(progress); dest.writeInt(progress);
dest.writeInt(total); dest.writeInt(total);
dest.writeBundle(data); dest.writeBundle(data);
@ -68,7 +58,7 @@ public interface ProgressListener {
public static final Parcelable.Creator<Event> CREATOR = new Parcelable.Creator<Event>() { public static final Parcelable.Creator<Event> CREATOR = new Parcelable.Creator<Event>() {
@Override @Override
public Event createFromParcel(Parcel in) { public Event createFromParcel(Parcel in) {
return new Event(in.readInt(), in.readInt(), in.readInt(), in.readBundle()); return new Event(in.readString(), in.readInt(), in.readInt(), in.readBundle());
} }
@Override @Override
@ -77,6 +67,16 @@ public interface ProgressListener {
} }
}; };
/**
* Can help to provide context to the listener about what process is causing the event.
* For example, the repo updater uses one listener to listen to multiple downloaders.
* When it receives an event, it doesn't know which repo download is causing the event,
* so we pass that through to the downloader when we set the progress listener. This way,
* we can ask the event for the name of the repo.
*/
public Bundle getData() {
return data;
}
} }
} }

View File

@ -279,12 +279,13 @@ public class RepoXMLHandler extends DefaultHandler {
} else if (localName.equals("application") && curapp == null) { } else if (localName.equals("application") && curapp == null) {
curapp = new App(); curapp = new App();
curapp.id = attributes.getValue("", "id"); curapp.id = attributes.getValue("", "id");
Bundle progressData = RepoUpdater.createProgressData(repo.address);
progressCounter ++; progressCounter ++;
Bundle data = new Bundle(1);
data.putString(RepoUpdater.PROGRESS_DATA_REPO_ADDRESS, repo.address);
progressListener.onProgress( progressListener.onProgress(
new ProgressListener.Event( new ProgressListener.Event(
RepoUpdater.PROGRESS_TYPE_PROCESS_XML, progressCounter, RepoUpdater.PROGRESS_TYPE_PROCESS_XML,
totalAppCount, progressData)); progressCounter, totalAppCount, data));
} else if (localName.equals("package") && curapp != null && curapk == null) { } else if (localName.equals("package") && curapp != null && curapk == null) {
curapk = new Apk(); curapk = new Apk();

View File

@ -33,6 +33,7 @@ import android.text.TextUtils;
import android.util.Log; import android.util.Log;
import android.widget.Toast; import android.widget.Toast;
import org.fdroid.fdroid.data.*; import org.fdroid.fdroid.data.*;
import org.fdroid.fdroid.net.Downloader;
import org.fdroid.fdroid.updater.RepoUpdater; import org.fdroid.fdroid.updater.RepoUpdater;
import java.util.*; import java.util.*;
@ -47,6 +48,14 @@ public class UpdateService extends IntentService implements ProgressListener {
public static final int STATUS_ERROR = 2; public static final int STATUS_ERROR = 2;
public static final int STATUS_INFO = 3; public static final int STATUS_INFO = 3;
// I don't like that I've had to dupliacte the statuses above with strings here, however
// one method of communication/notification is using ResultReceiver (int status codes)
// while the other uses progress events (string event types).
public static final String EVENT_COMPLETE_WITH_CHANGES = "repoUpdateComplete (changed)";
public static final String EVENT_COMPLETE_AND_SAME = "repoUpdateComplete (not changed)";
public static final String EVENT_ERROR = "repoUpdateError";
public static final String EVENT_INFO = "repoUpdateInfo";
public static final String EXTRA_RECEIVER = "receiver"; public static final String EXTRA_RECEIVER = "receiver";
public static final String EXTRA_ADDRESS = "address"; public static final String EXTRA_ADDRESS = "address";
@ -97,28 +106,31 @@ public class UpdateService extends IntentService implements ProgressListener {
return this; return this;
} }
private void forwardEvent(String type) {
if (listener != null) {
listener.onProgress(new Event(type));
}
}
@Override @Override
protected void onReceiveResult(int resultCode, Bundle resultData) { protected void onReceiveResult(int resultCode, Bundle resultData) {
String message = resultData.getString(UpdateService.RESULT_MESSAGE); String message = resultData.getString(UpdateService.RESULT_MESSAGE);
boolean finished = false; boolean finished = false;
if (resultCode == UpdateService.STATUS_ERROR) { if (resultCode == UpdateService.STATUS_ERROR) {
forwardEvent(EVENT_ERROR);
Toast.makeText(context, message, Toast.LENGTH_LONG).show(); Toast.makeText(context, message, Toast.LENGTH_LONG).show();
finished = true; finished = true;
} else if (resultCode == UpdateService.STATUS_COMPLETE_WITH_CHANGES } else if (resultCode == UpdateService.STATUS_COMPLETE_WITH_CHANGES) {
|| resultCode == UpdateService.STATUS_COMPLETE_AND_SAME) { forwardEvent(EVENT_COMPLETE_WITH_CHANGES);
finished = true;
} else if (resultCode == UpdateService.STATUS_COMPLETE_AND_SAME) {
forwardEvent(EVENT_COMPLETE_AND_SAME);
finished = true; finished = true;
} else if (resultCode == UpdateService.STATUS_INFO) { } else if (resultCode == UpdateService.STATUS_INFO) {
forwardEvent(EVENT_INFO);
dialog.setMessage(message); dialog.setMessage(message);
} }
// Forward the progress event on to anybody else who'd like to know.
if (listener != null) {
Parcelable event = resultData.getParcelable(UpdateService.RESULT_EVENT);
if (event != null && event instanceof Event) {
listener.onProgress((Event)event);
}
}
if (finished && dialog.isShowing()) if (finished && dialog.isShowing())
try { try {
dialog.dismiss(); dialog.dismiss();
@ -185,17 +197,10 @@ public class UpdateService extends IntentService implements ProgressListener {
} }
protected void sendStatus(int statusCode, String message) { protected void sendStatus(int statusCode, String message) {
sendStatus(statusCode, message, null);
}
protected void sendStatus(int statusCode, String message, Event event) {
if (receiver != null) { if (receiver != null) {
Bundle resultData = new Bundle(); Bundle resultData = new Bundle();
if (message != null && message.length() > 0) if (message != null && message.length() > 0)
resultData.putString(RESULT_MESSAGE, message); resultData.putString(RESULT_MESSAGE, message);
if (event == null)
event = new Event(statusCode);
resultData.putParcelable(RESULT_EVENT, event);
receiver.send(statusCode, resultData); receiver.send(statusCode, resultData);
} }
} }
@ -675,14 +680,15 @@ public class UpdateService extends IntentService implements ProgressListener {
@Override @Override
public void onProgress(ProgressListener.Event event) { public void onProgress(ProgressListener.Event event) {
String message = ""; String message = "";
if (event.type == RepoUpdater.PROGRESS_TYPE_DOWNLOAD) { // TODO: Switch to passing through Bundles of data with the event, rather than a repo address. They are
String repoAddress = event.data.getString(RepoUpdater.PROGRESS_DATA_REPO); // now much more general purpose then just repo downloading.
String downloadedSize = Utils.getFriendlySize( event.progress ); String repoAddress = event.getData().getString(RepoUpdater.PROGRESS_DATA_REPO_ADDRESS);
String totalSize = Utils.getFriendlySize( event.total ); if (event.type.equals(Downloader.EVENT_PROGRESS)) {
String downloadedSize = Utils.getFriendlySize(event.progress);
String totalSize = Utils.getFriendlySize(event.total);
int percent = (int)((double)event.progress/event.total * 100); int percent = (int)((double)event.progress/event.total * 100);
message = getString(R.string.status_download, repoAddress, downloadedSize, totalSize, percent); message = getString(R.string.status_download, repoAddress, downloadedSize, totalSize, percent);
} else if (event.type == RepoUpdater.PROGRESS_TYPE_PROCESS_XML) { } else if (event.type.equals(RepoUpdater.PROGRESS_TYPE_PROCESS_XML)) {
String repoAddress = event.data.getString(RepoUpdater.PROGRESS_DATA_REPO);
message = getString(R.string.status_processing_xml, repoAddress, event.progress, event.total); message = getString(R.string.status_processing_xml, repoAddress, event.progress, event.total);
} }
sendStatus(STATUS_INFO, message); sendStatus(STATUS_INFO, message);

View File

@ -0,0 +1,277 @@
/*
* Copyright (C) 2010-2012 Ciaran Gultnieks <ciaran@ciarang.com>
* Copyright (C) 2011 Henrik Tunedal <tunedal@gmail.com>
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
* as published by the Free Software Foundation; either version 3
* of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
* MA 02110-1301, USA.
*/
package org.fdroid.fdroid.net;
import android.os.Bundle;
import android.util.Log;
import org.fdroid.fdroid.Hasher;
import org.fdroid.fdroid.ProgressListener;
import org.fdroid.fdroid.data.Apk;
import java.io.File;
import java.io.IOException;
import java.net.MalformedURLException;
import java.security.NoSuchAlgorithmException;
/**
* Downloads and verifies (against the Apk.hash) the apk file.
* If the file has previously been downloaded, it will make use of that
* instead, without going to the network to download a new one.
*/
public class ApkDownloader implements AsyncDownloadWrapper.Listener {
private static final String TAG = "org.fdroid.fdroid.net.ApkDownloader";
public static final String EVENT_APK_DOWNLOAD_COMPLETE = "apkDownloadComplete";
public static final String EVENT_APK_DOWNLOAD_CANCELLED = "apkDownloadCancelled";
public static final String EVENT_ERROR = "apkDownloadError";
public static final int ERROR_HASH_MISMATCH = 101;
public static final int ERROR_DOWNLOAD_FAILED = 102;
public static final int ERROR_UNKNOWN = 103;
private static final String EVENT_SOURCE_ID = "sourceId";
private static long downloadIdCounter = 0;
/**
* Used as a key to pass data through with an error event, explaining the type of event.
*/
public static final String EVENT_DATA_ERROR_TYPE = "apkDownloadErrorType";
private Apk curApk;
private String repoAddress;
private File localFile;
private ProgressListener listener;
private AsyncDownloadWrapper dlWrapper = null;
private int progress = 0;
private int totalSize = 0;
private boolean isComplete = false;
private long id = ++downloadIdCounter;
public void setProgressListener(ProgressListener listener) {
this.listener = listener;
}
public void removeProgressListener() {
setProgressListener(null);
}
public ApkDownloader(Apk apk, String repoAddress, File destDir) {
curApk = apk;
this.repoAddress = repoAddress;
localFile = new File(destDir, curApk.apkName);
}
/**
* The downloaded APK. Valid only when getStatus() has returned STATUS.DONE.
*/
public File localFile() {
return localFile;
}
/**
* When stopping/starting downloaders multiple times (on different threads), it can
* get weird whereby different threads are sending progress events. It is important
* to be able to see which downloader these progress events are coming from.
*/
public boolean isEventFromThis(Event event) {
return event.getData().containsKey(EVENT_SOURCE_ID) && event.getData().getLong(EVENT_SOURCE_ID) == id;
}
public String getRemoteAddress() {
return repoAddress + "/" + curApk.apkName.replace(" ", "%20");
}
private Hasher createHasher() {
Hasher hasher;
try {
hasher = new Hasher(curApk.hashType, localFile);
} catch (NoSuchAlgorithmException e) {
Log.e("FDroid", "Error verifying hash of cached apk at " + localFile + ". " +
"I don't understand what the " + curApk.hashType + " hash algorithm is :(");
hasher = null;
}
return hasher;
}
private boolean hashMatches() {
if (!localFile.exists()) {
return false;
}
Hasher hasher = createHasher();
return hasher != null && hasher.match(curApk.hash);
}
/**
* If an existing cached version exists, and matches the hash of the apk we
* want to download, then we will return true. Otherwise, we return false
* (and remove the cached file - if it exists and didn't match the correct hash).
*/
private boolean verifyOrDeleteCachedVersion() {
if (localFile.exists()) {
if (hashMatches()) {
Log.d("FDroid", "Using cached apk at " + localFile);
return true;
} else {
Log.d("FDroid", "Not using cached apk at " + localFile);
deleteLocalFile();
}
}
return false;
}
private void deleteLocalFile() {
if (localFile != null && localFile.exists()) {
localFile.delete();
}
}
private void sendCompleteMessage() {
isComplete = true;
sendMessage(EVENT_APK_DOWNLOAD_COMPLETE);
}
public boolean isComplete() {
return this.isComplete;
}
/**
* If the download successfully spins up a new thread to start downloading, then we return
* true, otherwise false. This is useful, e.g. when we use a cached version, and so don't
* want to bother with progress dialogs et al.
*/
public boolean download() {
// Can we use the cached version?
if (verifyOrDeleteCachedVersion()) {
sendCompleteMessage();
return false;
}
String remoteAddress = getRemoteAddress();
Log.d(TAG, "Downloading apk from " + remoteAddress);
try {
Downloader downloader = new HttpDownloader(remoteAddress, localFile);
dlWrapper = new AsyncDownloadWrapper(downloader, this);
dlWrapper.download();
return true;
} catch (MalformedURLException e) {
onErrorDownloading(e.getLocalizedMessage());
} catch (IOException e) {
onErrorDownloading(e.getLocalizedMessage());
}
return false;
}
private void sendMessage(String type) {
sendProgressEvent(new ProgressListener.Event(type));
}
private void sendError(int errorType) {
Bundle data = new Bundle(1);
data.putInt(EVENT_DATA_ERROR_TYPE, errorType);
sendProgressEvent(new Event(EVENT_ERROR, data));
}
private void sendProgressEvent(Event event) {
if (event.type.equals(Downloader.EVENT_PROGRESS)) {
// Keep a copy of these ourselves, so people can interrogate us for the
// info (in addition to receiving events with the info).
totalSize = event.total;
progress = event.progress;
}
event.getData().putLong(EVENT_SOURCE_ID, id);
if (listener != null) {
listener.onProgress(event);
}
}
@Override
public void onReceiveTotalDownloadSize(int size) {
// Do nothing...
// Rather, we will obtain the total download size from the progress events
// when they start coming through.
}
@Override
public void onReceiveCacheTag(String cacheTag) {
// Do nothing...
}
@Override
public void onErrorDownloading(String localisedExceptionDetails) {
Log.e("FDroid", "Download failed: " + localisedExceptionDetails);
sendError(ERROR_DOWNLOAD_FAILED);
deleteLocalFile();
}
@Override
public void onDownloadComplete() {
if (!verifyOrDeleteCachedVersion()) {
sendError(ERROR_HASH_MISMATCH);
return;
}
Log.d("FDroid", "Download finished: " + localFile);
sendCompleteMessage();
}
@Override
public void onDownloadCancelled() {
sendMessage(EVENT_APK_DOWNLOAD_CANCELLED);
}
@Override
public void onProgress(Event event) {
sendProgressEvent(event);
}
/**
* Attempts to cancel the download (if in progress) and also removes the progress
* listener (to prevent
*/
public void cancel() {
if (dlWrapper != null) {
dlWrapper.attemptCancel();
}
}
public Apk getApk() {
return curApk;
}
public int getProgress() {
return progress;
}
public int getTotalSize() {
return totalSize;
}
}

View File

@ -0,0 +1,145 @@
package org.fdroid.fdroid.net;
import android.os.Bundle;
import android.os.Handler;
import android.os.Message;
import android.util.Log;
import org.fdroid.fdroid.ProgressListener;
import java.io.IOException;
/**
* Given a {@link org.fdroid.fdroid.net.Downloader}, this wrapper will conduct the download operation on a
* separate thread. All progress/status/error/etc events will be forwarded from that thread to the thread
* that {@link AsyncDownloadWrapper#download()} was invoked on. If you want to respond with UI feedback
* to these events, it is important that you execute the download method of this class from the UI thread.
* That way, all forwarded events will be handled on that thread.
*/
public class AsyncDownloadWrapper extends Handler {
private static final String TAG = "org.fdroid.fdroid.net.AsyncDownloadWrapper";
private static final int MSG_PROGRESS = 1;
private static final int MSG_DOWNLOAD_COMPLETE = 2;
private static final int MSG_DOWNLOAD_CANCELLED = 3;
private static final int MSG_ERROR = 4;
private static final String MSG_DATA = "data";
private Downloader downloader;
private Listener listener;
private DownloadThread downloadThread = null;
/**
* Normally the listener would be provided using a setListener method.
* However for the purposes of this async downloader, it doesn't make
* sense to have an async task without any way to notify the outside
* world about completion. Therefore, we require the listener as a
* parameter to the constructor.
*/
public AsyncDownloadWrapper(Downloader downloader, Listener listener) {
this.downloader = downloader;
this.listener = listener;
}
public void fetchTotalDownloadSize() {
int size = downloader.totalDownloadSize();
listener.onReceiveTotalDownloadSize(size);
}
public void fetchCacheTag() {
String cacheTag = downloader.getCacheTag();
listener.onReceiveCacheTag(cacheTag);
}
public void download() {
downloadThread = new DownloadThread();
downloadThread.start();
}
public void attemptCancel() {
if (downloadThread != null) {
downloadThread.interrupt();
}
}
public static class NotDownloadingException extends Exception {
public NotDownloadingException(String message) {
super(message);
}
}
public void cancelDownload() throws NotDownloadingException {
if (downloadThread == null) {
throw new RuntimeException("Can't cancel download, it hasn't started yet.");
} else if (!downloadThread.isAlive()) {
throw new RuntimeException("Can't cancel download, it is already finished.");
}
downloadThread.interrupt();
}
/**
* Receives "messages" from the download thread, and passes them onto the
* relevant {@link org.fdroid.fdroid.net.AsyncDownloadWrapper.Listener}
* @param message
*/
public void handleMessage(Message message) {
if (message.arg1 == MSG_PROGRESS) {
Bundle data = message.getData();
ProgressListener.Event event = data.getParcelable(MSG_DATA);
listener.onProgress(event);
} else if (message.arg1 == MSG_DOWNLOAD_COMPLETE) {
listener.onDownloadComplete();
} else if (message.arg1 == MSG_DOWNLOAD_CANCELLED) {
listener.onDownloadCancelled();
} else if (message.arg1 == MSG_ERROR) {
listener.onErrorDownloading(message.getData().getString(MSG_DATA));
}
}
public interface Listener extends ProgressListener {
public void onReceiveTotalDownloadSize(int size);
public void onReceiveCacheTag(String cacheTag);
public void onErrorDownloading(String localisedExceptionDetails);
public void onDownloadComplete();
public void onDownloadCancelled();
}
private class DownloadThread extends Thread implements ProgressListener {
public void run() {
try {
downloader.setProgressListener(this);
downloader.download();
sendMessage(MSG_DOWNLOAD_COMPLETE);
} catch (InterruptedException e) {
sendMessage(MSG_DOWNLOAD_CANCELLED);
} catch (IOException e) {
Log.e(TAG, e.getMessage() + ": " + Log.getStackTraceString(e));
Bundle data = new Bundle(1);
data.putString(MSG_DATA, e.getLocalizedMessage());
Message message = new Message();
message.arg1 = MSG_ERROR;
message.setData(data);
AsyncDownloadWrapper.this.sendMessage(message);
}
}
private void sendMessage(int messageType) {
Message message = new Message();
message.arg1 = messageType;
AsyncDownloadWrapper.this.sendMessage(message);
}
@Override
public void onProgress(Event event) {
Message message = new Message();
Bundle data = new Bundle();
data.putParcelable(MSG_DATA, event);
message.setData(data);
message.arg1 = MSG_PROGRESS;
AsyncDownloadWrapper.this.sendMessage(message);
}
}
}

View File

@ -1,55 +1,86 @@
package org.fdroid.fdroid.net; package org.fdroid.fdroid.net;
import java.io.*; import android.content.Context;
import java.net.*; import android.os.Bundle;
import android.content.*; import android.util.Log;
import org.fdroid.fdroid.*;
public class Downloader { import org.fdroid.fdroid.ProgressListener;
import org.fdroid.fdroid.Utils;
private static final String HEADER_IF_NONE_MATCH = "If-None-Match"; import java.io.File;
private static final String HEADER_FIELD_ETAG = "ETag"; import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.MalformedURLException;
private URL sourceUrl; public abstract class Downloader {
private static final String TAG = "org.fdroid.fdroid.net.Downloader";
private OutputStream outputStream; private OutputStream outputStream;
private ProgressListener progressListener = null; private ProgressListener progressListener = null;
private ProgressListener.Event progressEvent = null; private Bundle eventData = null;
private String eTag = null; private File outputFile;
private final File outputFile; protected String cacheTag = null;
private HttpURLConnection connection;
private int statusCode = -1; public static final String EVENT_PROGRESS = "downloadProgress";
public abstract InputStream inputStream() throws IOException;
// The context is required for opening the file to write to. // The context is required for opening the file to write to.
public Downloader(String source, String destFile, Context ctx) public Downloader(String destFile, Context ctx)
throws FileNotFoundException, MalformedURLException { throws FileNotFoundException, MalformedURLException {
sourceUrl = new URL(source); this(new File(ctx.getFilesDir() + File.separator + destFile));
outputStream = ctx.openFileOutput(destFile, Context.MODE_PRIVATE);
outputFile = new File(ctx.getFilesDir() + File.separator + destFile);
} }
/** // The context is required for opening the file to write to.
* Downloads to a temporary file, which *you must delete yourself when public Downloader(Context ctx) throws IOException {
* you are done*. this(File.createTempFile("dl-", "", ctx.getCacheDir()));
* @see org.fdroid.fdroid.net.Downloader#getFile() }
*/
public Downloader(String source, Context ctx) throws IOException { public Downloader(File destFile)
throws FileNotFoundException, MalformedURLException {
// http://developer.android.com/guide/topics/data/data-storage.html#InternalCache // http://developer.android.com/guide/topics/data/data-storage.html#InternalCache
outputFile = File.createTempFile("dl-", "", ctx.getCacheDir()); outputFile = destFile;
outputStream = new FileOutputStream(outputFile); outputStream = new FileOutputStream(outputFile);
sourceUrl = new URL(source);
} }
public Downloader(String source, OutputStream output) public Downloader(OutputStream output)
throws MalformedURLException { throws MalformedURLException {
sourceUrl = new URL(source);
outputStream = output; outputStream = output;
outputFile = null; outputFile = null;
} }
public void setProgressListener(ProgressListener progressListener, public void setProgressListener(ProgressListener listener) {
ProgressListener.Event progressEvent) { setProgressListener(listener, null);
this.progressListener = progressListener; }
this.progressEvent = progressEvent;
public void setProgressListener(ProgressListener listener, Bundle eventData) {
this.progressListener = listener;
this.eventData = eventData;
}
/**
* If you ask for the cacheTag before calling download(), you will get the
* same one you passed in (if any). If you call it after download(), you
* will get the new cacheTag from the server, or null if there was none.
*/
public String getCacheTag() {
return cacheTag;
}
/**
* If this cacheTag matches that returned by the server, then no download will
* take place, and a status code of 304 will be returned by download().
*/
public void setCacheTag(String cacheTag) {
this.cacheTag = cacheTag;
}
protected boolean wantToCheckCache() {
return cacheTag != null;
} }
/** /**
@ -61,82 +92,103 @@ public class Downloader {
return outputFile; return outputFile;
} }
public abstract boolean hasChanged();
public abstract int totalDownloadSize();
/** /**
* Only available after downloading a file. * Helper function for synchronous downloads (i.e. those *not* using AsyncDownloadWrapper),
* which don't really want to bother dealing with an InterruptedException.
* The InterruptedException thrown from download() is there to enable cancelling asynchronous
* downloads, but regular synchronous downloads cannot be cancelled because download() will
* block until completed.
* @throws IOException
*/ */
public int getStatusCode() { public void downloadUninterrupted() throws IOException {
return statusCode; try {
download();
} catch (InterruptedException ignored) {}
}
public abstract void download() throws IOException, InterruptedException;
public abstract boolean isCached();
protected void downloadFromStream() throws IOException, InterruptedException {
Log.d(TAG, "Downloading from stream");
InputStream input = null;
try {
input = inputStream();
// Getting the input stream is slow(ish) for HTTP downloads, so we'll check if
// we were interrupted before proceeding to the download.
throwExceptionIfInterrupted();
copyInputToOutputStream(inputStream());
} finally {
Utils.closeQuietly(outputStream);
Utils.closeQuietly(input);
}
// Even if we have completely downloaded the file, we should probably respect
// the wishes of the user who wanted to cancel us.
throwExceptionIfInterrupted();
} }
/** /**
* If you ask for the eTag before calling download(), you will get the * In a synchronous download (the usual usage of the Downloader interface),
* same one you passed in (if any). If you call it after download(), you * you will not be able to interrupt this because the thread will block
* will get the new eTag from the server, or null if there was none. * after you have called download(). However if you use the AsyncDownloadWrapper,
* then it will use this mechanism to cancel the download.
*
* After every network operation that could take a while, we will check if an
* interrupt occured during that blocking operation. The goal is to ensure we
* don't move onto another slow, network operation if we have cancelled the
* download.
* @throws InterruptedException
*/ */
public String getETag() { private void throwExceptionIfInterrupted() throws InterruptedException {
return eTag; if (Thread.interrupted()) {
Log.d(TAG, "Received interrupt, cancelling download");
throw new InterruptedException();
}
} }
/** protected void copyInputToOutputStream(InputStream input) throws IOException, InterruptedException {
* If this eTag matches that returned by the server, then no download will
* take place, and a status code of 304 will be returned by download().
*/
public void setETag(String eTag) {
this.eTag = eTag;
}
// Get a remote file. Returns the HTTP response code. byte[] buffer = new byte[Utils.BUFFER_SIZE];
// If 'etag' is not null, it's passed to the server as an If-None-Match int bytesRead = 0;
// header, in which case expect a 304 response if nothing changed. int totalBytes = totalDownloadSize();
// In the event of a 200 response ONLY, 'retag' (which should be passed
// empty) may contain an etag value for the response, or it may be left // Getting the total download size could potentially take time, depending on how
// empty if none was available. // it is implemented, so we may as well check this before we proceed.
public int download() throws IOException { throwExceptionIfInterrupted();
connection = (HttpURLConnection)sourceUrl.openConnection();
setupCacheCheck(); sendProgress(bytesRead, totalBytes);
statusCode = connection.getResponseCode(); while (true) {
if (statusCode == 200) {
setupProgressListener(); int count = input.read(buffer);
InputStream input = null; throwExceptionIfInterrupted();
try {
input = connection.getInputStream(); bytesRead += count;
Utils.copy(input, outputStream, sendProgress(bytesRead, totalBytes);
progressListener, progressEvent); if (count == -1) {
} finally { Log.d(TAG, "Finished downloading from stream");
Utils.closeQuietly(outputStream); break;
Utils.closeQuietly(input);
} }
updateCacheCheck(); outputStream.write(buffer, 0, count);
} }
return statusCode; outputStream.flush();
} }
protected void setupCacheCheck() { protected void sendProgress(int bytesRead, int totalBytes) {
if (eTag != null) { sendProgress(new ProgressListener.Event(EVENT_PROGRESS, bytesRead, totalBytes, eventData));
connection.setRequestProperty(HEADER_IF_NONE_MATCH, eTag); }
protected void sendProgress(ProgressListener.Event event) {
if (progressListener != null) {
progressListener.onProgress(event);
} }
} }
protected void updateCacheCheck() {
eTag = connection.getHeaderField(HEADER_FIELD_ETAG);
}
protected void setupProgressListener() {
if (progressListener != null && progressEvent != null) {
// Testing in the emulator for me, showed that figuring out the
// filesize took about 1 to 1.5 seconds.
// To put this in context, downloading a repo of:
// - 400k takes ~6 seconds
// - 5k takes ~3 seconds
// on my connection. I think the 1/1.5 seconds is worth it,
// because as the repo grows, the tradeoff will
// become more worth it.
progressEvent.total = connection.getContentLength();
}
}
public boolean hasChanged() {
return this.statusCode == 200;
}
} }

View File

@ -0,0 +1,128 @@
package org.fdroid.fdroid.net;
import android.content.Context;
import android.util.Log;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.UnknownHostException;
import javax.net.ssl.SSLHandshakeException;
public class HttpDownloader extends Downloader {
private static final String TAG = "org.fdroid.fdroid.net.HttpDownloader";
private static final String HEADER_IF_NONE_MATCH = "If-None-Match";
private static final String HEADER_FIELD_ETAG = "ETag";
private URL sourceUrl;
private HttpURLConnection connection;
private int statusCode = -1;
// The context is required for opening the file to write to.
public HttpDownloader(String source, String destFile, Context ctx)
throws FileNotFoundException, MalformedURLException {
super(destFile, ctx);
sourceUrl = new URL(source);
}
// The context is required for opening the file to write to.
public HttpDownloader(String source, File destFile)
throws FileNotFoundException, MalformedURLException {
super(destFile);
sourceUrl = new URL(source);
}
/**
* Downloads to a temporary file, which *you must delete yourself when
* you are done*.
* @see org.fdroid.fdroid.net.HttpDownloader#getFile()
*/
public HttpDownloader(String source, Context ctx) throws IOException {
super(ctx);
sourceUrl = new URL(source);
}
public HttpDownloader(String source, OutputStream output)
throws MalformedURLException {
super(output);
sourceUrl = new URL(source);
}
public InputStream inputStream() throws IOException {
return connection.getInputStream();
}
// Get a remote file. Returns the HTTP response code.
// If 'etag' is not null, it's passed to the server as an If-None-Match
// header, in which case expect a 304 response if nothing changed.
// In the event of a 200 response ONLY, 'retag' (which should be passed
// empty) may contain an etag value for the response, or it may be left
// empty if none was available.
@Override
public void download() throws IOException, InterruptedException {
try {
connection = (HttpURLConnection)sourceUrl.openConnection();
if (wantToCheckCache()) {
setupCacheCheck();
Log.i(TAG, "Checking cached status of " + sourceUrl);
statusCode = connection.getResponseCode();
}
if (isCached()) {
Log.i(TAG, sourceUrl + " is cached, so not downloading (HTTP " + statusCode + ")");
} else {
Log.i(TAG, "Downloading from " + sourceUrl);
downloadFromStream();
updateCacheCheck();
}
} catch (SSLHandshakeException e) {
// TODO this should be handled better, it is not internationalised here.
throw new IOException(
"A problem occurred while establishing an SSL " +
"connection. If this problem persists, AND you have a " +
"very old device, you could try using http instead of " +
"https for the repo URL." + Log.getStackTraceString(e) );
}
}
public boolean isCached() {
return wantToCheckCache() && statusCode == 304;
}
private void setupCacheCheck() {
if (cacheTag != null) {
connection.setRequestProperty(HEADER_IF_NONE_MATCH, cacheTag);
}
}
private void updateCacheCheck() {
cacheTag = connection.getHeaderField(HEADER_FIELD_ETAG);
}
// Testing in the emulator for me, showed that figuring out the
// filesize took about 1 to 1.5 seconds.
// To put this in context, downloading a repo of:
// - 400k takes ~6 seconds
// - 5k takes ~3 seconds
// on my connection. I think the 1/1.5 seconds is worth it,
// because as the repo grows, the tradeoff will
// become more worth it.
@Override
public int totalDownloadSize() {
return connection.getContentLength();
}
@Override
public boolean hasChanged() {
return this.statusCode != 304;
}
}

View File

@ -4,6 +4,7 @@ import android.content.ContentValues;
import android.content.Context; import android.content.Context;
import android.os.Bundle; import android.os.Bundle;
import android.util.Log; import android.util.Log;
import org.fdroid.fdroid.ProgressListener; import org.fdroid.fdroid.ProgressListener;
import org.fdroid.fdroid.RepoXMLHandler; import org.fdroid.fdroid.RepoXMLHandler;
import org.fdroid.fdroid.Utils; import org.fdroid.fdroid.Utils;
@ -12,24 +13,29 @@ import org.fdroid.fdroid.data.App;
import org.fdroid.fdroid.data.Repo; import org.fdroid.fdroid.data.Repo;
import org.fdroid.fdroid.data.RepoProvider; import org.fdroid.fdroid.data.RepoProvider;
import org.fdroid.fdroid.net.Downloader; import org.fdroid.fdroid.net.Downloader;
import org.fdroid.fdroid.net.HttpDownloader;
import org.xml.sax.InputSource; import org.xml.sax.InputSource;
import org.xml.sax.SAXException; import org.xml.sax.SAXException;
import org.xml.sax.XMLReader; import org.xml.sax.XMLReader;
import javax.net.ssl.SSLHandshakeException; import java.io.BufferedReader;
import javax.xml.parsers.ParserConfigurationException; import java.io.File;
import javax.xml.parsers.SAXParser; import java.io.FileNotFoundException;
import javax.xml.parsers.SAXParserFactory; import java.io.FileReader;
import java.io.*; import java.io.IOException;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Date; import java.util.Date;
import java.util.List; import java.util.List;
import javax.xml.parsers.ParserConfigurationException;
import javax.xml.parsers.SAXParser;
import javax.xml.parsers.SAXParserFactory;
abstract public class RepoUpdater { abstract public class RepoUpdater {
public static final int PROGRESS_TYPE_DOWNLOAD = 1; public static final String PROGRESS_TYPE_PROCESS_XML = "processingXml";
public static final int PROGRESS_TYPE_PROCESS_XML = 2;
public static final String PROGRESS_DATA_REPO = "repo"; public static final String PROGRESS_DATA_REPO_ADDRESS = "repoAddress";
public static RepoUpdater createUpdaterFor(Context ctx, Repo repo) { public static RepoUpdater createUpdaterFor(Context ctx, Repo repo) {
if (repo.fingerprint == null && repo.pubkey == null) { if (repo.fingerprint == null && repo.pubkey == null) {
@ -68,10 +74,6 @@ abstract public class RepoUpdater {
return apks; return apks;
} }
public boolean isInteractive() {
return progressListener != null;
}
/** /**
* For example, you may want to unzip a jar file to get the index inside, * For example, you may want to unzip a jar file to get the index inside,
* or if the file is not compressed, you can just return a reference to * or if the file is not compressed, you can just return a reference to
@ -85,47 +87,26 @@ abstract public class RepoUpdater {
protected abstract String getIndexAddress(); protected abstract String getIndexAddress();
protected Downloader downloadIndex() throws UpdateException { protected Downloader downloadIndex() throws UpdateException {
Bundle progressData = createProgressData(repo.address);
Downloader downloader = null; Downloader downloader = null;
try { try {
downloader = new Downloader(getIndexAddress(), context); downloader = new HttpDownloader(getIndexAddress(), context);
downloader.setETag(repo.lastetag); downloader.setCacheTag(repo.lastetag);
if (isInteractive()) { if (progressListener != null) { // interactive session, show progress
ProgressListener.Event event = Bundle data = new Bundle(1);
new ProgressListener.Event( data.putString(PROGRESS_DATA_REPO_ADDRESS, repo.address);
RepoUpdater.PROGRESS_TYPE_DOWNLOAD, progressData); downloader.setProgressListener(progressListener, data);
downloader.setProgressListener(progressListener, event);
} }
int status = downloader.download(); downloader.downloadUninterrupted();
if (status == 304) { if (downloader.isCached()) {
// The index is unchanged since we last read it. We just mark // The index is unchanged since we last read it. We just mark
// everything that came from this repo as being updated. // everything that came from this repo as being updated.
Log.d("FDroid", "Repo index for " + repo.address Log.d("FDroid", "Repo index for " + getIndexAddress()
+ " is up to date (by etag)"); + " is up to date (by etag)");
} else if (status == 200) {
// Nothing needed to be done here...
} else {
// Is there any code other than 200 which still returns
// content? Just in case, lets try to clean up.
if (downloader.getFile() != null) {
downloader.getFile().delete();
}
throw new UpdateException(
repo,
"Failed to update repo " + repo.address +
" - HTTP response " + status);
} }
} catch (SSLHandshakeException e) {
throw new UpdateException(
repo,
"A problem occurred while establishing an SSL " +
"connection. If this problem persists, AND you have a " +
"very old device, you could try using http instead of " +
"https for the repo URL.",
e );
} catch (IOException e) { } catch (IOException e) {
if (downloader != null && downloader.getFile() != null) { if (downloader != null && downloader.getFile() != null) {
downloader.getFile().delete(); downloader.getFile().delete();
@ -138,12 +119,6 @@ abstract public class RepoUpdater {
return downloader; return downloader;
} }
public static Bundle createProgressData(String repoAddress) {
Bundle data = new Bundle();
data.putString(PROGRESS_DATA_REPO, repoAddress);
return data;
}
private int estimateAppCount(File indexFile) { private int estimateAppCount(File indexFile) {
int count = -1; int count = -1;
try { try {
@ -182,7 +157,7 @@ abstract public class RepoUpdater {
XMLReader reader = parser.getXMLReader(); XMLReader reader = parser.getXMLReader();
RepoXMLHandler handler = new RepoXMLHandler(repo, progressListener); RepoXMLHandler handler = new RepoXMLHandler(repo, progressListener);
if (isInteractive()) { if (progressListener != null) {
// Only bother spending the time to count the expected apps // Only bother spending the time to count the expected apps
// if we can show that to the user... // if we can show that to the user...
handler.setTotalAppCount(estimateAppCount(indexFile)); handler.setTotalAppCount(estimateAppCount(indexFile));
@ -195,7 +170,7 @@ abstract public class RepoUpdater {
reader.parse(is); reader.parse(is);
apps = handler.getApps(); apps = handler.getApps();
apks = handler.getApks(); apks = handler.getApks();
updateRepo(handler, downloader.getETag()); updateRepo(handler, downloader.getCacheTag());
} }
} catch (SAXException e) { } catch (SAXException e) {
throw new UpdateException( throw new UpdateException(

View File

@ -203,7 +203,7 @@ public class RepoDetailsFragment extends Fragment {
UpdateService.updateRepoNow(repo.address, getActivity()).setListener(new ProgressListener() { UpdateService.updateRepoNow(repo.address, getActivity()).setListener(new ProgressListener() {
@Override @Override
public void onProgress(Event event) { public void onProgress(Event event) {
if (event.type == UpdateService.STATUS_COMPLETE_WITH_CHANGES) { if (event.type.equals(UpdateService.EVENT_COMPLETE_WITH_CHANGES)) {
repo = loadRepoDetails(); repo = loadRepoDetails();
updateView((ViewGroup)getView()); updateView((ViewGroup)getView());
} }

View File

@ -218,8 +218,8 @@ public class RepoListFragment extends ListFragment
UpdateService.updateNow(getActivity()).setListener(new ProgressListener() { UpdateService.updateNow(getActivity()).setListener(new ProgressListener() {
@Override @Override
public void onProgress(Event event) { public void onProgress(Event event) {
if (event.type == UpdateService.STATUS_COMPLETE_AND_SAME || if (event.type.equals(UpdateService.EVENT_COMPLETE_AND_SAME) ||
event.type == UpdateService.STATUS_COMPLETE_WITH_CHANGES) { event.type.equals(UpdateService.EVENT_COMPLETE_WITH_CHANGES)) {
// No need to prompt to update any more, we just did it! // No need to prompt to update any more, we just did it!
changed = false; changed = false;
} }