diff --git a/app/src/androidTest/java/org/fdroid/fdroid/net/DownloaderServiceTest.java b/app/src/androidTest/java/org/fdroid/fdroid/net/DownloaderServiceTest.java new file mode 100644 index 000000000..7803ad070 --- /dev/null +++ b/app/src/androidTest/java/org/fdroid/fdroid/net/DownloaderServiceTest.java @@ -0,0 +1,41 @@ + +package org.fdroid.fdroid.net; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.support.v4.content.LocalBroadcastManager; +import android.test.ServiceTestCase; +import android.util.Log; + +@SuppressWarnings("PMD") // TODO port this to JUnit 4 semantics +public class DownloaderServiceTest extends ServiceTestCase { + public static final String TAG = "DownloaderServiceTest"; + + String[] urls = { + "https://en.wikipedia.org/wiki/Index.html", + "https://mirrors.kernel.org/debian/dists/stable/Release", + "https://f-droid.org/archive/de.we.acaldav_5.apk", + // sites that use SNI for HTTPS + "https://guardianproject.info/fdroid/repo/index.jar", + }; + + public DownloaderServiceTest() { + super(DownloaderService.class); + } + + public void testQueueingDownload() throws InterruptedException { + LocalBroadcastManager localBroadcastManager = LocalBroadcastManager.getInstance(getContext()); + localBroadcastManager.registerReceiver(new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + Log.i(TAG, "onReceive " + intent); + } + }, new IntentFilter(Downloader.ACTION_PROGRESS)); + for (String url : urls) { + DownloaderService.queue(getContext(), url); + } + Thread.sleep(30000); + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 9b21da52c..3610320ea 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -439,6 +439,9 @@ + diff --git a/app/src/main/java/org/fdroid/fdroid/AppDetails.java b/app/src/main/java/org/fdroid/fdroid/AppDetails.java index 26668808c..690bd0618 100644 --- a/app/src/main/java/org/fdroid/fdroid/AppDetails.java +++ b/app/src/main/java/org/fdroid/fdroid/AppDetails.java @@ -29,7 +29,6 @@ import android.content.ContentValues; import android.content.Context; import android.content.DialogInterface; import android.content.Intent; -import android.content.IntentFilter; import android.content.pm.PackageManager; import android.database.ContentObserver; import android.graphics.Bitmap; @@ -92,6 +91,7 @@ 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.fdroid.fdroid.net.DownloaderService; import java.io.File; import java.util.Iterator; @@ -106,7 +106,7 @@ interface AppDetailsData { /** * Interface which allows the apk list fragment to communicate with the activity when * a user requests to install/remove an apk by clicking on an item in the list. - * + *

