use IntentService style to download all APKs via a queue

DownloaderService is based on IntentService to provide queued requests that
run in a background thread via the Handler and the HandlerThread.  It began
as the IntentService code, but it could not be a subclass because the
downloading needs to be cancelable.  IntentServices cannot be canceled and
they provide no visibility into their queue.

DownloaderService then announces relevant events via LocalBroadcastManager
and Intents with custom "action" Strings.

https://gitlab.com/fdroid/fdroidclient/issues/601 #601
This commit is contained in:
Hans-Christoph Steiner 2016-03-31 21:55:07 +02:00
parent d98d59a8d3
commit 20c66a825d
11 changed files with 463 additions and 366 deletions

View File

@ -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<DownloaderService> {
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);
}
}

View File

@ -439,6 +439,9 @@
</receiver>
<service android:name=".UpdateService" />
<service
android:name=".net.DownloaderService"
android:exported="false" />
<service android:name=".net.WifiStateChangeService" />
<service android:name=".localrepo.SwapService" />
</application>

View File

@ -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.
*
* <p/>
* NOTE: This is <em>not</em> 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();

View File

@ -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;
}
};
}

View File

@ -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);
}
}
}

View File

@ -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();
}

View File

@ -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;

View File

@ -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);
}
}

View File

@ -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.
* <p/>
* <p>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.
* <p/>
* <p>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.
* <p/>
* <div class="special reference">
* <h3>Developer Guides</h3>
* <p>For a detailed discussion about how to create services, read the
* <a href="{@docRoot}guide/topics/fundamentals/services.html">Services</a> developer guide.</p>
* </div>
*
* @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<String, Integer> queueWhats = new HashMap<String, Integer>();
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.
* <p/>
* 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.
* <p/>
* 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;
}
}

View File

@ -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) {

View File

@ -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
}