/*
* Copyright (C) 2008 The Android Open Source Project
*
* 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 com.novoda.downloadmanager.lib;
import android.app.AlarmManager;
import android.app.PendingIntent;
import android.app.Service;
import android.content.ContentResolver;
import android.content.ContentUris;
import android.content.ContentValues;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.database.ContentObserver;
import android.net.Uri;
import android.os.Environment;
import android.os.Handler;
import android.os.HandlerThread;
import android.os.IBinder;
import android.os.Message;
import android.os.Process;
import android.support.annotation.NonNull;
import com.novoda.downloadmanager.lib.logger.LLog;
import com.novoda.downloadmanager.notifications.DownloadNotifier;
import com.novoda.downloadmanager.notifications.DownloadNotifierFactory;
import java.io.File;
import java.io.FileDescriptor;
import java.io.PrintWriter;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import static android.text.format.DateUtils.MINUTE_IN_MILLIS;
/**
* Performs background downloads as requested by applications that use
* {DownloadManager}. Multiple start commands can be issued at this
* service, and it will continue running until no downloads are being actively
* processed. It may schedule alarms to resume downloads in future.
* <p/>
* Any database updates important enough to initiate tasks should always be
* delivered through {Context#startService(Intent)}.
*/
public class DownloadService extends Service {
// TODO: migrate WakeLock from individual DownloadThreads out into
// DownloadReceiver to protect our entire workflow.
private static final boolean DEBUG_LIFECYCLE = false;
private final ContentLengthFetcher contentLengthFetcher = new ContentLengthFetcher();
private SystemFacade systemFacade;
private AlarmManager alarmManager;
private StorageManager storageManager;
private DownloadManagerContentObserver downloadManagerContentObserver;
private DownloadNotifier downloadNotifier;
private ExecutorService executor;
private DownloadScanner downloadScanner;
private HandlerThread updateThread;
private Handler updateHandler;
private volatile int lastStartId;
private BatchRepository batchRepository;
private DownloadsRepository downloadsRepository;
private DownloadDeleter downloadDeleter;
private DownloadReadyChecker downloadReadyChecker;
private DownloadsUriProvider downloadsUriProvider;
private BatchInformationBroadcaster batchInformationBroadcaster;
private NetworkChecker networkChecker;
private DestroyListener destroyListener;
/**
* Receives notifications when the data in the content provider changes
*/
private class DownloadManagerContentObserver extends ContentObserver {
public DownloadManagerContentObserver() {
super(new Handler());
}
@Override
public void onChange(final boolean selfChange) {
enqueueUpdate();
}
}
/**
* Returns an IBinder instance when someone wants to connect to this
* service. Binding to this service is not allowed.
*
* @throws UnsupportedOperationException
*/
@Override
public IBinder onBind(@NonNull Intent intent) {
throw new UnsupportedOperationException("Cannot bind to Download Manager Service");
}
@Override
public void onCreate() {
super.onCreate();
LLog.v("Service onCreate");
if (systemFacade == null) {
systemFacade = new RealSystemFacade(this, new Clock());
}
this.downloadsUriProvider = DownloadsUriProvider.getInstance();
this.downloadDeleter = new DownloadDeleter(getContentResolver());
this.batchRepository = BatchRepository.from(getContentResolver(), downloadDeleter, downloadsUriProvider, systemFacade);
this.networkChecker = new NetworkChecker(this.systemFacade);
DownloadManagerModules modules = getDownloadManagerModules();
this.destroyListener = modules.getDestroyListener();
DownloadClientReadyChecker downloadClientReadyChecker = modules.getDownloadClientReadyChecker();
PublicFacingDownloadMarshaller downloadMarshaller = new PublicFacingDownloadMarshaller();
this.downloadReadyChecker = new DownloadReadyChecker(this.systemFacade, networkChecker, downloadClientReadyChecker, downloadMarshaller);
String applicationPackageName = getApplicationContext().getPackageName();
this.batchInformationBroadcaster = new BatchInformationBroadcaster(this, applicationPackageName);
alarmManager = (AlarmManager) getSystemService(Context.ALARM_SERVICE);
ContentResolver contentResolver = getContentResolver();
File downloadDataDir = StorageManager.getDownloadDataDirectory(this);
File externalStorageDir = Environment.getExternalStorageDirectory();
File[] externalStorageDirs = new File[0];
if (android.os.Build.VERSION.SDK_INT >= 19) {
externalStorageDirs = this.getExternalFilesDirs(null);
}
File internalStorageDir = Environment.getDataDirectory();
File systemCacheDir = Environment.getDownloadCacheDirectory();
storageManager = new StorageManager(contentResolver, externalStorageDir, externalStorageDirs, internalStorageDir, systemCacheDir, downloadDataDir, downloadsUriProvider);
downloadScanner = new DownloadScanner(getContentResolver(), this, downloadsUriProvider);
DownloadNotifierFactory downloadNotifierFactory = new DownloadNotifierFactory();
PublicFacingStatusTranslator statusTranslator = new PublicFacingStatusTranslator();
downloadNotifier = downloadNotifierFactory.getDownloadNotifier(this, modules, downloadMarshaller, statusTranslator);
downloadNotifier.cancelAll();
downloadManagerContentObserver = new DownloadManagerContentObserver();
getContentResolver().registerContentObserver(
downloadsUriProvider.getAllDownloadsUri(),
true, downloadManagerContentObserver
);
PackageManager packageManager = getPackageManager();
String packageName = getApplicationContext().getPackageName();
ConcurrentDownloadsLimitProvider concurrentDownloadsLimitProvider = new ConcurrentDownloadsLimitProvider(packageManager, packageName);
DownloadExecutorFactory factory = new DownloadExecutorFactory(concurrentDownloadsLimitProvider);
executor = factory.createExecutor();
this.downloadsRepository = new DownloadsRepository(
systemFacade, getContentResolver(), new DownloadsRepository.DownloadInfoCreator() {
@Override
public FileDownloadInfo create(FileDownloadInfo.Reader reader) {
return createNewDownloadInfo(reader);
}
}, downloadsUriProvider
);
unlockStaleDownloads();
updateThread = new HandlerThread("DownloadManager-UpdateThread");
updateThread.start();
updateHandler = new Handler(updateThread.getLooper(), updateCallback);
}
private void unlockStaleDownloads() {
List<String> batchesToBeUnlocked = downloadsRepository.getCurrentDownloadingOrSubmittedBatchIds();
if (batchesToBeUnlocked.isEmpty()) {
return;
}
downloadsRepository.updateRunningOrSubmittedDownloadsToPending();
batchRepository.updateBatchesToPendingStatus(batchesToBeUnlocked);
}
/**
* Keeps a local copy of the info about a download, and initiates the
* download if appropriate.
*/
private FileDownloadInfo createNewDownloadInfo(FileDownloadInfo.Reader reader) {
FileDownloadInfo info = reader.newDownloadInfo(systemFacade, downloadsUriProvider);
LLog.v("processing inserted download " + info.getId());
return info;
}
private DownloadManagerModules getDownloadManagerModules() {
if (getApplication() instanceof DownloadManagerModules.Provider) {
return ((DownloadManagerModules.Provider) getApplication()).provideDownloadManagerModules();
}
return new DefaultsDownloadManagerModules(getApplication());
}
@Override
public int onStartCommand(@NonNull Intent intent, int flags, int startId) {
int returnValue = super.onStartCommand(intent, flags, startId);
LLog.v("Service onStart");
lastStartId = startId;
enqueueUpdate();
return returnValue;
}
@Override
public void onDestroy() {
shutDown();
destroyListener.onDownloadManagerModulesDestroyed();
LLog.v("Service onDestroy");
super.onDestroy();
}
private void shutDown() {
LLog.d("Shutting down service");
getContentResolver().unregisterContentObserver(downloadManagerContentObserver);
downloadScanner.shutdown();
executor.shutdownNow();
updateThread.quit();
}
/**
* Enqueue an {#updateLocked()} pass to occur in future.
*/
private void enqueueUpdate() {
if (updateThread.isAlive()) {
updateHandler.removeMessages(MSG_UPDATE);
updateHandler.obtainMessage(MSG_UPDATE, lastStartId, -1).sendToTarget();
}
}
/**
* Enqueue an {#updateLocked()} pass to occur after delay, usually to
* catch any finished operations that didn't trigger an update pass.
*/
private void enqueueFinalUpdate() {
updateHandler.removeMessages(MSG_FINAL_UPDATE);
updateHandler.sendMessageDelayed(
updateHandler.obtainMessage(MSG_FINAL_UPDATE, lastStartId, -1),
5 * MINUTE_IN_MILLIS
);
}
private static final int MSG_UPDATE = 1;
private static final int MSG_FINAL_UPDATE = 2;
private final Handler.Callback updateCallback = new Handler.Callback() {
@Override
public boolean handleMessage(@NonNull Message msg) {
Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND);
final int startId = msg.arg1;
if (DEBUG_LIFECYCLE) {
LLog.v("Updating for startId " + startId);
}
// Since database is current source of truth, our "active" status
// depends on database state. We always get one final update pass
// once the real actions have finished and persisted their state.
// TODO: switch to asking real tasks to derive active state
// TODO: handle media scanner timeouts
boolean isActive = updateLocked();
if (msg.what == MSG_FINAL_UPDATE) {
// Dump thread stacks belonging to pool
for (Map.Entry<Thread, StackTraceElement[]> entry :
Thread.getAllStackTraces().entrySet()) {
if (entry.getKey().getName().startsWith("pool")) {
LLog.d(entry.getKey() + ": " + Arrays.toString(entry.getValue()));
}
}
LLog.wtf(new IllegalStateException("someone didn't update correctly"), "Final update pass triggered, isActive=" + isActive);
}
if (isActive) {
// Still doing useful work, keep service alive. These active
// tasks will trigger another update pass when they're finished.
// Enqueue delayed update pass to catch finished operations that
// didn't trigger an update pass; these are bugs.
enqueueFinalUpdate();
} else {
// No active tasks, and any pending update messages can be
// ignored, since any updates important enough to initiate tasks
// will always be delivered with a new startId.
if (stopSelfResult(startId)) {
if (DEBUG_LIFECYCLE) {
LLog.v("Nothing left; stopped");
}
shutDown();
}
}
return true;
}
};
/**
* Update {#downloads} to match {DownloadProvider} state.
* Depending on current download state it may enqueue {DownloadTask}
* instances, request {DownloadScanner} scans, update user-visible
* notifications, and/or schedule future actions with {AlarmManager}.
* <p/>
* Should only be called from {#updateThread} as after being
* requested through {#enqueueUpdate()}.
* for (DownloadInfo info : downloadBatch.getDownloads()) {
* if (info.isDeleted) {
* snapshot taken in this update.
*/
private boolean updateLocked() {
boolean isActive = false;
long nextRetryTimeMillis = Long.MAX_VALUE;
long now = systemFacade.currentTimeMillis();
Collection<FileDownloadInfo> allDownloads = downloadsRepository.getAllDownloads();
updateTotalBytesFor(allDownloads);
List<DownloadBatch> downloadBatches = batchRepository.retrieveBatchesFor(allDownloads);
for (DownloadBatch downloadBatch : downloadBatches) {
if (downloadBatch.isActive()) {
isActive = true;
break;
}
}
for (DownloadBatch downloadBatch : downloadBatches) {
if (downloadBatch.isDeleted() || downloadBatch.prune(downloadDeleter)) {
continue;
}
if (!isActive && downloadReadyChecker.canDownload(downloadBatch)) {
boolean isBatchStartingForTheFirstTime = batchRepository.isBatchStartingForTheFirstTime(downloadBatch.getBatchId());
if (isBatchStartingForTheFirstTime) {
handleBatchStartingForTheFirstTime(downloadBatch);
}
downloadOrContinueBatch(downloadBatch.getDownloads());
isActive = true;
} else if (downloadBatch.scanCompletedMediaIfReady(downloadScanner)) {
isActive = true;
}
nextRetryTimeMillis = downloadBatch.nextActionMillis(now, nextRetryTimeMillis);
}
batchRepository.deleteMarkedBatchesFor(allDownloads);
updateUserVisibleNotification(downloadBatches);
// Set alarm when next action is in future. It's okay if the service
// continues to run in meantime, since it will kick off an update pass.
if (nextRetryTimeMillis > 0 && nextRetryTimeMillis < Long.MAX_VALUE) {
LLog.v("scheduling start in " + nextRetryTimeMillis + "ms");
Intent intent = new Intent(Constants.ACTION_RETRY);
intent.setClass(this, DownloadReceiver.class);
alarmManager.set(AlarmManager.RTC_WAKEUP, now + nextRetryTimeMillis, PendingIntent.getBroadcast(this, 0, intent, PendingIntent.FLAG_ONE_SHOT));
}
if (!isActive) {
moveSubmittedTasksToBatchStatusIfNecessary();
}
return isActive;
}
private void handleBatchStartingForTheFirstTime(DownloadBatch downloadBatch) {
batchRepository.markBatchAsStarted(downloadBatch.getBatchId());
batchInformationBroadcaster.notifyBatchStartedFor(downloadBatch.getBatchId());
}
private void moveSubmittedTasksToBatchStatusIfNecessary() {
List<FileDownloadInfo> allDownloads = downloadsRepository.getAllDownloads();
List<DownloadBatch> downloadBatches = batchRepository.retrieveBatchesFor(allDownloads);
for (DownloadBatch downloadBatch : downloadBatches) {
List<Long> ids = getSubmittedDownloadIdsFrom(downloadBatch);
downloadsRepository.moveDownloadsStatusTo(ids, downloadBatch.getStatus());
}
}
private List<Long> getSubmittedDownloadIdsFrom(DownloadBatch downloadBatch) {
List<Long> ids = new ArrayList<>();
List<FileDownloadInfo> downloads = downloadBatch.getDownloads();
for (FileDownloadInfo downloadInfo : downloads) {
if (downloadInfo.getStatus() == DownloadStatus.SUBMITTED) {
ids.add(downloadInfo.getId());
}
}
return ids;
}
private void downloadOrContinueBatch(List<FileDownloadInfo> downloads) {
for (FileDownloadInfo info : downloads) {
if (!DownloadStatus.isCompleted(info.getStatus()) && !info.isSubmittedOrRunning()) {
download(info);
return;
}
}
}
private void download(FileDownloadInfo info) {
Uri downloadUri = ContentUris.withAppendedId(downloadsUriProvider.getAllDownloadsUri(), info.getId());
FileDownloadInfo.ControlStatus.Reader controlReader = new FileDownloadInfo.ControlStatus.Reader(getContentResolver(), downloadUri);
DownloadBatch downloadBatch = batchRepository.retrieveBatchFor(info);
DownloadTask downloadTask = new DownloadTask(
this, systemFacade, info, downloadBatch, storageManager, downloadNotifier,
batchInformationBroadcaster, batchRepository, downloadsUriProvider,
controlReader, networkChecker, downloadReadyChecker, new Clock(),
downloadsRepository
);
downloadsRepository.setDownloadSubmitted(info);
int batchStatus = batchRepository.calculateBatchStatus(info.getBatchId());
batchRepository.updateBatchStatus(info.getBatchId(), batchStatus);
executor.submit(downloadTask);
}
private void updateTotalBytesFor(Collection<FileDownloadInfo> downloadInfos) {
ContentValues values = new ContentValues(1);
for (FileDownloadInfo downloadInfo : downloadInfos) {
if (downloadInfo.hasUnknownTotalBytes()) {
long totalBytes = contentLengthFetcher.fetchContentLengthFor(downloadInfo);
values.put(DownloadContract.Downloads.COLUMN_TOTAL_BYTES, totalBytes);
getContentResolver().update(downloadInfo.getAllDownloadsUri(), values, null, null);
}
}
}
private void updateUserVisibleNotification(Collection<DownloadBatch> batches) {
downloadNotifier.updateWith(batches);
}
@Override
protected void dump(FileDescriptor fd, @NonNull PrintWriter writer, String[] args) {
LLog.e("I want to dump but nothing to dump into");
}
}