* NOTE: This is not to do with with the sudo/packagemanager/other installer * stuff which allows multiple ways to install apps. It is only here to make fragment- * activity communication possible. @@ -117,7 +117,7 @@ interface AppInstallListener { void removeApk(String packageName); } -public class AppDetails extends AppCompatActivity implements ProgressListener, AppDetailsData, AppInstallListener { +public class AppDetails extends AppCompatActivity implements AppDetailsData, AppInstallListener { private static final String TAG = "AppDetails"; @@ -451,17 +451,7 @@ public class AppDetails extends AppCompatActivity implements ProgressListener, A refreshApkList(); refreshHeader(); supportInvalidateOptionsMenu(); - - if (downloadHandler != null) { - if (downloadHandler.isComplete()) { - downloadCompleteInstallApk(); - } else { - localBroadcastManager.registerReceiver(downloaderProgressReceiver, - new IntentFilter(Downloader.LOCAL_ACTION_PROGRESS)); - downloadHandler.setProgressListener(this); - headerFragment.startProgress(); - } - } + registerDownloaderReceivers(); } /** @@ -469,23 +459,11 @@ public class AppDetails extends AppCompatActivity implements ProgressListener, A */ private void cleanUpFinishedDownload() { if (downloadHandler != null) { - downloadHandler.removeProgressListener(); headerFragment.removeProgress(); 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) { - installApk(downloadHandler.localFile()); - cleanUpFinishedDownload(); - } - } - protected void onStop() { super.onStop(); getContentResolver().unregisterContentObserver(myAppObserver); @@ -499,16 +477,41 @@ public class AppDetails extends AppCompatActivity implements ProgressListener, A Utils.debugLog(TAG, "Updating 'ignore updates', as it has changed since we started the activity..."); setIgnoreUpdates(app.packageName, app.ignoreAllUpdates, app.ignoreThisUpdate); } - - localBroadcastManager.unregisterReceiver(downloaderProgressReceiver); - if (downloadHandler != null) { - downloadHandler.removeProgressListener(); - } - + unregisterDownloaderReceivers(); headerFragment.removeProgress(); } - private final BroadcastReceiver downloaderProgressReceiver = new BroadcastReceiver() { + private void unregisterDownloaderReceivers() { + localBroadcastManager.unregisterReceiver(startedReceiver); + localBroadcastManager.unregisterReceiver(progressReceiver); + localBroadcastManager.unregisterReceiver(completeReceiver); + localBroadcastManager.unregisterReceiver(interruptedReceiver); + } + + private void registerDownloaderReceivers() { + if (downloadHandler != null) { // if a download is active + String url = downloadHandler.urlString; + localBroadcastManager.registerReceiver(startedReceiver, + DownloaderService.getIntentFilter(url, Downloader.ACTION_STARTED)); + localBroadcastManager.registerReceiver(progressReceiver, + DownloaderService.getIntentFilter(url, Downloader.LOCAL_ACTION_PROGRESS)); + localBroadcastManager.registerReceiver(completeReceiver, + DownloaderService.getIntentFilter(url, Downloader.ACTION_COMPLETE)); + localBroadcastManager.registerReceiver(interruptedReceiver, + DownloaderService.getIntentFilter(url, Downloader.ACTION_INTERRUPTED)); + } + } + + private final BroadcastReceiver startedReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + if (headerFragment != null) { + headerFragment.startProgress(); + } + } + }; + + private final BroadcastReceiver progressReceiver = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { if (headerFragment != null) { @@ -518,6 +521,23 @@ public class AppDetails extends AppCompatActivity implements ProgressListener, A } }; + private final BroadcastReceiver completeReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + File localFile = new File(intent.getStringExtra(Downloader.EXTRA_DOWNLOAD_PATH)); + installApk(localFile); + cleanUpFinishedDownload(); + } + }; + + private final BroadcastReceiver interruptedReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + Toast.makeText(context, R.string.details_notinstalled, Toast.LENGTH_LONG).show(); + cleanUpFinishedDownload(); + } + }; + private void onAppChanged() { if (!reset(app.packageName)) { this.finish(); @@ -550,7 +570,7 @@ public class AppDetails extends AppCompatActivity implements ProgressListener, A @Override protected void onDestroy() { if (downloadHandler != null && !inProcessOfChangingConfiguration) { - downloadHandler.cancel(); + DownloaderService.cancel(context, downloadHandler.urlString); cleanUpFinishedDownload(); } inProcessOfChangingConfiguration = false; @@ -793,11 +813,6 @@ public class AppDetails extends AppCompatActivity implements ProgressListener, A return; } - // Ignore call if another download is running. - if (downloadHandler != null && !downloadHandler.isComplete()) { - return; - } - final String repoaddress = getRepoAddress(apk); if (repoaddress == null) return; @@ -853,13 +868,9 @@ public class AppDetails extends AppCompatActivity implements ProgressListener, A private void startDownload(Apk apk, String repoAddress) { downloadHandler = new ApkDownloader(getBaseContext(), app, apk, repoAddress); - - localBroadcastManager.registerReceiver(downloaderProgressReceiver, - new IntentFilter(Downloader.LOCAL_ACTION_PROGRESS)); - downloadHandler.setProgressListener(this); - if (downloadHandler.download()) { - headerFragment.startProgress(); - } + registerDownloaderReceivers(); + downloadHandler.download(); + headerFragment.startProgress(); } private void installApk(File file) { @@ -951,42 +962,6 @@ public class AppDetails extends AppCompatActivity implements ProgressListener, A startActivity(Intent.createChooser(shareIntent, getString(R.string.menu_share))); } - @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 - Utils.debugLog(TAG, "Discarding downloader event \"" + event.type + "\" as it is from an old (probably cancelled) downloader."); - return; - } - - boolean finished = false; - switch (event.type) { - case ApkDownloader.EVENT_ERROR: - // this must be on the main UI thread - Toast.makeText(this, R.string.details_notinstalled, Toast.LENGTH_LONG).show(); - cleanUpFinishedDownload(); - finished = true; - break; - case ApkDownloader.EVENT_APK_DOWNLOAD_COMPLETE: - downloadCompleteInstallApk(); - finished = true; - break; - } - - if (finished) { - if (headerFragment != null) { - headerFragment.removeProgress(); - } - downloadHandler = null; - } - } - @Override protected void onActivityResult(int requestCode, int resultCode, Intent data) { // handle cases for install manager first @@ -1536,7 +1511,7 @@ public class AppDetails extends AppCompatActivity implements ProgressListener, A return; } - activity.downloadHandler.cancel(); + DownloaderService.cancel(getContext(), activity.downloadHandler.urlString); activity.cleanUpFinishedDownload(); setProgressVisible(false); updateViews(); diff --git a/app/src/main/java/org/fdroid/fdroid/net/ApkDownloader.java b/app/src/main/java/org/fdroid/fdroid/net/ApkDownloader.java index ac3a5ff09..0673a7fe4 100644 --- a/app/src/main/java/org/fdroid/fdroid/net/ApkDownloader.java +++ b/app/src/main/java/org/fdroid/fdroid/net/ApkDownloader.java @@ -20,6 +20,7 @@ package org.fdroid.fdroid.net; +import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; import android.net.Uri; @@ -30,16 +31,13 @@ import android.widget.Toast; import org.fdroid.fdroid.Hasher; import org.fdroid.fdroid.Preferences; -import org.fdroid.fdroid.ProgressListener; import org.fdroid.fdroid.R; import org.fdroid.fdroid.Utils; -import org.fdroid.fdroid.compat.FileCompat; import org.fdroid.fdroid.data.Apk; import org.fdroid.fdroid.data.App; import org.fdroid.fdroid.data.SanitizedFile; import java.io.File; -import java.io.IOException; import java.security.NoSuchAlgorithmException; /** @@ -47,62 +45,24 @@ import java.security.NoSuchAlgorithmException; * 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 AsyncDownloader.Listener { +public class ApkDownloader { private static final String TAG = "ApkDownloader"; - public static final String EVENT_APK_DOWNLOAD_COMPLETE = "apkDownloadComplete"; - public static final String EVENT_ERROR = "apkDownloadError"; + public final String urlString; - public static final String ACTION_STATUS = "apkDownloadStatus"; - - private static final String EVENT_SOURCE_ID = "sourceId"; - private static long downloadIdCounter; - - @NonNull private final App app; @NonNull private final Apk curApk; @NonNull private final Context context; - @NonNull private final String repoAddress; - @NonNull private final SanitizedFile localFile; + @NonNull private SanitizedFile localFile; @NonNull private final SanitizedFile potentiallyCachedFile; - - private ProgressListener listener; - private AsyncDownloader dlWrapper; - private boolean isComplete; - - private final long id = ++downloadIdCounter; - - public void setProgressListener(ProgressListener listener) { - this.listener = listener; - } - - public void removeProgressListener() { - setProgressListener(null); - } + private final LocalBroadcastManager localBroadcastManager; public ApkDownloader(@NonNull final Context context, @NonNull final App app, @NonNull final Apk apk, @NonNull final String repoAddress) { this.context = context; - this.app = app; curApk = apk; - this.repoAddress = repoAddress; - localFile = new SanitizedFile(Utils.getApkDownloadDir(context), apk.apkName); potentiallyCachedFile = new SanitizedFile(Utils.getApkCacheDir(context), apk.apkName); - } - - /** - * The downloaded APK. Valid only when getStatus() has returned STATUS.DONE. - */ - public SanitizedFile 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; + urlString = Utils.getApkUrl(repoAddress, apk); + localBroadcastManager = LocalBroadcastManager.getInstance(context); } private Hasher createHasher(File apkFile) { @@ -150,115 +110,51 @@ public class ApkDownloader implements AsyncDownloader.Listener { } } - private void prepareApkFileAndSendCompleteMessage() { - - // Need the apk to be world readable, so that the installer is able to read it. - // Note that saving it into external storage for the purpose of letting the installer - // have access is insecure, because apps with permission to write to the external - // storage can overwrite the app between F-Droid asking for it to be installed and - // the installer actually installing it. - FileCompat.setReadable(localFile, true, false); - - isComplete = true; - sendMessage(EVENT_APK_DOWNLOAD_COMPLETE); + private void sendDownloadComplete() { + Utils.debugLog(TAG, "Download finished: " + localFile); + localBroadcastManager.unregisterReceiver(downloadCompleteReceiver); } - 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() { - + public void download() { // Can we use the cached version? if (verifyOrDelete(potentiallyCachedFile)) { delete(localFile); Utils.copyQuietly(potentiallyCachedFile, localFile); - prepareApkFileAndSendCompleteMessage(); - return false; - } - - String remoteAddress = Utils.getApkUrl(repoAddress, curApk); - Utils.debugLog(TAG, "Downloading apk from " + remoteAddress + " to " + localFile); - - try { - dlWrapper = DownloaderFactory.createAsync(context, remoteAddress, localFile, this); - dlWrapper.download(); - return true; - } catch (IOException e) { - e.printStackTrace(); - onErrorDownloading(); - } - - return false; - } - - private void sendMessage(String type) { - sendProgressEvent(new ProgressListener.Event(type)); - } - - // TODO: Completely remove progress listener, only use broadcasts... - private void sendProgressEvent(Event event) { - - event.getData().putLong(EVENT_SOURCE_ID, id); - - if (listener != null) { - listener.onProgress(event); - } - - Intent intent = new Intent(ACTION_STATUS); - intent.setData(Uri.parse(Utils.getApkUrl(repoAddress, curApk))); - intent.putExtras(event.getData()); - LocalBroadcastManager.getInstance(context).sendBroadcast(intent); - } - - @Override - public void onErrorDownloading() { - delete(localFile); - } - - private void cacheIfRequired() { - if (Preferences.get().shouldCacheApks()) { - Utils.debugLog(TAG, "Copying .apk file to cache at " + potentiallyCachedFile.getAbsolutePath()); - Utils.copyQuietly(localFile, potentiallyCachedFile); - } - } - - @Override - public void onDownloadComplete() { - - if (!verifyOrDelete(localFile)) { - sendProgressEvent(new Event(EVENT_ERROR)); - Toast.makeText(context, R.string.corrupt_download, Toast.LENGTH_LONG).show(); + sendDownloadComplete(); return; } - cacheIfRequired(); + Utils.debugLog(TAG, "Downloading apk from " + urlString + " to " + localFile); + localBroadcastManager.registerReceiver(downloadCompleteReceiver, + DownloaderService.getIntentFilter(urlString, Downloader.ACTION_COMPLETE)); - Utils.debugLog(TAG, "Download finished: " + localFile); - prepareApkFileAndSendCompleteMessage(); + DownloaderService.queue(context, urlString); } - @Override - public void onProgress(Event event) { - sendProgressEvent(event); + private void sendProgressEvent(String status) { + Intent intent = new Intent(status); + intent.setData(Uri.parse(urlString)); + intent.putExtra(Downloader.EXTRA_DOWNLOAD_PATH, localFile.getAbsolutePath()); + localBroadcastManager.sendBroadcast(intent); } - /** - * Attempts to cancel the download (if in progress) and also removes the progress - * listener - */ - public void cancel() { - if (dlWrapper != null) { - dlWrapper.attemptCancel(); + // TODO move this code to somewhere more appropriate + BroadcastReceiver downloadCompleteReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + localFile = SanitizedFile.knownSanitized(intent.getStringExtra(Downloader.EXTRA_DOWNLOAD_PATH)); + if (!verifyOrDelete(localFile)) { + sendProgressEvent(Downloader.ACTION_INTERRUPTED); + Toast.makeText(context, R.string.corrupt_download, Toast.LENGTH_LONG).show(); + return; + } + + if (Preferences.get().shouldCacheApks()) { + Utils.debugLog(TAG, "Copying .apk file to cache at " + potentiallyCachedFile.getAbsolutePath()); + Utils.copyQuietly(localFile, potentiallyCachedFile); + } + + sendDownloadComplete(); } - } - - public Apk getApk() { - return curApk; - } + }; } diff --git a/app/src/main/java/org/fdroid/fdroid/net/AsyncDownloadWrapper.java b/app/src/main/java/org/fdroid/fdroid/net/AsyncDownloadWrapper.java deleted file mode 100644 index 37992a85a..000000000 --- a/app/src/main/java/org/fdroid/fdroid/net/AsyncDownloadWrapper.java +++ /dev/null @@ -1,79 +0,0 @@ -package org.fdroid.fdroid.net; - -import android.os.Handler; -import android.os.Message; -import android.util.Log; - -import java.io.IOException; - -class AsyncDownloadWrapper extends Handler implements AsyncDownloader { - - private static final String TAG = "AsyncDownloadWrapper"; - - private static final int MSG_DOWNLOAD_COMPLETE = 2; - private static final int MSG_ERROR = 4; - - private final Downloader downloader; - private DownloadThread downloadThread; - - private final Listener listener; - - /** - * 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. - */ - AsyncDownloadWrapper(Downloader downloader, Listener listener) { - this.downloader = downloader; - this.listener = listener; - } - - public void download() { - downloadThread = new DownloadThread(); - downloadThread.start(); - } - - public void attemptCancel() { - if (downloader != null) { - downloader.cancelDownload(); - } - } - - /** - * Receives "messages" from the download thread, and passes them onto the - * relevant {@link AsyncDownloader.Listener} - */ - public void handleMessage(Message message) { - switch (message.arg1) { - case MSG_DOWNLOAD_COMPLETE: - listener.onDownloadComplete(); - break; - case MSG_ERROR: - listener.onErrorDownloading(); - break; - } - } - - private class DownloadThread extends Thread { - - public void run() { - try { - downloader.download(); - sendMessage(MSG_DOWNLOAD_COMPLETE); - } catch (InterruptedException e) { - // ignored - } catch (IOException e) { - Log.e(TAG, "I/O exception in download thread", e); - sendMessage(MSG_ERROR); - } - } - - private void sendMessage(int messageType) { - Message message = new Message(); - message.arg1 = messageType; - AsyncDownloadWrapper.this.sendMessage(message); - } - } -} diff --git a/app/src/main/java/org/fdroid/fdroid/net/AsyncDownloader.java b/app/src/main/java/org/fdroid/fdroid/net/AsyncDownloader.java deleted file mode 100644 index 413b76a05..000000000 --- a/app/src/main/java/org/fdroid/fdroid/net/AsyncDownloader.java +++ /dev/null @@ -1,17 +0,0 @@ -package org.fdroid.fdroid.net; - -import org.fdroid.fdroid.ProgressListener; - -public interface AsyncDownloader { - - interface Listener extends ProgressListener { - void onErrorDownloading(); - - void onDownloadComplete(); - } - - void download(); - - void attemptCancel(); - -} diff --git a/app/src/main/java/org/fdroid/fdroid/net/Downloader.java b/app/src/main/java/org/fdroid/fdroid/net/Downloader.java index f3ed8e207..76b7849a6 100644 --- a/app/src/main/java/org/fdroid/fdroid/net/Downloader.java +++ b/app/src/main/java/org/fdroid/fdroid/net/Downloader.java @@ -16,10 +16,14 @@ public abstract class Downloader { private static final String TAG = "Downloader"; public static final String LOCAL_ACTION_PROGRESS = "Downloader.PROGRESS"; + public static final String ACTION_STARTED = "org.fdroid.fdroid.net.Downloader.action.STARTED"; + public static final String ACTION_INTERRUPTED = "org.fdroid.fdroid.net.Downloader.action.INTERRUPTED"; + public static final String ACTION_COMPLETE = "org.fdroid.fdroid.net.Downloader.action.COMPLETE"; public static final String EXTRA_ADDRESS = "extraAddress"; - public static final String EXTRA_BYTES_READ = "extraBytesRead"; - public static final String EXTRA_TOTAL_BYTES = "extraTotalBytes"; + public static final String EXTRA_DOWNLOAD_PATH = "org.fdroid.fdroid.net.Downloader.extra.DOWNLOAD_PATH"; + public static final String EXTRA_BYTES_READ = "org.fdroid.fdroid.net.Downloader.extra.BYTES_READ"; + public static final String EXTRA_TOTAL_BYTES = "org.fdroid.fdroid.net.Downloader.extra.TOTAL_BYTES"; private volatile boolean cancelled = false; diff --git a/app/src/main/java/org/fdroid/fdroid/net/DownloaderFactory.java b/app/src/main/java/org/fdroid/fdroid/net/DownloaderFactory.java index 57ea2b7b4..e72eb0ed2 100644 --- a/app/src/main/java/org/fdroid/fdroid/net/DownloaderFactory.java +++ b/app/src/main/java/org/fdroid/fdroid/net/DownloaderFactory.java @@ -2,6 +2,7 @@ package org.fdroid.fdroid.net; import android.content.Context; import android.content.Intent; +import android.net.Uri; import android.support.v4.content.LocalBroadcastManager; import org.apache.commons.io.FilenameUtils; @@ -25,11 +26,17 @@ public class DownloaderFactory { throws IOException { File destFile = File.createTempFile("dl-", "", context.getCacheDir()); destFile.deleteOnExit(); // this probably does nothing, but maybe... - return create(context, new URL(urlString), destFile); + return create(context, urlString, destFile); } - public static Downloader create(Context context, URL url, File destFile) + public static Downloader create(Context context, Uri uri, File destFile) throws IOException { + return create(context, uri.toString(), destFile); + } + + public static Downloader create(Context context, String urlString, File destFile) + throws IOException { + URL url = new URL(urlString); Downloader downloader = null; if (localBroadcastManager == null) { localBroadcastManager = LocalBroadcastManager.getInstance(context); @@ -71,10 +78,4 @@ public class DownloaderFactory { private static boolean isLocalFile(URL url) { return "file".equalsIgnoreCase(url.getProtocol()); } - - public static AsyncDownloader createAsync(Context context, String urlString, File destFile, AsyncDownloader.Listener listener) - throws IOException { - URL url = new URL(urlString); - return new AsyncDownloadWrapper(create(context, url, destFile), listener); - } } diff --git a/app/src/main/java/org/fdroid/fdroid/net/DownloaderService.java b/app/src/main/java/org/fdroid/fdroid/net/DownloaderService.java new file mode 100644 index 000000000..0c3986610 --- /dev/null +++ b/app/src/main/java/org/fdroid/fdroid/net/DownloaderService.java @@ -0,0 +1,276 @@ +/* + * Copyright (C) 2008 The Android Open Source Project + * Copyright (C) 2016 Hans-Christoph Steiner + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.fdroid.fdroid.net; + +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.net.Uri; +import android.os.Handler; +import android.os.HandlerThread; +import android.os.IBinder; +import android.os.Looper; +import android.os.Message; +import android.os.PatternMatcher; +import android.os.Process; +import android.support.v4.content.LocalBroadcastManager; +import android.text.TextUtils; +import android.util.Log; + +import org.fdroid.fdroid.Utils; +import org.fdroid.fdroid.compat.FileCompat; +import org.fdroid.fdroid.data.SanitizedFile; + +import java.io.File; +import java.io.IOException; +import java.net.URL; +import java.util.HashMap; + +/** + * DownloaderService is a service that handles asynchronous download requests + * (expressed as {@link Intent}s) on demand. Clients send download requests + * through {@link android.content.Context#startService(Intent)} calls; the + * service is started as needed, handles each Intent in turn using a worker + * thread, and stops itself when it runs out of work. + *

+ *

This "work queue processor" pattern is commonly used to offload tasks + * from an application's main thread. The DownloaderService class exists to + * simplify this pattern and take care of the mechanics. DownloaderService + * will receive the Intents, launch a worker thread, and stop the service as + * appropriate. + *

+ *

All requests are handled on a single worker thread -- they may take as + * long as necessary (and will not block the application's main loop), but + * only one request will be processed at a time. + *

+ *

+ *

Developer Guides

+ *

For a detailed discussion about how to create services, read the + * Services developer guide.

+ *
+ * + * @see android.os.AsyncTask + */ +public class DownloaderService extends Service { + public static final String TAG = "DownloaderService"; + + private static final String ACTION_QUEUE = "org.fdroid.fdroid.net.DownloaderService.action.QUEUE"; + private static final String ACTION_CANCEL = "org.fdroid.fdroid.net.DownloaderService.action.CANCEL"; + + private volatile Looper serviceLooper; + private volatile ServiceHandler serviceHandler; + private volatile Downloader downloader; + private LocalBroadcastManager localBroadcastManager; + + private final HashMap queueWhats = new HashMap(); + private int what; + + private final class ServiceHandler extends Handler { + ServiceHandler(Looper looper) { + super(looper); + } + + @Override + public void handleMessage(Message msg) { + Log.i(TAG, "handleMessage " + msg); + handleIntent((Intent) msg.obj); + stopSelf(msg.arg1); + } + } + + @Override + public void onCreate() { + super.onCreate(); + Log.i(TAG, "onCreate"); + + HandlerThread thread = new HandlerThread(TAG, Process.THREAD_PRIORITY_BACKGROUND); + thread.start(); + + serviceLooper = thread.getLooper(); + serviceHandler = new ServiceHandler(serviceLooper); + localBroadcastManager = LocalBroadcastManager.getInstance(this); + } + + @Override + public void onStart(Intent intent, int startId) { + super.onStart(intent, startId); + Log.i(TAG, "onStart " + startId + " " + intent); + String uriString = intent.getDataString(); + if (uriString == null) { + Log.e(TAG, "Received Intent with no URI: " + intent); + return; + } + if (ACTION_CANCEL.equals(intent.getAction())) { + Log.i(TAG, "Removed " + intent); + Integer what = queueWhats.remove(uriString); + if (what != null && serviceHandler.hasMessages(what)) { + // the URL is in the queue, remove it + serviceHandler.removeMessages(what); + } else if (downloader != null && TextUtils.equals(uriString, downloader.sourceUrl.toString())) { + // the URL is being downloaded, cancel it + downloader.cancelDownload(); + } else { + Log.e(TAG, "CANCEL called on something not queued or running: " + startId + " " + intent); + } + } else if (ACTION_QUEUE.equals(intent.getAction())) { + Log.i(TAG, "Queued " + intent); + Message msg = serviceHandler.obtainMessage(); + msg.arg1 = startId; + msg.obj = intent; + msg.what = what++; + serviceHandler.sendMessage(msg); + Log.i(TAG, "queueWhats.put(" + uriString + ", " + msg.what); + queueWhats.put(uriString, msg.what); + } else { + Log.e(TAG, "Received Intent with unknown action: " + intent); + } + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + onStart(intent, startId); + Log.i(TAG, "onStartCommand " + intent); + return START_REDELIVER_INTENT; // if killed before completion, retry Intent + } + + @Override + public void onDestroy() { + Log.i(TAG, "onDestroy"); + serviceLooper.quit(); //NOPMD - this is copied from IntentService, no super call needed + } + + /** + * This service does not use binding, so no need to implement this method + */ + @Override + public IBinder onBind(Intent intent) { + return null; + } + + /** + * This method is invoked on the worker thread with a request to process. + * Only one Intent is processed at a time, but the processing happens on a + * worker thread that runs independently from other application logic. + * So, if this code takes a long time, it will hold up other requests to + * the same DownloaderService, but it will not hold up anything else. + * When all requests have been handled, the DownloaderService stops itself, + * so you should not ever call {@link #stopSelf}. + * + * @param intent The {@link Intent} passed via {@link + * android.content.Context#startService(Intent)}. + */ + protected void handleIntent(Intent intent) { + final Uri uri = intent.getData(); + Log.i(TAG, "handleIntent " + uri); + final SanitizedFile localFile = new SanitizedFile(Utils.getApkDownloadDir(this), uri.getLastPathSegment()); + sendBroadcast(uri, Downloader.ACTION_STARTED, localFile); + try { + downloader = DownloaderFactory.create(this, uri, localFile); + downloader.setListener(new Downloader.DownloaderProgressListener() { + @Override + public void sendProgress(URL sourceUrl, int bytesRead, int totalBytes) { + //Log.i(TAG, "sendProgress " + sourceUrl + " " + bytesRead + " / " + totalBytes); + Intent intent = new Intent(Downloader.LOCAL_ACTION_PROGRESS); + intent.setData(uri); + intent.putExtra(Downloader.EXTRA_BYTES_READ, bytesRead); + intent.putExtra(Downloader.EXTRA_TOTAL_BYTES, totalBytes); + localBroadcastManager.sendBroadcast(intent); + } + }); + downloader.download(); + // Need the apk to be world readable, so that the installer is able to read it. + // Note that saving it into external storage for the purpose of letting the installer + // have access is insecure, because apps with permission to write to the external + // storage can overwrite the app between F-Droid asking for it to be installed and + // the installer actually installing it. + FileCompat.setReadable(localFile, true, false); + sendBroadcast(uri, Downloader.ACTION_COMPLETE, localFile); + } catch (InterruptedException e) { + sendBroadcast(uri, Downloader.ACTION_INTERRUPTED, localFile); + } catch (IOException e) { + sendBroadcast(uri, Downloader.ACTION_INTERRUPTED, localFile); + e.printStackTrace(); + } finally { + if (downloader != null) { + downloader.close(); + } + } + downloader = null; + Log.i(TAG, "handleIntent DONE " + uri); + } + + private void sendBroadcast(Uri uri, String action, File file) { + Log.i(TAG, "sendBroadcast " + uri + " " + action + " " + file); + Intent intent = new Intent(action); + intent.setData(uri); + intent.putExtra(Downloader.EXTRA_DOWNLOAD_PATH, file.getAbsolutePath()); + localBroadcastManager.sendBroadcast(intent); + } + + /** + * Add a URL to the download queue. + *

+ * All notifications are sent as an {@link Intent} via local broadcasts to be received by + * + * @param context + * @param urlString The URL to add to the download queue + * @see #cancel(Context, String) + */ + public static void queue(Context context, String urlString) { + Log.i(TAG, "queue " + urlString); + Intent intent = new Intent(context, DownloaderService.class); + intent.setAction(ACTION_QUEUE); + intent.setData(Uri.parse(urlString)); + context.startService(intent); + } + + /** + * Remove a URL to the download queue, even if it is currently downloading. + *

+ * All notifications are sent as an {@link Intent} via local broadcasts to be received by + * + * @param context + * @param urlString The URL to remove from the download queue + * @see #queue(Context, String) + */ + public static void cancel(Context context, String urlString) { + Log.i(TAG, "cancel " + urlString); + Intent intent = new Intent(context, DownloaderService.class); + intent.setAction(ACTION_CANCEL); + intent.setData(Uri.parse(urlString)); + context.startService(intent); + } + + /** + * Get a prepared {@link IntentFilter} for use for matching this service's action events. + * + * @param urlString The full file URL to match. + * @param action {@link Downloader#ACTION_STARTED}, {@link Downloader#LOCAL_ACTION_PROGRESS}, + * {@link Downloader#ACTION_INTERRUPTED}, or {@link Downloader#ACTION_COMPLETE}, + * @return + */ + public static IntentFilter getIntentFilter(String urlString, String action) { + Uri uri = Uri.parse(urlString); + IntentFilter intentFilter = new IntentFilter(action); + intentFilter.addDataScheme(uri.getScheme()); + intentFilter.addDataAuthority(uri.getHost(), String.valueOf(uri.getPort())); + intentFilter.addDataPath(uri.getPath(), PatternMatcher.PATTERN_LITERAL); + return intentFilter; + } +} diff --git a/app/src/main/java/org/fdroid/fdroid/views/swap/SwapAppsView.java b/app/src/main/java/org/fdroid/fdroid/views/swap/SwapAppsView.java index 8672b7531..3a4df7e85 100644 --- a/app/src/main/java/org/fdroid/fdroid/views/swap/SwapAppsView.java +++ b/app/src/main/java/org/fdroid/fdroid/views/swap/SwapAppsView.java @@ -49,8 +49,8 @@ import org.fdroid.fdroid.data.App; import org.fdroid.fdroid.data.AppProvider; import org.fdroid.fdroid.data.Repo; import org.fdroid.fdroid.localrepo.SwapService; -import org.fdroid.fdroid.net.ApkDownloader; import org.fdroid.fdroid.net.Downloader; +import org.fdroid.fdroid.net.DownloaderService; import java.util.Timer; import java.util.TimerTask; @@ -231,6 +231,7 @@ public class SwapAppsView extends ListView implements private class ViewHolder { + private final LocalBroadcastManager localBroadcastManager; private App app; @Nullable @@ -247,13 +248,6 @@ public class SwapAppsView extends ListView implements private final BroadcastReceiver downloadProgressReceiver = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { - Apk apk = getApkToInstall(); - String broadcastUrl = intent.getStringExtra(Downloader.EXTRA_ADDRESS); - - if (apk != null && apk.repoAddress != null && !TextUtils.equals(Utils.getApkUrl(apk.repoAddress, apk), broadcastUrl)) { - return; - } - int read = intent.getIntExtra(Downloader.EXTRA_BYTES_READ, 0); int total = intent.getIntExtra(Downloader.EXTRA_TOTAL_BYTES, 0); if (total > 0) { @@ -267,21 +261,10 @@ public class SwapAppsView extends ListView implements } }; - private final BroadcastReceiver apkDownloadReceiver = new BroadcastReceiver() { + private final BroadcastReceiver appListViewResetReceiver = new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { - Apk apk = getApkToInstall(); - - // Note: This can also be done by using the build in IntentFilter.matchData() - // functionality, matching against the Intent.getData() of the incoming intent. - // I've chosen to do this way, because otherwise we need to query the database - // once for each ViewHolder in order to get the repository address for the - // apkToInstall. This way, we can wait until we receive an incoming intent (if - // at all) and then lazily load the apk to install. - String broadcastUrl = intent.getDataString(); - if (TextUtils.equals(Utils.getApkUrl(apk.repoAddress, apk), broadcastUrl)) { - resetView(); - } + resetView(); } }; @@ -299,11 +282,19 @@ public class SwapAppsView extends ListView implements ViewHolder() { // TODO: Unregister receivers correctly... - IntentFilter apkFilter = new IntentFilter(ApkDownloader.ACTION_STATUS); - LocalBroadcastManager.getInstance(getActivity()).registerReceiver(apkDownloadReceiver, apkFilter); - IntentFilter progressFilter = new IntentFilter(Downloader.LOCAL_ACTION_PROGRESS); - LocalBroadcastManager.getInstance(getActivity()).registerReceiver(downloadProgressReceiver, progressFilter); + Apk apk = getApkToInstall(); + String url = Utils.getApkUrl(apk.repoAddress, apk); + + localBroadcastManager = LocalBroadcastManager.getInstance(getActivity()); + localBroadcastManager.registerReceiver(appListViewResetReceiver, + DownloaderService.getIntentFilter(url, Downloader.ACTION_STARTED)); + localBroadcastManager.registerReceiver(downloadProgressReceiver, + DownloaderService.getIntentFilter(url, Downloader.LOCAL_ACTION_PROGRESS)); + localBroadcastManager.registerReceiver(appListViewResetReceiver, + DownloaderService.getIntentFilter(url, Downloader.ACTION_COMPLETE)); + localBroadcastManager.registerReceiver(appListViewResetReceiver, + DownloaderService.getIntentFilter(url, Downloader.ACTION_INTERRUPTED)); } public void setApp(@NonNull App app) { diff --git a/app/src/main/java/org/fdroid/fdroid/views/swap/SwapWorkflowActivity.java b/app/src/main/java/org/fdroid/fdroid/views/swap/SwapWorkflowActivity.java index 7edf430c3..7af7262f4 100644 --- a/app/src/main/java/org/fdroid/fdroid/views/swap/SwapWorkflowActivity.java +++ b/app/src/main/java/org/fdroid/fdroid/views/swap/SwapWorkflowActivity.java @@ -2,6 +2,7 @@ package org.fdroid.fdroid.views.swap; import android.app.Activity; import android.bluetooth.BluetoothAdapter; +import android.content.BroadcastReceiver; import android.content.ComponentName; import android.content.Context; import android.content.DialogInterface; @@ -34,7 +35,6 @@ import com.google.zxing.integration.android.IntentResult; import org.fdroid.fdroid.FDroidApp; import org.fdroid.fdroid.NfcHelper; import org.fdroid.fdroid.Preferences; -import org.fdroid.fdroid.ProgressListener; import org.fdroid.fdroid.R; import org.fdroid.fdroid.Utils; import org.fdroid.fdroid.data.Apk; @@ -46,6 +46,8 @@ import org.fdroid.fdroid.localrepo.LocalRepoManager; import org.fdroid.fdroid.localrepo.SwapService; import org.fdroid.fdroid.localrepo.peers.Peer; import org.fdroid.fdroid.net.ApkDownloader; +import org.fdroid.fdroid.net.Downloader; +import org.fdroid.fdroid.net.DownloaderService; import java.io.File; import java.util.Arrays; @@ -116,6 +118,8 @@ public class SwapWorkflowActivity extends AppCompatActivity { private boolean hasPreparedLocalRepo; private PrepareSwapRepo updateSwappableAppsTask; private NewRepoConfig confirmSwapConfig; + private LocalBroadcastManager localBroadcastManager; + private BroadcastReceiver downloadCompleteReceiver; @NonNull private final ServiceConnection serviceConnection = new ServiceConnection() { @@ -181,6 +185,8 @@ public class SwapWorkflowActivity extends AppCompatActivity { container = (ViewGroup) findViewById(R.id.fragment_container); + localBroadcastManager = LocalBroadcastManager.getInstance(this); + new SwapDebug().logStatus(); } @@ -779,16 +785,15 @@ public class SwapWorkflowActivity extends AppCompatActivity { public void install(@NonNull final App app) { final Apk apkToInstall = ApkProvider.Helper.find(this, app.packageName, app.suggestedVercode); final ApkDownloader downloader = new ApkDownloader(this, app, apkToInstall, apkToInstall.repoAddress); - downloader.setProgressListener(new ProgressListener() { + downloadCompleteReceiver = new BroadcastReceiver() { @Override - public void onProgress(Event event) { - switch (event.type) { - case ApkDownloader.EVENT_APK_DOWNLOAD_COMPLETE: - handleDownloadComplete(downloader.localFile(), app.packageName); - break; - } + public void onReceive(Context context, Intent intent) { + String path = intent.getStringExtra(Downloader.EXTRA_DOWNLOAD_PATH); + handleDownloadComplete(new File(path), app.packageName); } - }); + }; + localBroadcastManager.registerReceiver(downloadCompleteReceiver, + DownloaderService.getIntentFilter(downloader.urlString, Downloader.ACTION_COMPLETE)); downloader.download(); } @@ -808,6 +813,7 @@ public class SwapWorkflowActivity extends AppCompatActivity { // TODO: Boo! } }).installPackage(apkFile, packageName); + localBroadcastManager.unregisterReceiver(downloadCompleteReceiver); } catch (Installer.AndroidNotCompatibleException e) { // TODO: Handle exception properly }