/*
* 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 org.fdroid.fdroid.ProgressListener;
import org.fdroid.fdroid.Utils;
import org.fdroid.fdroid.data.SanitizedFile;
import org.fdroid.fdroid.installer.ApkCache;
import java.io.File;
import java.io.IOException;
import java.net.URL;
/**
* DownloaderService is a service that handles asynchronous download requests
* (expressed as {@link Intent}s) on demand. Clients send download requests
* through {@link #queue(Context, String)} calls. The
* service is started as needed, it handles each {@code Intent} using a worker
* thread, and stops itself when it runs out of work. Requests can be canceled
* using {@link #cancel(Context, String)}. If this service is killed during
* operation, it will receive the queued {@link #queue(Context, String)} and
* {@link #cancel(Context, String)} requests again due to
* {@link Service#START_REDELIVER_INTENT}. Bad requests will be ignored,
* including on restart after killing via {@link Service#START_NOT_STICKY}.
* <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>
* 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>
* The full URL for the file to download is also used as the unique ID to
* represent the download itself throughout F-Droid. This follows the model
* of {@link Intent#setData(Uri)}, where the core data of an {@code Intent} is
* a {@code Uri}. For places that need an {@code int} ID,
* {@link String#hashCode()} should be used to get a reproducible, unique {@code int}
* from any {@code urlString}. The full URL is guaranteed to be unique since
* it points to a file on a filesystem. This is more important with media files
* than with APKs since there is not reliable standard for a unique ID for
* media files, unlike APKs with {@code packageName} and {@code versionCode}.
*
* @see android.app.IntentService
*/
public class DownloaderService extends Service {
private 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 static volatile ServiceHandler serviceHandler;
private static volatile Downloader downloader;
private LocalBroadcastManager localBroadcastManager;
private final class ServiceHandler extends Handler {
ServiceHandler(Looper looper) {
super(looper);
}
@Override
public void handleMessage(Message msg) {
Utils.debugLog(TAG, "Handling download message with ID of " + msg.what);
handleIntent((Intent) msg.obj);
stopSelf(msg.arg1);
}
}
@Override
public void onCreate() {
super.onCreate();
Utils.debugLog(TAG, "Creating downloader service.");
HandlerThread thread = new HandlerThread(TAG, Process.THREAD_PRIORITY_BACKGROUND);
thread.start();
serviceLooper = thread.getLooper();
serviceHandler = new ServiceHandler(serviceLooper);
localBroadcastManager = LocalBroadcastManager.getInstance(this);
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
Utils.debugLog(TAG, "Received Intent for downloading: " + intent + " (with a startId of " + startId + ")");
if (intent == null) {
return START_NOT_STICKY;
}
String uriString = intent.getDataString();
if (uriString == null) {
Utils.debugLog(TAG, "Received Intent with no URI: " + intent);
return START_NOT_STICKY;
}
if (ACTION_CANCEL.equals(intent.getAction())) {
Utils.debugLog(TAG, "Cancelling download of " + uriString);
Integer whatToRemove = uriString.hashCode();
if (serviceHandler.hasMessages(whatToRemove)) {
Utils.debugLog(TAG, "Removing download with ID of " + whatToRemove + " from service handler, then sending interrupted event.");
serviceHandler.removeMessages(whatToRemove);
sendBroadcast(intent.getData(), Downloader.ACTION_INTERRUPTED);
} else if (isActive(uriString)) {
downloader.cancelDownload();
} else {
Utils.debugLog(TAG, "ACTION_CANCEL called on something not queued or running (expected to find message with ID of " + whatToRemove + " in queue).");
}
} else if (ACTION_QUEUE.equals(intent.getAction())) {
Message msg = serviceHandler.obtainMessage();
msg.arg1 = startId;
msg.obj = intent;
msg.what = uriString.hashCode();
serviceHandler.sendMessage(msg);
Utils.debugLog(TAG, "Queued download of " + uriString);
} else {
Utils.debugLog(TAG, "Received Intent with unknown action: " + intent);
}
return START_REDELIVER_INTENT; // if killed before completion, retry Intent
}
@Override
public void onDestroy() {
Utils.debugLog(TAG, "Destroying downloader service. Will move to background and stop our Looper.");
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}.
* <p/>
* Downloads are put into subdirectories based on hostname/port of each repo
* to prevent files with the same names from conflicting. Each repo enforces
* unique APK file names on the server side.
*
* @param intent The {@link Intent} passed via {@link
* android.content.Context#startService(Intent)}.
*/
private void handleIntent(Intent intent) {
final Uri uri = intent.getData();
final SanitizedFile localFile = ApkCache.getApkDownloadPath(this, uri);
sendBroadcast(uri, Downloader.ACTION_STARTED, localFile);
try {
downloader = DownloaderFactory.create(this, uri, localFile);
downloader.setListener(new ProgressListener() {
@Override
public void onProgress(URL sourceUrl, int bytesRead, int totalBytes) {
Intent intent = new Intent(Downloader.ACTION_PROGRESS);
intent.setData(uri);
intent.putExtra(Downloader.EXTRA_BYTES_READ, bytesRead);
intent.putExtra(Downloader.EXTRA_TOTAL_BYTES, totalBytes);
localBroadcastManager.sendBroadcast(intent);
}
});
downloader.download();
sendBroadcast(uri, Downloader.ACTION_COMPLETE, localFile);
} catch (InterruptedException e) {
sendBroadcast(uri, Downloader.ACTION_INTERRUPTED, localFile);
} catch (IOException e) {
e.printStackTrace();
sendBroadcast(uri, Downloader.ACTION_INTERRUPTED, localFile,
e.getLocalizedMessage());
} finally {
if (downloader != null) {
downloader.close();
}
}
downloader = null;
}
private void sendBroadcast(Uri uri, String action) {
sendBroadcast(uri, action, null, null);
}
private void sendBroadcast(Uri uri, String action, File file) {
sendBroadcast(uri, action, file, null);
}
private void sendBroadcast(Uri uri, String action, File file, String errorMessage) {
Intent intent = new Intent(action);
intent.setData(uri);
if (file != null) {
intent.putExtra(Downloader.EXTRA_DOWNLOAD_PATH, file.getAbsolutePath());
}
if (!TextUtils.isEmpty(errorMessage)) {
intent.putExtra(Downloader.EXTRA_ERROR_MESSAGE, errorMessage);
}
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 this app's {@link Context}
* @param urlString The URL to add to the download queue
* @see #cancel(Context, String)
*/
public static void queue(Context context, String urlString) {
if (TextUtils.isEmpty(urlString)) {
return;
}
Utils.debugLog(TAG, "Preparing " + urlString + " to go into the download queue");
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 this app's {@link Context}
* @param urlString The URL to remove from the download queue
* @see #queue(Context, String)
*/
public static void cancel(Context context, String urlString) {
if (TextUtils.isEmpty(urlString)) {
return;
}
Utils.debugLog(TAG, "Preparing cancellation of " + urlString + " download");
Intent intent = new Intent(context, DownloaderService.class);
intent.setAction(ACTION_CANCEL);
intent.setData(Uri.parse(urlString));
context.startService(intent);
}
/**
* Check if a URL is waiting in the queue for downloading or if actively being downloaded.
* This is useful for checking whether to re-register {@link android.content.BroadcastReceiver}s
* in {@link android.app.Activity#onResume()}.
*/
public static boolean isQueuedOrActive(String urlString) {
if (TextUtils.isEmpty(urlString)) { //NOPMD - suggests unreadable format
return false;
}
if (serviceHandler == null) {
return false; // this service is not even running
}
return serviceHandler.hasMessages(urlString.hashCode()) || isActive(urlString);
}
/**
* Check if a URL is actively being downloaded.
*/
private static boolean isActive(String urlString) {
return downloader != null && TextUtils.equals(urlString, downloader.sourceUrl.toString());
}
/**
* Get a prepared {@link IntentFilter} for use for matching this service's action events.
*
* @param urlString The full file URL to match.
*/
public static IntentFilter getIntentFilter(String urlString) {
Uri uri = Uri.parse(urlString);
IntentFilter intentFilter = new IntentFilter();
intentFilter.addAction(Downloader.ACTION_STARTED);
intentFilter.addAction(Downloader.ACTION_PROGRESS);
intentFilter.addAction(Downloader.ACTION_COMPLETE);
intentFilter.addAction(Downloader.ACTION_INTERRUPTED);
intentFilter.addDataScheme(uri.getScheme());
intentFilter.addDataAuthority(uri.getHost(), String.valueOf(uri.getPort()));
intentFilter.addDataPath(uri.getPath(), PatternMatcher.PATTERN_LITERAL);
return intentFilter;
}
}