/* * Copyright 2015 Daniel Dittmar * * 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 dan.dit.whatsthat.system; import android.content.Context; import android.os.AsyncTask; import android.os.PowerManager; import android.text.TextUtils; import android.util.Log; import org.xmlpull.v1.XmlPullParserException; import java.io.BufferedInputStream; import java.io.File; import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.net.HttpURLConnection; import java.net.MalformedURLException; import java.net.URL; import dan.dit.whatsthat.R; import dan.dit.whatsthat.image.BundleManager; import dan.dit.whatsthat.image.Image; import dan.dit.whatsthat.image.ImageManager; import dan.dit.whatsthat.image.ImageXmlParser; import dan.dit.whatsthat.util.general.IOUtil; import dan.dit.whatsthat.util.general.PercentProgressListener; import dan.dit.whatsthat.util.image.ExternalStorage; /** * Created by daniel on 06.07.15. */ public class ImageDataDownload { private static final int ERROR_CODE_NONE = 0; private static final int ERROR_CODE_DOWNLOAD_RESPONSE_NOT_OK = 1001; private static final int ERROR_CODE_DOWNLOAD_FILE_ILLEGAL = 1002; public static final int ERROR_CODE_DOWNLOAD_IOEXCEPTION = 1003; private static final int ERROR_CODE_STORAGE_NOT_AVAILABLE = 2000; private static final int ERROR_CODE_SYNC_NOT_DOWNLOADED = 3001; private static final int ERROR_CODE_SYNC_UNZIP_FAILED = 3002; private static final int ERROR_CODE_SYNC_NO_DATA_FILE = 3003; private static final int ERROR_CODE_SYNC_NO_DATA_FILE_EXCEPTION_FNF = 3004; private static final int ERROR_CODE_SYNC_NO_DATA_FILE_EXCEPTION_PARSER = 3005; private static final int ERROR_CODE_SYNC_NO_DATA_FILE_EXCEPTION_IOE = 3006; private static final int ERROR_CODE_SYNC_TO_DATABASE_FAILED = 3007; private static final double PROGRESS_WEIGHT_FOR_DOWNLOAD = 0.5; private final Feedback mListener; private int mEstimatedSizeMB; private final String mURL; private final String mDataName; private final String mOrigin; private DownloadTask mDownloadTask; private Context mContext; private boolean mIsDownloaded; private SyncTask mSyncTask; private boolean mKeepBundleAfterSync; public boolean isWorking() { return (mDownloadTask != null && !mDownloadTask.isCancelled()) || (mSyncTask != null && !mSyncTask.isCancelled()); } public void setKeepBundleAfterSync() { mKeepBundleAfterSync = true; } public boolean isDownloaded() { return mIsDownloaded; } public String getOrigin() { return mOrigin; } public String getDataName() { return mDataName; } public String getUrl() { return mURL; } public String getURLHost() { if (TextUtils.isEmpty(mURL)) { return null; } try { return new URL(mURL).getHost(); } catch (MalformedURLException e) { return null; } } public int getEstimatedSize() { return mEstimatedSizeMB; } public void cancel() { if (mDownloadTask != null) { mDownloadTask.cancel(true); mDownloadTask = null; } //only allow to cancel download, not syncing //if (mSyncTask != null) { // mSyncTask.cancel(true); // mSyncTask = null; //} } public int getEstimatedImages() { return -1; } public interface Feedback extends PercentProgressListener { void setIsWorking(boolean isWorking); void onError(int messageResId, int errorCode); void onDownloadComplete(); void onComplete(); } /** * Creates a new ImageDataDownloader that waits for execution to either download * a zip containing the imagedata.xml and the actual images or parse and sync the already downloaded * file to the database. * @param context The application context. * @param origin The origin name, usually Image.ORIGIN_IS_THE_APP if from official source or some other name, used as directory name. * @param dataName A (better) unique name for all image data available that can be used to create a file name. * @param estimatedSizeMB The estimated size of the data in mb in case the data is not available from the connection * or to show the size before downloading even started. * @param url The url to the zip. Can only be null if you are sure that the file to use is already downloaded and existent! Else this * class does nothing. * @param feedback The progress of the downloading, parsing and syncing. Reference will be held forever by this object. */ public ImageDataDownload(Context context, String origin, String dataName, int estimatedSizeMB, String url, Feedback feedback) { if (context == null || TextUtils.isEmpty(origin) || TextUtils.isEmpty(dataName) || feedback == null) { throw new IllegalArgumentException("Null or empty parameter given for " + dataName + " in " + origin + " and url " + url); } mContext = context.getApplicationContext(); mEstimatedSizeMB = estimatedSizeMB; mOrigin = origin; mDataName = dataName; mURL = url; mListener = feedback; File downloaded = checkIfIsDownloaded(); Log.d("Image", "Data download is downloaded file: " + downloaded); } public void start() { if (isWorking()) { Log.e("Image", "Trying to start already working image data process."); return; } if (!mIsDownloaded) { // Step 0: download Log.d("Image", "Is not downloaded, download required first of url " + mURL); startDownload(mURL); } else { // Step 1: sync Log.d("Image", "Is downloaded, directly start syncing."); startSync(); } } private void startSync() { mSyncTask = new SyncTask(); mSyncTask.execute(); mListener.setIsWorking(true); } public void stop() { if (mDownloadTask != null) { mDownloadTask.cancel(true); mDownloadTask = null; } if (mSyncTask != null) { mSyncTask.cancel(true); mSyncTask = null; } } /** * Check if the storage file is available. We cannot be sure that the file is intact * and downloaded completely, but this will be found out when trying to unzip and parse. */ private File checkIfIsDownloaded() { File storageFile = getTempDownloadFile(); mIsDownloaded = storageFile != null && storageFile.exists(); return storageFile; } private void startDownload(String url) { if (url == null) { return; } mDownloadTask = new DownloadTask(); mDownloadTask.execute(url); mListener.setIsWorking(true); } private File getStorageDirectory() { String storageDirectoryPath = ExternalStorage.getExternalStoragePathIfMounted(Image.IMAGES_DIRECTORY_NAME); if (storageDirectoryPath == null) { Log.e("Image", "External storage unavailable? " + ExternalStorage.isMounted() + " for " + Image.IMAGES_DIRECTORY_NAME); return null; } storageDirectoryPath += "/" + mOrigin; File storageDirectory = new File(storageDirectoryPath); if (!storageDirectory.isDirectory() && !storageDirectory.mkdirs()) { Log.e("Image", "No storage directory: " + storageDirectory + " storageDirectoryPath: " + storageDirectoryPath); return null; } try { //noinspection ResultOfMethodCallIgnored new File(storageDirectory, ".nomedia").createNewFile(); } catch (IOException e) { Log.e("Image", "Failed creating nomedia file in directory " + storageDirectory + ": " + e); } return storageDirectory; } private File getTempDownloadFile() { File storageDirectory = BundleManager.ensureBundleDirectory(); if (storageDirectory == null) { Log.e("Image", "External storage unavailable? " + ExternalStorage.isMounted() + " for " + BundleManager.BUNDLES_DIRECTORY_NAME); return null; } return BundleManager.makeBundleFile(storageDirectory, mOrigin, mDataName); } private class DownloadTask extends AsyncTask<String, Integer, Integer> { private PowerManager.WakeLock mWakeLock; @Override protected void onPreExecute() { super.onPreExecute(); // take CPU lock to prevent CPU from going off if the user // presses the power button during download PowerManager pm = (PowerManager) mContext.getSystemService(Context.POWER_SERVICE); mWakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, getClass().getName()); mWakeLock.acquire(); } @Override protected void onCancelled() { super.onCancelled(); if (mWakeLock != null) { mWakeLock.release(); } mDownloadTask = null; File file = getTempDownloadFile(); if (file != null) { file.delete(); } } @Override protected void onPostExecute(Integer errorCode) { mDownloadTask = null; if (mWakeLock != null) { mWakeLock.release(); } mListener.setIsWorking(false); if (errorCode != ERROR_CODE_NONE) { mListener.onError(R.string.download_article_toast_error_download, errorCode); } else { Log.d("Image", "Download complete : " + mDataName + " of " + mOrigin + " url " + mURL); mListener.onDownloadComplete(); startSync(); } } @Override protected void onProgressUpdate(Integer... values) { if (values != null && values.length > 0) { mListener.onProgressUpdate((int) (values[0] * PROGRESS_WEIGHT_FOR_DOWNLOAD)); } } @Override protected Integer doInBackground(String... sUrl) { InputStream input = null; OutputStream output = null; HttpURLConnection connection = null; try { URL url = new URL(sUrl[0]); connection = (HttpURLConnection) url.openConnection(); connection.connect(); // expect HTTP 200 OK, so we don't mistakenly save error report // instead of the file if (connection.getResponseCode() != HttpURLConnection.HTTP_OK) { Log.e("Image", "Server returned HTTP " + connection.getResponseCode() + " " + connection.getResponseMessage()); return ERROR_CODE_DOWNLOAD_RESPONSE_NOT_OK; } int fileLength = connection.getContentLength(); if (fileLength > 0) { mEstimatedSizeMB = fileLength / (1024 * 1024); } fileLength = fileLength > 0 ? fileLength : mEstimatedSizeMB * 1024 * 1024; if (isCancelled()) { return null; } File storageFile = getTempDownloadFile(); if (storageFile == null) { Log.e("Image", "No storage directory or file available"); return ERROR_CODE_STORAGE_NOT_AVAILABLE; } // download the file input = new BufferedInputStream(connection.getInputStream(), 1024 * 20); // 20 kb buffer try { output = new FileOutputStream(storageFile, false); } catch (FileNotFoundException fnf) { Log.e("Image", "Target file on sd card not found: " + fnf + " for name " + storageFile); return ERROR_CODE_DOWNLOAD_FILE_ILLEGAL; } byte data[] = new byte[4096]; long total = 0; int count; while ((count = input.read(data)) > 0) { if (isCancelled()) { input.close(); output.close(); return null; } total += count; // publishing the progress.... if (fileLength > 0) // only if total length is known publishProgress((int) (total * 100 / (double) fileLength)); output.write(data, 0, count); } } catch (Exception e) { Log.e("Image", "Exception during download: " + e); return ERROR_CODE_DOWNLOAD_IOEXCEPTION; } finally { try { if (output != null) output.close(); if (input != null) input.close(); } catch (IOException ignored) { } if (connection != null) connection.disconnect(); } return ERROR_CODE_NONE; } } private class SyncTask extends AsyncTask<Void, Integer, Integer> { @Override protected void onCancelled(Integer result) { mSyncTask = null; } @Override protected void onPostExecute(Integer errorCode) { mSyncTask = null; File storageFile = getTempDownloadFile(); if (storageFile != null && !mKeepBundleAfterSync && storageFile.delete()) { mIsDownloaded = false; } mListener.setIsWorking(false); if (errorCode != ERROR_CODE_NONE) { mListener.onError(R.string.download_article_toast_error_syncing, errorCode); } else { mListener.onComplete(); } } @Override protected void onProgressUpdate(Integer... values) { if (values != null && values.length > 0) { mListener.onProgressUpdate((int) (PercentProgressListener.PROGRESS_COMPLETE * PROGRESS_WEIGHT_FOR_DOWNLOAD + values[0] * ( 1 - PROGRESS_WEIGHT_FOR_DOWNLOAD))); } } @Override protected Integer doInBackground(Void... voids) { // Step 0: check if zip is downloaded and existant.. yes, we are so paranoid and think it disappeared File storage = checkIfIsDownloaded(); if (!mIsDownloaded || storage == null) { return ERROR_CODE_SYNC_NOT_DOWNLOADED; } // Step 1: unzip into storage directory File storageDirectory = getStorageDirectory(); Log.d("Image", "Starting to unzip " + mOrigin + " bundle " + mDataName + " url " + mURL + " storage: " + storage); if (storageDirectory != null && IOUtil.unzip(storage, storageDirectory)) { publishProgress(10); // Step 2: find the xml holding required information to parse data File dataHolder = null; for (File subFile : storageDirectory.listFiles()) { if (subFile != null && subFile.getName().equalsIgnoreCase(mDataName + ".xml")) { dataHolder = subFile; break; } } Log.d("Image", "Unzipped success, dataholder: " + dataHolder); if (dataHolder != null) { ImageXmlParser parser; try { parser = ImageXmlParser.parseInput(mContext, new FileInputStream(dataHolder), Integer.MIN_VALUE, false); publishProgress(15); } catch (FileNotFoundException fnf) { return ERROR_CODE_SYNC_NO_DATA_FILE_EXCEPTION_FNF; } catch (IOException ioe) { return ERROR_CODE_SYNC_NO_DATA_FILE_EXCEPTION_IOE; } catch (XmlPullParserException e) { Log.e("Image", "Image data download failed: exception with data file parser exception: " + e); return ERROR_CODE_SYNC_NO_DATA_FILE_EXCEPTION_PARSER; } Log.d("Image", "Syncing image data in progress: parser=" + parser); if (parser != null && parser.syncToDatabase(new ImageManager.SynchronizationListener() { @Override public void onSyncProgress(int progress) { publishProgress((int) (15. + progress * (85./100.))); } @Override public void onSyncComplete() { publishProgress(PercentProgressListener.PROGRESS_COMPLETE); } @Override public boolean isSyncCancelled() { return isCancelled(); } })) { if (!dataHolder.delete()) { Log.d("Image", "Failed deleting data holder after successfully syncing. Might leak game data, but whatever."); } Log.d("Image", "Successfully synced bundle. Finally!"); } else { return ERROR_CODE_SYNC_TO_DATABASE_FAILED; } } else { return ERROR_CODE_SYNC_NO_DATA_FILE; } } else { return ERROR_CODE_SYNC_UNZIP_FAILED; } return ERROR_CODE_NONE; } } }