diff --git a/.gitignore b/.gitignore index 22273c3f4..9708725fd 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,6 @@ /build.xml *~ /.idea/ -/*.iml +*.iml out /.settings/ diff --git a/src/org/fdroid/fdroid/AppDetails.java b/src/org/fdroid/fdroid/AppDetails.java index 96e82b6bf..fca4c0b10 100644 --- a/src/org/fdroid/fdroid/AppDetails.java +++ b/src/org/fdroid/fdroid/AppDetails.java @@ -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; @@ -98,7 +102,7 @@ public class AppDetails extends ListActivity { // observer to update view when package has been installed/deleted AppObserver myAppObserver; - class AppObserver extends ContentObserver { + class AppObserver extends ContentObserver { public AppObserver(Handler handler) { super(handler); } @@ -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: + * + * @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 not 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 (!reset()) { - finish(); - return; + if (downloadHandler != null) { + if (downloadHandler.isComplete()) { + downloadCompleteInstallApk(); + } 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(); 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) { @@ -926,10 +1013,8 @@ public class AppDetails extends ListActivity { new DialogInterface.OnClickListener() { @Override public void onClick(DialogInterface dialog, - int whichButton) { - downloadHandler = new DownloadHandler(apk, - repoaddress, Utils - .getApkCacheDir(getBaseContext())); + int whichButton) { + 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,137 +1128,115 @@ 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) { - 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); - pd.setOnCancelListener(new DialogInterface.OnCancelListener() { - @Override - public void onCancel(DialogInterface dialog) { - downloadHandler.cancel(); - } - }); - pd.setButton(DialogInterface.BUTTON_NEUTRAL, - getString(R.string.cancel), - new DialogInterface.OnClickListener() { - @Override - public void onClick(DialogInterface dialog, int which) { - pd.cancel(); + 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.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."); } - }); - pd.show(); - return pd; + progressDialog = null; + Toast.makeText(AppDetails.this, getString(R.string.download_cancelled), Toast.LENGTH_LONG).show(); + } + }); + 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 { - 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 downloadHandler 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; + 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.setIndeterminate(true); + pd.setProgress(progress); + pd.setMax(0); } - startUpdates(); - } - - 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); + if (!pd.isShowing()) { + Log.d(TAG, "Showing progress dialog for download."); + pd.show(); } } + } - public void stopUpdates() { - updating = false; - removeMessages(0); + @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; } - 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; + 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 - 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) { case REQUEST_ENABLE_BLUETOOTH: fdroidApp.sendViaBluetooth(this, resultCode, app.id); - break; + break; } } } diff --git a/src/org/fdroid/fdroid/Downloader.java b/src/org/fdroid/fdroid/Downloader.java deleted file mode 100644 index 22bcac17e..000000000 --- a/src/org/fdroid/fdroid/Downloader.java +++ /dev/null @@ -1,195 +0,0 @@ -/* - * Copyright (C) 2010-2012 Ciaran Gultnieks - * Copyright (C) 2011 Henrik Tunedal - * - * 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; - } - } -} diff --git a/src/org/fdroid/fdroid/ProgressListener.java b/src/org/fdroid/fdroid/ProgressListener.java index 5550c1492..6af2daf4b 100644 --- a/src/org/fdroid/fdroid/ProgressListener.java +++ b/src/org/fdroid/fdroid/ProgressListener.java @@ -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 CREATOR = new Parcelable.Creator() { @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; + } } } diff --git a/src/org/fdroid/fdroid/RepoXMLHandler.java b/src/org/fdroid/fdroid/RepoXMLHandler.java index 1c7fb5009..f85a8eafc 100644 --- a/src/org/fdroid/fdroid/RepoXMLHandler.java +++ b/src/org/fdroid/fdroid/RepoXMLHandler.java @@ -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(); diff --git a/src/org/fdroid/fdroid/UpdateService.java b/src/org/fdroid/fdroid/UpdateService.java index 471b120f2..bf7fd8b71 100644 --- a/src/org/fdroid/fdroid/UpdateService.java +++ b/src/org/fdroid/fdroid/UpdateService.java @@ -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); diff --git a/src/org/fdroid/fdroid/net/ApkDownloader.java b/src/org/fdroid/fdroid/net/ApkDownloader.java new file mode 100644 index 000000000..baddfd5ef --- /dev/null +++ b/src/org/fdroid/fdroid/net/ApkDownloader.java @@ -0,0 +1,277 @@ +/* + * Copyright (C) 2010-2012 Ciaran Gultnieks + * Copyright (C) 2011 Henrik Tunedal + * + * 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; + } +} diff --git a/src/org/fdroid/fdroid/net/AsyncDownloadWrapper.java b/src/org/fdroid/fdroid/net/AsyncDownloadWrapper.java new file mode 100644 index 000000000..5e250aaa4 --- /dev/null +++ b/src/org/fdroid/fdroid/net/AsyncDownloadWrapper.java @@ -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); + } + } + +} diff --git a/src/org/fdroid/fdroid/net/Downloader.java b/src/org/fdroid/fdroid/net/Downloader.java index 27fc6bb79..8c9f7cf77 100644 --- a/src/org/fdroid/fdroid/net/Downloader.java +++ b/src/org/fdroid/fdroid/net/Downloader.java @@ -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; } + 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() { - return statusCode; + public void downloadUninterrupted() throws IOException { + 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 - * 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. + * 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 */ - public String getETag() { - return eTag; + private void throwExceptionIfInterrupted() throws InterruptedException { + if (Thread.interrupted()) { + Log.d(TAG, "Received interrupt, cancelling download"); + throw new 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; - } + protected void copyInputToOutputStream(InputStream input) 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(); - InputStream input = null; - try { - input = connection.getInputStream(); - Utils.copy(input, outputStream, - progressListener, progressEvent); - } finally { - Utils.closeQuietly(outputStream); - Utils.closeQuietly(input); + 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; } - updateCacheCheck(); + outputStream.write(buffer, 0, count); } - return statusCode; + outputStream.flush(); } - protected void setupCacheCheck() { - if (eTag != null) { - connection.setRequestProperty(HEADER_IF_NONE_MATCH, eTag); + protected void sendProgress(int bytesRead, int totalBytes) { + sendProgress(new ProgressListener.Event(EVENT_PROGRESS, bytesRead, totalBytes, eventData)); + } + + 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; - } - } diff --git a/src/org/fdroid/fdroid/net/HttpDownloader.java b/src/org/fdroid/fdroid/net/HttpDownloader.java new file mode 100644 index 000000000..177fa6de1 --- /dev/null +++ b/src/org/fdroid/fdroid/net/HttpDownloader.java @@ -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; + } + +} diff --git a/src/org/fdroid/fdroid/updater/RepoUpdater.java b/src/org/fdroid/fdroid/updater/RepoUpdater.java index 486798154..eab4291d7 100644 --- a/src/org/fdroid/fdroid/updater/RepoUpdater.java +++ b/src/org/fdroid/fdroid/updater/RepoUpdater.java @@ -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( diff --git a/src/org/fdroid/fdroid/views/fragments/RepoDetailsFragment.java b/src/org/fdroid/fdroid/views/fragments/RepoDetailsFragment.java index 0a3ff5d2f..471a6a654 100644 --- a/src/org/fdroid/fdroid/views/fragments/RepoDetailsFragment.java +++ b/src/org/fdroid/fdroid/views/fragments/RepoDetailsFragment.java @@ -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()); } diff --git a/src/org/fdroid/fdroid/views/fragments/RepoListFragment.java b/src/org/fdroid/fdroid/views/fragments/RepoListFragment.java index afd69cddf..5295af7de 100644 --- a/src/org/fdroid/fdroid/views/fragments/RepoListFragment.java +++ b/src/org/fdroid/fdroid/views/fragments/RepoListFragment.java @@ -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; }