package com.quran.labs.androidquran.service; import android.app.Service; import android.content.Context; import android.content.Intent; import android.net.wifi.WifiManager; import android.net.wifi.WifiManager.WifiLock; import android.os.Environment; import android.os.Handler; import android.os.HandlerThread; import android.os.IBinder; import android.os.Looper; import android.os.Message; import android.os.StatFs; import android.support.v4.content.LocalBroadcastManager; import com.crashlytics.android.Crashlytics; import com.quran.labs.androidquran.QuranApplication; import com.quran.labs.androidquran.data.QuranInfo; import com.quran.labs.androidquran.data.SuraAyah; import com.quran.labs.androidquran.service.util.QuranDownloadNotifier; import com.quran.labs.androidquran.service.util.QuranDownloadNotifier.NotificationDetails; import com.quran.labs.androidquran.service.util.QuranDownloadNotifier.ProgressIntent; import com.quran.labs.androidquran.util.QuranFileUtils; import com.quran.labs.androidquran.util.QuranSettings; import com.quran.labs.androidquran.util.QuranUtils; import com.quran.labs.androidquran.util.ZipUtils; import java.io.File; import java.io.IOException; import java.util.HashMap; import java.util.Locale; import java.util.Map; import javax.inject.Inject; import okhttp3.Call; import okhttp3.OkHttpClient; import okhttp3.Request; import okhttp3.Response; import okhttp3.ResponseBody; import okio.BufferedSink; import okio.BufferedSource; import okio.Okio; import timber.log.Timber; public class QuranDownloadService extends Service implements ZipUtils.ZipListener<NotificationDetails> { public static final String TAG = "QuranDownloadService"; public static final String DEFAULT_TAG = "QuranDownload"; // intent actions public static final String ACTION_DOWNLOAD_URL = "com.quran.labs.androidquran.DOWNLOAD_URL"; public static final String ACTION_CANCEL_DOWNLOADS = "com.quran.labs.androidquran.CANCEL_DOWNLOADS"; public static final String ACTION_RECONNECT = "com.quran.labs.androidquran.RECONNECT"; // extras public static final String EXTRA_URL = "url"; public static final String EXTRA_DESTINATION = "destination"; public static final String EXTRA_NOTIFICATION_NAME = "notificationName"; public static final String EXTRA_DOWNLOAD_KEY = "downloadKey"; public static final String EXTRA_REPEAT_LAST_ERROR = "repeatLastError"; public static final String EXTRA_DOWNLOAD_TYPE = "downloadType"; public static final String EXTRA_OUTPUT_FILE_NAME = "outputFileName"; // extras for range downloads public static final String EXTRA_START_VERSE = "startVerse"; public static final String EXTRA_END_VERSE = "endVerse"; public static final String EXTRA_IS_GAPLESS = "isGapless"; // download types (also handler message types) public static final int DOWNLOAD_TYPE_UNDEF = 0; public static final int DOWNLOAD_TYPE_PAGES = 1; public static final int DOWNLOAD_TYPE_AUDIO = 2; public static final int DOWNLOAD_TYPE_TRANSLATION = 3; public static final int DOWNLOAD_TYPE_ARABIC_SEARCH_DB = 4; // continuation of handler message types public static final int NO_OP = 9; // error prefs public static final String PREF_LAST_DOWNLOAD_ERROR = "lastDownloadError"; public static final String PREF_LAST_DOWNLOAD_ITEM = "lastDownloadItem"; public static final int BUFFER_SIZE = 4096 * 2; private static final int WAIT_TIME = 15 * 1000; private static final int RETRY_COUNT = 3; private static final String PARTIAL_EXT = ".part"; // download method return values private static final int DOWNLOAD_SUCCESS = 0; private Looper mServiceLooper; private ServiceHandler mServiceHandler; private QuranDownloadNotifier mNotifier; // written from ui thread and read by download thread private volatile boolean mIsDownloadCanceled; private LocalBroadcastManager mBroadcastManager; private QuranSettings mQuranSettings; private WifiLock mWifiLock; private Intent mLastSentIntent = null; private Map<String, Boolean> mSuccessfulZippedDownloads = null; private Map<String, Intent> mRecentlyFailedDownloads = null; @Inject OkHttpClient mOkHttpClient; private final class ServiceHandler extends Handler { public ServiceHandler(Looper looper) { super(looper); } @Override public void handleMessage(Message msg) { if (msg.obj != null) { onHandleIntent((Intent) msg.obj); } stopSelf(msg.arg1); } } @Override public void onCreate() { super.onCreate(); HandlerThread thread = new HandlerThread(TAG); thread.start(); Context appContext = getApplicationContext(); mNotifier = new QuranDownloadNotifier(this); mWifiLock = ((WifiManager) appContext.getSystemService(Context.WIFI_SERVICE)) .createWifiLock(WifiManager.WIFI_MODE_FULL, "downloadLock"); mServiceLooper = thread.getLooper(); mServiceHandler = new ServiceHandler(mServiceLooper); mIsDownloadCanceled = false; mSuccessfulZippedDownloads = new HashMap<>(); mRecentlyFailedDownloads = new HashMap<>(); mQuranSettings = QuranSettings.getInstance(this); ((QuranApplication) getApplication()).getApplicationComponent().inject(this); mBroadcastManager = LocalBroadcastManager.getInstance(appContext); } private void handleOnStartCommand(Intent intent, int startId) { if (intent != null) { if (ACTION_CANCEL_DOWNLOADS.equals(intent.getAction())) { mServiceHandler.removeCallbacksAndMessages(null); mIsDownloadCanceled = true; sendNoOpMessage(startId); } else if (ACTION_RECONNECT.equals(intent.getAction())) { int type = intent.getIntExtra(EXTRA_DOWNLOAD_TYPE, DOWNLOAD_TYPE_UNDEF); Intent currentLast = mLastSentIntent; int lastType = currentLast == null ? -1 : currentLast.getIntExtra(EXTRA_DOWNLOAD_TYPE, DOWNLOAD_TYPE_UNDEF); if (type == lastType) { if (currentLast != null) { mBroadcastManager.sendBroadcast(currentLast); } } else if (mServiceHandler.hasMessages(type)) { Intent progressIntent = new Intent(ProgressIntent.INTENT_NAME); progressIntent.putExtra(ProgressIntent.DOWNLOAD_TYPE, type); progressIntent.putExtra(ProgressIntent.STATE, ProgressIntent.STATE_DOWNLOADING); mBroadcastManager.sendBroadcast(progressIntent); } sendNoOpMessage(startId); } else { // if we are currently downloading, resend the last broadcast // and don't queue anything String download = intent.getStringExtra(EXTRA_DOWNLOAD_KEY); Intent currentLast = mLastSentIntent; String currentDownload = currentLast == null ? null : currentLast.getStringExtra(ProgressIntent.DOWNLOAD_KEY); if (download != null && currentDownload != null && download.equals(currentDownload)) { Timber.d("resending last broadcast..."); mBroadcastManager.sendBroadcast(currentLast); String state = currentLast.getStringExtra(ProgressIntent.STATE); if (!ProgressIntent.STATE_SUCCESS.equals(state) && !ProgressIntent.STATE_ERROR.equals(state)) { // re-queue fatal errors and success cases again just in case // of a race condition in which we miss the error pref and // miss the success/failure notification and this re-play sendNoOpMessage(startId); Timber.d("leaving..."); return; } } int what = intent.getIntExtra(EXTRA_DOWNLOAD_TYPE, DOWNLOAD_TYPE_UNDEF); // put the message in the queue Message msg = mServiceHandler.obtainMessage(); msg.arg1 = startId; msg.obj = intent; msg.what = what; mServiceHandler.sendMessage(msg); } } } /** * send a no-op message to the handler to ensure * that the service isn't left running. * * @param id the start id */ private void sendNoOpMessage(int id) { Message msg = mServiceHandler.obtainMessage(); msg.arg1 = id; msg.obj = null; msg.what = NO_OP; mServiceHandler.sendMessage(msg); } @Override public int onStartCommand(Intent intent, int flags, int startId) { handleOnStartCommand(intent, startId); return START_NOT_STICKY; } @Override public void onDestroy() { if (mWifiLock.isHeld()) { mWifiLock.release(); } mServiceLooper.quit(); } @Override public IBinder onBind(Intent intent) { return null; } private void onHandleIntent(Intent intent) { if (ACTION_DOWNLOAD_URL.equals(intent.getAction())) { String url = intent.getStringExtra(EXTRA_URL); String key = intent.getStringExtra(EXTRA_DOWNLOAD_KEY); int type = intent.getIntExtra(EXTRA_DOWNLOAD_TYPE, 0); String notificationTitle = intent.getStringExtra(EXTRA_NOTIFICATION_NAME); NotificationDetails details = new NotificationDetails(notificationTitle, key, type); // check if already downloaded, and if so, send broadcast boolean isZipFile = url.endsWith(".zip"); if (isZipFile && mSuccessfulZippedDownloads.containsKey(url)) { mLastSentIntent = mNotifier.broadcastDownloadSuccessful(details); return; } else if (mRecentlyFailedDownloads.containsKey(url)) { // if recently failed and we want to repeat the last error... if (intent.getBooleanExtra(EXTRA_REPEAT_LAST_ERROR, false)) { Intent failedIntent = mRecentlyFailedDownloads.get(url); if (failedIntent != null) { // re-broadcast and leave - just in case of race condition mBroadcastManager.sendBroadcast(failedIntent); return; } } // otherwise, remove the fact it was an error and retry else { mRecentlyFailedDownloads.remove(url); } } mNotifier.resetNotifications(); // get the start/end ayah info if it's a ranged download SuraAyah startAyah = intent.getParcelableExtra(EXTRA_START_VERSE); SuraAyah endAyah = intent.getParcelableExtra(EXTRA_END_VERSE); boolean isGapless = intent.getBooleanExtra(EXTRA_IS_GAPLESS, false); String outputFile = intent.getStringExtra(EXTRA_OUTPUT_FILE_NAME); if (outputFile == null) { outputFile = getFilenameFromUrl(url); } String destination = intent.getStringExtra(EXTRA_DESTINATION); mLastSentIntent = null; if (destination == null) { return; } boolean result; if (startAyah != null && endAyah != null) { result = downloadRange(url, destination, startAyah, endAyah, isGapless, details); } else { result = download(url, destination, outputFile, details); } if (result && isZipFile) { mSuccessfulZippedDownloads.put(url, true); } else if (!result) { mRecentlyFailedDownloads.put(url, mLastSentIntent); } mLastSentIntent = null; } } private boolean download(String urlString, String destination, String outputFile, NotificationDetails details) { // make the directory if it doesn't exist new File(destination).mkdirs(); Timber.d("making directory %s", destination); details.setFileStatus(1, 1); // notify download starting mLastSentIntent = mNotifier.notifyProgress(details, 0, 0); boolean result = downloadFileWrapper(urlString, destination, outputFile, details); if (result) { mLastSentIntent = mNotifier.notifyDownloadSuccessful(details); } return result; } private boolean downloadRange(String urlString, String destination, SuraAyah startVerse, SuraAyah endVerse, boolean isGapless, NotificationDetails details) { details.setIsGapless(isGapless); new File(destination).mkdirs(); int totalAyahs = 0; int startSura = startVerse.sura; int startAyah = startVerse.ayah; int endSura = endVerse.sura; int endAyah = endVerse.ayah; if (isGapless) { totalAyahs = endSura - startSura + 1; if (endAyah == 0) { totalAyahs--; } } else { if (startSura == endSura) { totalAyahs = endAyah - startAyah + 1; } else { // add the number ayahs from suras in between start and end for (int i = startSura + 1; i < endSura; i++) { totalAyahs += QuranInfo.getNumAyahs(i); } // add the number of ayahs from the start sura totalAyahs += QuranInfo.getNumAyahs(startSura) - startAyah + 1; // add the number of ayahs from the last sura totalAyahs += endAyah; } } Timber.d("downloadRange for %d between %d:%d to %d:%d, gaplessFlag: %s", totalAyahs, startSura, startAyah, endSura, endAyah, isGapless ? "true" : "false"); details.setFileStatus(1, totalAyahs); mLastSentIntent = mNotifier.notifyProgress(details, 0, 0); // extension and filename template don't change final String singleFileName = QuranDownloadService.getFilenameFromUrl(urlString); final int extLocation = singleFileName.lastIndexOf("."); final String extension = singleFileName.substring(extLocation); boolean result; for (int i = startSura; i <= endSura; i++) { int lastAyah = QuranInfo.getNumAyahs(i); if (i == endSura) { lastAyah = endAyah; } int firstAyah = 1; if (i == startSura) { firstAyah = startAyah; } details.sura = i; if (isGapless) { if (i == endSura && endAyah == 0) { continue; } String destDir = destination + File.separator; String url = String.format(Locale.US, urlString, i); Timber.d("gapless asking to download %s to %s", url, destDir); final String filename = QuranDownloadService.getFilenameFromUrl(url); if (!new File(destDir, filename).exists()) { result = downloadFileWrapper(url, destDir, filename, details); if (!result) { return false; } } details.currentFile++; continue; } // same destination directory for ayahs within the same sura String destDir = destination + File.separator + i + File.separator; new File(destDir).mkdirs(); for (int j = firstAyah; j <= lastAyah; j++) { details.ayah = j; String url = String.format(Locale.US, urlString, i, j); String destFile = j + extension; if (!new File(destDir, destFile).exists()) { result = downloadFileWrapper(url, destDir, destFile, details); if (!result) { return false; } } details.currentFile++; } } if (!isGapless) { // attempt to download basmallah if it doesn't exist String destDir = destination + File.separator + 1 + File.separator; new File(destDir).mkdirs(); File basmallah = new File(destDir, "1" + extension); if (!basmallah.exists()) { Timber.d("basmallah doesn't exist, downloading..."); String url = String.format(Locale.US, urlString, 1, 1); String destFile = 1 + extension; result = downloadFileWrapper(url, destDir, destFile, details); if (!result) { return false; } } } mLastSentIntent = mNotifier.notifyDownloadSuccessful(details); return true; } private boolean downloadFileWrapper(String urlString, String destination, String outputFile, NotificationDetails details) { boolean previouslyCorrupted = false; int res = DOWNLOAD_SUCCESS; for (int i = 0; i < RETRY_COUNT; i++) { if (mIsDownloadCanceled) { break; } if (i > 0) { // want to wait before retrying again try { Thread.sleep(WAIT_TIME); } catch (InterruptedException exception) { // no op } mNotifier.resetNotifications(); } mWifiLock.acquire(); res = startDownload(urlString, destination, outputFile, details); if (mWifiLock.isHeld()) { mWifiLock.release(); } if (res == DOWNLOAD_SUCCESS) { return true; } else if (res == QuranDownloadNotifier.ERROR_DISK_SPACE || res == QuranDownloadNotifier.ERROR_PERMISSIONS) { // critical errors mNotifier.notifyError(res, true, details); return false; } else if (res == QuranDownloadNotifier.ERROR_INVALID_DOWNLOAD) { // corrupted download if (!previouslyCorrupted) { // give one more chance if this is the first time // this file was corrupted i--; previouslyCorrupted = true; } if (i + 1 < RETRY_COUNT) { notifyError(res, false, details); } } } if (mIsDownloadCanceled) { res = QuranDownloadNotifier.ERROR_CANCELLED; } notifyError(res, true, details); return false; } private int startDownload(String url, String path, String filename, NotificationDetails notificationInfo) { if (!QuranUtils.haveInternet(this)) { notifyError(QuranDownloadNotifier.ERROR_NETWORK, false, notificationInfo); return QuranDownloadNotifier.ERROR_NETWORK; } final int result = downloadUrl(url, path, filename, notificationInfo); if (result == DOWNLOAD_SUCCESS) { if (filename.endsWith("zip")) { final File actualFile = new File(path, filename); if (!ZipUtils.unzipFile(actualFile.getAbsolutePath(), path, notificationInfo, this)) { return !actualFile.delete() ? QuranDownloadNotifier.ERROR_PERMISSIONS : QuranDownloadNotifier.ERROR_INVALID_DOWNLOAD; } else { actualFile.delete(); } } } return result; } private int downloadUrl(String url, String path, String filename, NotificationDetails notificationInfo) { final Request.Builder builder = new Request.Builder() .url(url).tag(DEFAULT_TAG); final File partialFile = new File(path, filename + PARTIAL_EXT); final File actualFile = new File(path, filename); Timber.d("downloadUrl: trying to download - file %s", actualFile.exists() ? "exists" : "doesn't exist"); long downloadedAmount = 0; if (partialFile.exists()) { downloadedAmount = partialFile.length(); Timber.d("downloadUrl: partialFile exists, length: %d", downloadedAmount); builder.addHeader("Range", "bytes=" + downloadedAmount + "-"); } final boolean isZip = filename.endsWith(".zip"); Call call = null; BufferedSource source = null; try { final Request request = builder.build(); call = mOkHttpClient.newCall(request); final Response response = call.execute(); if (response.isSuccessful()) { Crashlytics.log("successful response: " + response.code() + " - " + downloadedAmount); final BufferedSink sink = Okio.buffer(Okio.appendingSink(partialFile)); final ResponseBody body = response.body(); source = body.source(); final long size = body.contentLength() + downloadedAmount; if (!isSpaceAvailable(size + (isZip ? downloadedAmount + size : 0))) { return QuranDownloadNotifier.ERROR_DISK_SPACE; } else if (actualFile.exists()) { if (actualFile.length() == (size + downloadedAmount)) { // we already downloaded, why are we re-downloading? return DOWNLOAD_SUCCESS; } else if (!actualFile.delete()) { return QuranDownloadNotifier.ERROR_PERMISSIONS; } } long read; int loops = 0; long totalRead = downloadedAmount; /* Temporarily log information to try to understand the root cause for the okio exception. * The exception would happen as a result of an empty buffer, which one would not expect * here since we would have just gotten a (successful) result back from the web server. * * TODO - insha'Allah remove in the next version. */ if (!mIsDownloadCanceled && source.exhausted()) { Crashlytics.log("rc: " + response.code() + " -- downloaded: " + downloadedAmount + " -- fn: " + filename); if (partialFile.exists()) { Crashlytics.log("length of partial file: " + partialFile.length()); } Crashlytics.log("request headers=" + request.headers().toString()); Crashlytics.log("hdrs=" + response.headers().toString()); final Exception exception = new IllegalStateException("http source exhausted"); Crashlytics.logException(exception); } while (!mIsDownloadCanceled && !source.exhausted() && ((read = source.read(sink.buffer(), BUFFER_SIZE)) > 0)) { totalRead += read; if (loops++ % 5 == 0) { mLastSentIntent = mNotifier.notifyProgress(notificationInfo, totalRead, size); } sink.flush(); } QuranFileUtils.closeQuietly(sink); if (mIsDownloadCanceled) { return QuranDownloadNotifier.ERROR_CANCELLED; } else if (!partialFile.renameTo(actualFile)) { return notifyError(QuranDownloadNotifier.ERROR_PERMISSIONS, true, notificationInfo); } return DOWNLOAD_SUCCESS; } else if (response.code() == 416) { if (!partialFile.delete()) { return QuranDownloadNotifier.ERROR_PERMISSIONS; } return downloadUrl(url, path, filename, notificationInfo); } } catch (IOException exception) { Timber.e(exception, "Failed to download file"); } catch (SecurityException se) { Timber.e(se, "Security exception while downloading file"); } finally { QuranFileUtils.closeQuietly(source); } return (call != null && call.isCanceled()) ? QuranDownloadNotifier.ERROR_CANCELLED : notifyError(QuranDownloadNotifier.ERROR_NETWORK, false, notificationInfo); } @Override public void onProcessingProgress( NotificationDetails details, int processed, int total) { if (details.totalFiles == 1) { mLastSentIntent = mNotifier.notifyDownloadProcessing( details, processed, total); } } private int notifyError(int errorCode, boolean isFatal, NotificationDetails details) { mLastSentIntent = mNotifier.notifyError(errorCode, isFatal, details); if (isFatal) { // write last error in prefs mQuranSettings.setLastDownloadError(details.key, errorCode); } return errorCode; } // TODO: this is actually a bug - we may not be using /sdcard... // we may not have permission, etc - some devices get a IllegalArgumentException // because the path passed is /storage/emulated/0, for example. private boolean isSpaceAvailable(long spaceNeeded) { try { StatFs fsStats = new StatFs( Environment.getExternalStorageDirectory().getAbsolutePath()); double availableSpace = (double) fsStats.getAvailableBlocks() * (double) fsStats.getBlockSize(); return availableSpace > spaceNeeded; } catch (Exception e) { Crashlytics.logException(e); return true; } } private static String getFilenameFromUrl(String url) { int slashIndex = url.lastIndexOf("/"); if (slashIndex != -1) { return url.substring(slashIndex + 1); } // should never happen return url; } }