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
*~
/.idea/
/*.iml
*.iml
out
/.settings/

View File

@ -19,31 +19,24 @@
package org.fdroid.fdroid;
import android.content.*;
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.Activity;
import android.app.AlertDialog;
import android.app.ListActivity;
import android.app.ProgressDialog;
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.os.Bundle;
import android.os.Handler;
import android.os.Message;
import android.preference.PreferenceManager;
import android.support.v4.app.NavUtils;
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.Html;
import android.text.Html.TagHandler;
@ -58,24 +51,34 @@ import android.view.SubMenu;
import android.view.View;
import android.view.ViewGroup;
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.ImageLoader;
import com.nostra13.universalimageloader.core.assist.ImageScaleType;
import org.fdroid.fdroid.Utils.CommaSeparatedList;
import org.fdroid.fdroid.compat.ActionBarCompat;
import org.fdroid.fdroid.compat.MenuManager;
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.security.NoSuchAlgorithmException;
import java.util.Iterator;
import java.util.List;
public class AppDetails extends ListActivity {
private static final String TAG = "AppDetails";
public class AppDetails extends ListActivity implements ProgressListener {
private static final String TAG = "org.fdroid.fdroid.AppDetails";
public static final int REQUEST_ENABLE_BLUETOOTH = 2;
@ -84,6 +87,7 @@ public class AppDetails extends ListActivity {
private FDroidApp fdroidApp;
private ApkListAdapter adapter;
private ProgressDialog progressDialog;
private static class ViewHolder {
TextView version;
@ -110,7 +114,7 @@ public class AppDetails extends ListActivity {
@Override
public void onChange(boolean selfChange, Uri uri) {
if (!reset()) {
if (!reset(app.id)) {
AppDetails.this.finish();
return;
}
@ -267,10 +271,8 @@ public class AppDetails extends ListActivity {
private static final int SEND_VIA_BLUETOOTH = Menu.FIRST + 15;
private App app;
private String appid;
private PackageManager mPm;
private DownloadHandler downloadHandler;
private boolean stateRetained;
private ApkDownloader downloadHandler;
private boolean startingIgnoreAll;
private int startingIgnoreThis;
@ -282,6 +284,68 @@ public class AppDetails extends ListActivity {
private DisplayImageOptions displayImageOptions;
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
protected void onCreate(Bundle savedInstanceState) {
requestWindowFeature(Window.FEATURE_INDETERMINATE_PROGRESS);
@ -307,43 +371,27 @@ public class AppDetails extends ListActivity {
// for reason why.
ActionBarCompat.create(this).setDisplayHomeAsUpEnabled(true);
Intent i = getIntent();
Uri data = i.getData();
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));
if (getIntent().hasExtra(EXTRA_FROM)) {
setTitle(getIntent().getStringExtra(EXTRA_FROM));
}
mPm = getPackageManager();
installer = Installer.getActivityInstaller(this, mPm,
myInstallerCallback);
// Get the preferences we're going to use in this Activity...
AppDetails old = (AppDetails) getLastNonConfigurationInstance();
if (old != null) {
copyState(old);
ConfigurationChangeHelper previousData = (ConfigurationChangeHelper)getLastNonConfigurationInstance();
if (previousData != null) {
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 {
if (!reset()) {
if (!reset(getAppIdFromIntent())) {
finish();
return;
}
@ -377,7 +425,6 @@ public class AppDetails extends ListActivity {
@Override
protected void onResume() {
Log.d(TAG, "onresume");
super.onResume();
// register observer to know when install status changes
@ -386,17 +433,45 @@ public class AppDetails extends ListActivity {
AppProvider.getContentUri(app.id),
true,
myAppObserver);
if (downloadHandler != null) {
if (downloadHandler.isComplete()) {
downloadCompleteInstallApk();
} else {
downloadHandler.setProgressListener(this);
if (!reset()) {
finish();
return;
// 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();
MenuManager.create(this).invalidateOptionsMenu();
}
/**
* Remove progress listener, suppress progress dialog, set downloadHandler to null.
*/
private void cleanUpFinishedDownload() {
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) {
getContentResolver().unregisterContentObserver(myAppObserver);
}
if (downloadHandler != null) {
downloadHandler.stopUpdates();
}
if (app != null && (app.ignoreAllUpdates != startingIgnoreAll
|| 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);
}
if (downloadHandler != null) {
downloadHandler.removeProgressListener();
}
removeProgressDialog();
super.onPause();
}
@ -430,65 +510,73 @@ public class AppDetails extends ListActivity {
@Override
public Object onRetainNonConfigurationInstance() {
stateRetained = true;
return this;
inProcessOfChangingConfiguration = true;
return new ConfigurationChangeHelper(downloadHandler, app);
}
@Override
protected void onDestroy() {
if (downloadHandler != null) {
if (!stateRetained)
if (!inProcessOfChangingConfiguration) {
downloadHandler.cancel();
downloadHandler.destroy();
cleanUpFinishedDownload();
}
}
inProcessOfChangingConfiguration = false;
super.onDestroy();
}
// Copy all relevant state from an old instance. This is used in
// place of reset(), so it must initialize all fields normally set
// there.
private void copyState(AppDetails old) {
if (old.downloadHandler != null)
downloadHandler = new DownloadHandler(old.downloadHandler);
app = old.app;
mInstalledSignature = old.mInstalledSignature;
mInstalledSigID = old.mInstalledSigID;
private void removeProgressDialog() {
if (progressDialog != null) {
progressDialog.dismiss();
progressDialog = null;
}
}
// Reset the display and list contents. Used when entering the activity, and
// also when something has been installed/uninstalled.
// 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);
app = null;
Log.d("FDroid", "Getting application details for " + appId);
App newApp = null;
if (appid != null && appid.length() > 0) {
app = AppProvider.Helper.findById(getContentResolver(), appid);
if (appId != null && appId.length() > 0) {
newApp = AppProvider.Helper.findById(getContentResolver(), appId);
}
if (app == null) {
Toast toast = Toast.makeText(this,
getString(R.string.no_such_app), Toast.LENGTH_LONG);
toast.show();
setApp(newApp);
return this.app != null;
}
/**
* 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();
return false;
return;
}
app = newApp;
startingIgnoreAll = app.ignoreAllUpdates;
startingIgnoreThis = app.ignoreThisUpdate;
// Get the signature of the installed package...
mInstalledSignature = null;
mInstalledSigID = null;
if (app.isInstalled()) {
PackageManager pm = getBaseContext().getPackageManager();
PackageManager pm = getPackageManager();
try {
PackageInfo pi = pm.getPackageInfo(appid,
PackageManager.GET_SIGNATURES);
PackageInfo pi = pm.getPackageInfo(app.id, PackageManager.GET_SIGNATURES);
mInstalledSignature = pi.signatures[0];
Hasher hash = new Hasher("MD5", mInstalledSignature
.toCharsString().getBytes());
Hasher hash = new Hasher("MD5", mInstalledSignature.toCharsString().getBytes());
mInstalledSigID = hash.getHash();
} catch (NameNotFoundException e) {
Log.d("FDroid", "Failed to get installed signature");
@ -497,7 +585,6 @@ public class AppDetails extends ListActivity {
mInstalledSignature = null;
}
}
return true;
}
private void startViews() {
@ -511,10 +598,10 @@ public class AppDetails extends ListActivity {
headerView.removeAllViews();
if (landparent != null) {
landparent.addView(infoView);
Log.d("FDroid", "Setting landparent infoview");
Log.d("FDroid", "Setting up landscape view");
} else {
headerView.addView(infoView);
Log.d("FDroid", "Setting header infoview");
Log.d("FDroid", "Setting up portrait view");
}
// Set the icon...
@ -610,8 +697,7 @@ public class AppDetails extends ListActivity {
if (permissionName.equals("ACCESS_SUPERUSER")) {
sb.append("\t• Full permissions to all device features and storage\n");
} else {
Log.d("FDroid", "Permission not yet available: "
+permissionName);
Log.d("FDroid", "Permission not yet available: " + permissionName);
}
}
}
@ -912,6 +998,7 @@ public class AppDetails extends ListActivity {
// Install the version of this app denoted by 'app.curApk'.
private void install(final Apk apk) {
final Activity activity = this;
String [] projection = { RepoProvider.DataColumns.ADDRESS };
Repo repo = RepoProvider.Helper.findById(this, apk.repo, projection);
if (repo == null || repo.address == null) {
@ -927,9 +1014,7 @@ public class AppDetails extends ListActivity {
@Override
public void onClick(DialogInterface dialog,
int whichButton) {
downloadHandler = new DownloadHandler(apk,
repoaddress, Utils
.getApkCacheDir(getBaseContext()));
startDownload(apk, repoaddress);
}
});
ask_alrt.setNegativeButton(getString(R.string.no),
@ -958,9 +1043,17 @@ public class AppDetails extends ListActivity {
alert.show();
return;
}
downloadHandler = new DownloadHandler(apk, repoaddress,
Utils.getApkCacheDir(getBaseContext()));
startDownload(apk, repoaddress);
}
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) {
setProgressBarIndeterminateVisibility(true);
@ -989,10 +1082,6 @@ public class AppDetails extends ListActivity {
@Override
public void run() {
if (operation == Installer.InstallerCallback.OPERATION_INSTALL) {
if (downloadHandler != null) {
downloadHandler = null;
}
PackageManagerCompat.setInstaller(mPm, app.id);
}
@ -1039,23 +1128,34 @@ public class AppDetails extends ListActivity {
shareIntent.setType("text/plain");
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)));
}
private ProgressDialog createProgressDialog(String file, int p, int max) {
private ProgressDialog getProgressDialog(String file) {
if (progressDialog == null) {
final ProgressDialog pd = new ProgressDialog(this);
pd.setProgressStyle(ProgressDialog.STYLE_HORIZONTAL);
pd.setMessage(getString(R.string.download_server) + ":\n " + file);
pd.setMax(max);
pd.setProgress(p);
pd.setCancelable(true);
pd.setCanceledOnTouchOutside(false);
// The indeterminate-ness will get overridden on the first progress event we receive.
pd.setIndeterminate(true);
pd.setOnCancelListener(new DialogInterface.OnCancelListener() {
@Override
public void onCancel(DialogInterface dialog) {
Log.d(TAG, "User clicked 'cancel' on download, attempting to interrupt download thread.");
if (downloadHandler != null) {
downloadHandler.cancel();
cleanUpFinishedDownload();
} else {
Log.e(TAG, "Tried to cancel, but the downloadHandler doesn't exist.");
}
progressDialog = null;
Toast.makeText(AppDetails.this, getString(R.string.download_cancelled), Toast.LENGTH_LONG).show();
}
});
pd.setButton(DialogInterface.BUTTON_NEUTRAL,
@ -1065,111 +1165,78 @@ public class AppDetails extends ListActivity {
public void onClick(DialogInterface dialog, int which) {
pd.cancel();
}
});
pd.show();
return pd;
}
);
progressDialog = pd;
}
return progressDialog;
}
// Handler used to update the progress dialog while downloading.
private class DownloadHandler extends Handler {
private Downloader download;
private ProgressDialog pd;
private boolean updating;
private String id;
public DownloadHandler(Apk apk, String repoaddress, File destdir) {
id = apk.id;
download = new Downloader(apk, repoaddress, destdir);
download.start();
startUpdates();
/**
* Looks at the current <code>downloadHandler</code> and finds it's size and progress.
* This is in comparison to {@link org.fdroid.fdroid.AppDetails#updateProgressDialog(int, int)},
* which is used when you have the details from a freshly received
* {@link org.fdroid.fdroid.ProgressListener.Event}.
*/
private void updateProgressDialog() {
if (downloadHandler != null) {
updateProgressDialog(downloadHandler.getProgress(), downloadHandler.getTotalSize());
}
}
public DownloadHandler(DownloadHandler oldHandler) {
if (oldHandler != null) {
download = oldHandler.download;
}
startUpdates();
}
public boolean updateProgress() {
boolean finished = false;
switch (download.getStatus()) {
case RUNNING:
if (pd == null) {
pd = createProgressDialog(download.remoteFile(),
download.getProgress(), download.getMax());
private void updateProgressDialog(int progress, int total) {
if (downloadHandler != null) {
ProgressDialog pd = getProgressDialog(downloadHandler.getRemoteAddress());
if (total > 0) {
pd.setIndeterminate(false);
pd.setProgress(progress);
pd.setMax(total);
} else {
pd.setProgress(download.getProgress());
pd.setIndeterminate(true);
pd.setProgress(progress);
pd.setMax(0);
}
break;
case ERROR:
if (pd != null)
pd.dismiss();
String text;
if (download.getErrorType() == Downloader.Error.CORRUPT)
if (!pd.isShowing()) {
Log.d(TAG, "Showing progress dialog for download.");
pd.show();
}
}
}
@Override
public void onProgress(Event event) {
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;
}
boolean finished = false;
if (event.type.equals(Downloader.EVENT_PROGRESS)) {
updateProgressDialog(event.progress, event.total);
} else if (event.type.equals(ApkDownloader.EVENT_ERROR)) {
final String text;
if (event.getData().getInt(ApkDownloader.EVENT_DATA_ERROR_TYPE) == ApkDownloader.ERROR_HASH_MISMATCH)
text = getString(R.string.corrupt_download);
else
text = download.getErrorMessage();
Toast.makeText(AppDetails.this, text, Toast.LENGTH_LONG).show();
text = getString(R.string.details_notinstalled);
// this must be on the main UI thread
Toast.makeText(this, text, Toast.LENGTH_LONG).show();
finished = true;
break;
case DONE:
if (pd != null)
pd.dismiss();
installApk(download.localFile(), id);
} else if (event.type.equals(ApkDownloader.EVENT_APK_DOWNLOAD_COMPLETE)) {
downloadCompleteInstallApk();
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() {
updating = false;
removeMessages(0);
}
public void cancel() {
if (download != null)
download.interrupt();
}
public void destroy() {
// The dialog can't be dismissed when it's not displayed,
// 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
sendMessageDelayed(obtainMessage(), 50);
if (finished) {
removeProgressDialog();
downloadHandler = null;
}
}

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;
import android.os.Bundle;
import android.os.Parcel;
import android.os.Parcelable;
import android.text.TextUtils;
public interface ProgressListener {
@ -15,7 +17,7 @@ public interface ProgressListener {
public static final int NO_VALUE = Integer.MIN_VALUE;
public final int type;
public final String type;
public final Bundle data;
// 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 total;
public Event(int type) {
public Event(String type) {
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);
}
public Event(int type, int progress) {
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) {
public Event(String type, int progress, int total, Bundle data) {
this.type = type;
this.progress = progress;
this.total = total;
this.data = data == null ? new Bundle() : data;
this.data = (data == null) ? new Bundle() : data;
}
@Override
@ -59,7 +49,7 @@ public interface ProgressListener {
@Override
public void writeToParcel(Parcel dest, int flags) {
dest.writeInt(type);
dest.writeString(type);
dest.writeInt(progress);
dest.writeInt(total);
dest.writeBundle(data);
@ -68,7 +58,7 @@ public interface ProgressListener {
public static final Parcelable.Creator<Event> CREATOR = new Parcelable.Creator<Event>() {
@Override
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
@ -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) {
curapp = new App();
curapp.id = attributes.getValue("", "id");
Bundle progressData = RepoUpdater.createProgressData(repo.address);
progressCounter ++;
Bundle data = new Bundle(1);
data.putString(RepoUpdater.PROGRESS_DATA_REPO_ADDRESS, repo.address);
progressListener.onProgress(
new ProgressListener.Event(
RepoUpdater.PROGRESS_TYPE_PROCESS_XML, progressCounter,
totalAppCount, progressData));
RepoUpdater.PROGRESS_TYPE_PROCESS_XML,
progressCounter, totalAppCount, data));
} else if (localName.equals("package") && curapp != null && curapk == null) {
curapk = new Apk();

View File

@ -33,6 +33,7 @@ import android.text.TextUtils;
import android.util.Log;
import android.widget.Toast;
import org.fdroid.fdroid.data.*;
import org.fdroid.fdroid.net.Downloader;
import org.fdroid.fdroid.updater.RepoUpdater;
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_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_ADDRESS = "address";
@ -97,28 +106,31 @@ public class UpdateService extends IntentService implements ProgressListener {
return this;
}
private void forwardEvent(String type) {
if (listener != null) {
listener.onProgress(new Event(type));
}
}
@Override
protected void onReceiveResult(int resultCode, Bundle resultData) {
String message = resultData.getString(UpdateService.RESULT_MESSAGE);
boolean finished = false;
if (resultCode == UpdateService.STATUS_ERROR) {
forwardEvent(EVENT_ERROR);
Toast.makeText(context, message, Toast.LENGTH_LONG).show();
finished = true;
} else if (resultCode == UpdateService.STATUS_COMPLETE_WITH_CHANGES
|| resultCode == UpdateService.STATUS_COMPLETE_AND_SAME) {
} else if (resultCode == UpdateService.STATUS_COMPLETE_WITH_CHANGES) {
forwardEvent(EVENT_COMPLETE_WITH_CHANGES);
finished = true;
} else if (resultCode == UpdateService.STATUS_COMPLETE_AND_SAME) {
forwardEvent(EVENT_COMPLETE_AND_SAME);
finished = true;
} else if (resultCode == UpdateService.STATUS_INFO) {
forwardEvent(EVENT_INFO);
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())
try {
dialog.dismiss();
@ -185,17 +197,10 @@ public class UpdateService extends IntentService implements ProgressListener {
}
protected void sendStatus(int statusCode, String message) {
sendStatus(statusCode, message, null);
}
protected void sendStatus(int statusCode, String message, Event event) {
if (receiver != null) {
Bundle resultData = new Bundle();
if (message != null && message.length() > 0)
resultData.putString(RESULT_MESSAGE, message);
if (event == null)
event = new Event(statusCode);
resultData.putParcelable(RESULT_EVENT, event);
receiver.send(statusCode, resultData);
}
}
@ -675,14 +680,15 @@ public class UpdateService extends IntentService implements ProgressListener {
@Override
public void onProgress(ProgressListener.Event event) {
String message = "";
if (event.type == RepoUpdater.PROGRESS_TYPE_DOWNLOAD) {
String repoAddress = event.data.getString(RepoUpdater.PROGRESS_DATA_REPO);
String downloadedSize = Utils.getFriendlySize( event.progress );
String totalSize = Utils.getFriendlySize( event.total );
// TODO: Switch to passing through Bundles of data with the event, rather than a repo address. They are
// now much more general purpose then just repo downloading.
String repoAddress = event.getData().getString(RepoUpdater.PROGRESS_DATA_REPO_ADDRESS);
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);
message = getString(R.string.status_download, repoAddress, downloadedSize, totalSize, percent);
} else if (event.type == RepoUpdater.PROGRESS_TYPE_PROCESS_XML) {
String repoAddress = event.data.getString(RepoUpdater.PROGRESS_DATA_REPO);
} else if (event.type.equals(RepoUpdater.PROGRESS_TYPE_PROCESS_XML)) {
message = getString(R.string.status_processing_xml, repoAddress, event.progress, event.total);
}
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;
import java.io.*;
import java.net.*;
import android.content.*;
import org.fdroid.fdroid.*;
import android.content.Context;
import android.os.Bundle;
import android.util.Log;
public class Downloader {
import org.fdroid.fdroid.ProgressListener;
import org.fdroid.fdroid.Utils;
private static final String HEADER_IF_NONE_MATCH = "If-None-Match";
private static final String HEADER_FIELD_ETAG = "ETag";
import java.io.File;
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 ProgressListener progressListener = null;
private ProgressListener.Event progressEvent = null;
private String eTag = null;
private final File outputFile;
private HttpURLConnection connection;
private int statusCode = -1;
private Bundle eventData = null;
private File outputFile;
protected String cacheTag = null;
public static final String EVENT_PROGRESS = "downloadProgress";
public abstract InputStream inputStream() throws IOException;
// 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 {
sourceUrl = new URL(source);
outputStream = ctx.openFileOutput(destFile, Context.MODE_PRIVATE);
outputFile = new File(ctx.getFilesDir() + File.separator + destFile);
this(new File(ctx.getFilesDir() + File.separator + destFile));
}
/**
* Downloads to a temporary file, which *you must delete yourself when
* you are done*.
* @see org.fdroid.fdroid.net.Downloader#getFile()
*/
public Downloader(String source, Context ctx) throws IOException {
// The context is required for opening the file to write to.
public Downloader(Context ctx) throws IOException {
this(File.createTempFile("dl-", "", ctx.getCacheDir()));
}
public Downloader(File destFile)
throws FileNotFoundException, MalformedURLException {
// http://developer.android.com/guide/topics/data/data-storage.html#InternalCache
outputFile = File.createTempFile("dl-", "", ctx.getCacheDir());
outputFile = destFile;
outputStream = new FileOutputStream(outputFile);
sourceUrl = new URL(source);
}
public Downloader(String source, OutputStream output)
public Downloader(OutputStream output)
throws MalformedURLException {
sourceUrl = new URL(source);
outputStream = output;
outputFile = null;
}
public void setProgressListener(ProgressListener progressListener,
ProgressListener.Event progressEvent) {
this.progressListener = progressListener;
this.progressEvent = progressEvent;
public void setProgressListener(ProgressListener listener) {
setProgressListener(listener, null);
}
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;
}
/**
* Only available after downloading a file.
*/
public int getStatusCode() {
return statusCode;
}
public abstract boolean hasChanged();
public abstract int totalDownloadSize();
/**
* If you ask for the eTag 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 eTag from the server, or null if there was none.
* 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 String getETag() {
return eTag;
public void downloadUninterrupted() throws IOException {
try {
download();
} catch (InterruptedException ignored) {}
}
/**
* 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;
}
public abstract void download() throws IOException, InterruptedException;
// 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.
public int download() throws IOException {
connection = (HttpURLConnection)sourceUrl.openConnection();
setupCacheCheck();
statusCode = connection.getResponseCode();
if (statusCode == 200) {
setupProgressListener();
public abstract boolean isCached();
protected void downloadFromStream() throws IOException, InterruptedException {
Log.d(TAG, "Downloading from stream");
InputStream input = null;
try {
input = connection.getInputStream();
Utils.copy(input, outputStream,
progressListener, progressEvent);
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);
}
updateCacheCheck();
}
return statusCode;
// Even if we have completely downloaded the file, we should probably respect
// the wishes of the user who wanted to cancel us.
throwExceptionIfInterrupted();
}
protected void setupCacheCheck() {
if (eTag != null) {
connection.setRequestProperty(HEADER_IF_NONE_MATCH, eTag);
/**
* In a synchronous download (the usual usage of the Downloader interface),
* you will not be able to interrupt this because the thread will block
* 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
*/
private void throwExceptionIfInterrupted() throws InterruptedException {
if (Thread.interrupted()) {
Log.d(TAG, "Received interrupt, cancelling download");
throw new InterruptedException();
}
}
protected void updateCacheCheck() {
eTag = connection.getHeaderField(HEADER_FIELD_ETAG);
protected void copyInputToOutputStream(InputStream input) throws IOException, InterruptedException {
byte[] buffer = new byte[Utils.BUFFER_SIZE];
int bytesRead = 0;
int totalBytes = totalDownloadSize();
// Getting the total download size could potentially take time, depending on how
// it is implemented, so we may as well check this before we proceed.
throwExceptionIfInterrupted();
sendProgress(bytesRead, totalBytes);
while (true) {
int count = input.read(buffer);
throwExceptionIfInterrupted();
bytesRead += count;
sendProgress(bytesRead, totalBytes);
if (count == -1) {
Log.d(TAG, "Finished downloading from stream");
break;
}
outputStream.write(buffer, 0, count);
}
outputStream.flush();
}
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();
}
protected void sendProgress(int bytesRead, int totalBytes) {
sendProgress(new ProgressListener.Event(EVENT_PROGRESS, bytesRead, totalBytes, eventData));
}
public boolean hasChanged() {
return this.statusCode == 200;
protected void sendProgress(ProgressListener.Event event) {
if (progressListener != null) {
progressListener.onProgress(event);
}
}
}

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.os.Bundle;
import android.util.Log;
import org.fdroid.fdroid.ProgressListener;
import org.fdroid.fdroid.RepoXMLHandler;
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.RepoProvider;
import org.fdroid.fdroid.net.Downloader;
import org.fdroid.fdroid.net.HttpDownloader;
import org.xml.sax.InputSource;
import org.xml.sax.SAXException;
import org.xml.sax.XMLReader;
import javax.net.ssl.SSLHandshakeException;
import javax.xml.parsers.ParserConfigurationException;
import javax.xml.parsers.SAXParser;
import javax.xml.parsers.SAXParserFactory;
import java.io.*;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileReader;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import javax.xml.parsers.ParserConfigurationException;
import javax.xml.parsers.SAXParser;
import javax.xml.parsers.SAXParserFactory;
abstract public class RepoUpdater {
public static final int PROGRESS_TYPE_DOWNLOAD = 1;
public static final int PROGRESS_TYPE_PROCESS_XML = 2;
public static final String PROGRESS_DATA_REPO = "repo";
public static final String PROGRESS_TYPE_PROCESS_XML = "processingXml";
public static final String PROGRESS_DATA_REPO_ADDRESS = "repoAddress";
public static RepoUpdater createUpdaterFor(Context ctx, Repo repo) {
if (repo.fingerprint == null && repo.pubkey == null) {
@ -68,10 +74,6 @@ abstract public class RepoUpdater {
return apks;
}
public boolean isInteractive() {
return progressListener != null;
}
/**
* 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
@ -85,47 +87,26 @@ abstract public class RepoUpdater {
protected abstract String getIndexAddress();
protected Downloader downloadIndex() throws UpdateException {
Bundle progressData = createProgressData(repo.address);
Downloader downloader = null;
try {
downloader = new Downloader(getIndexAddress(), context);
downloader.setETag(repo.lastetag);
downloader = new HttpDownloader(getIndexAddress(), context);
downloader.setCacheTag(repo.lastetag);
if (isInteractive()) {
ProgressListener.Event event =
new ProgressListener.Event(
RepoUpdater.PROGRESS_TYPE_DOWNLOAD, progressData);
downloader.setProgressListener(progressListener, event);
if (progressListener != null) { // interactive session, show progress
Bundle data = new Bundle(1);
data.putString(PROGRESS_DATA_REPO_ADDRESS, repo.address);
downloader.setProgressListener(progressListener, data);
}
int status = downloader.download();
downloader.downloadUninterrupted();
if (status == 304) {
if (downloader.isCached()) {
// The index is unchanged since we last read it. We just mark
// 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)");
} 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) {
if (downloader != null && downloader.getFile() != null) {
downloader.getFile().delete();
@ -138,12 +119,6 @@ abstract public class RepoUpdater {
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) {
int count = -1;
try {
@ -182,7 +157,7 @@ abstract public class RepoUpdater {
XMLReader reader = parser.getXMLReader();
RepoXMLHandler handler = new RepoXMLHandler(repo, progressListener);
if (isInteractive()) {
if (progressListener != null) {
// Only bother spending the time to count the expected apps
// if we can show that to the user...
handler.setTotalAppCount(estimateAppCount(indexFile));
@ -195,7 +170,7 @@ abstract public class RepoUpdater {
reader.parse(is);
apps = handler.getApps();
apks = handler.getApks();
updateRepo(handler, downloader.getETag());
updateRepo(handler, downloader.getCacheTag());
}
} catch (SAXException e) {
throw new UpdateException(

View File

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

View File

@ -218,8 +218,8 @@ public 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) {
if (event.type.equals(UpdateService.EVENT_COMPLETE_AND_SAME) ||
event.type.equals(UpdateService.EVENT_COMPLETE_WITH_CHANGES)) {
// No need to prompt to update any more, we just did it!
changed = false;
}