Java tutorial
/* * Copyright 2015 OpenMarket Ltd * * 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.matrix.androidsdk.db; import android.content.Context; import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.graphics.Color; import android.net.Uri; import android.os.AsyncTask; import android.os.Looper; import android.support.v4.util.LruCache; import android.text.TextUtils; import org.matrix.androidsdk.util.Log; import android.util.Pair; import android.webkit.MimeTypeMap; import android.widget.ImageView; import com.google.gson.JsonElement; import com.google.gson.JsonParser; import org.matrix.androidsdk.HomeserverConnectionConfig; import org.matrix.androidsdk.crypto.MXEncryptedAttachments; import org.matrix.androidsdk.listeners.IMXMediaDownloadListener; import org.matrix.androidsdk.rest.model.EncryptedFileInfo; import org.matrix.androidsdk.ssl.CertUtil; import org.matrix.androidsdk.util.ImageUtils; import java.io.BufferedReader; import java.io.File; import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.InputStream; import java.io.InputStreamReader; import java.lang.ref.WeakReference; import java.net.URL; import java.net.URLConnection; import java.security.MessageDigest; import java.util.ArrayList; import java.util.HashMap; import java.util.Timer; import java.util.TimerTask; import javax.net.ssl.HttpsURLConnection; import javax.net.ssl.SSLSocketFactory; import javax.net.ssl.X509TrustManager; /** * This class manages the media downloading in background. */ class MXMediaDownloadWorkerTask extends AsyncTask<Integer, IMXMediaDownloadListener.DownloadStats, Void> { private static final String LOG_TAG = "MXMediaDwndWorkerTk"; /** * Pending media URLs */ private static final HashMap<String, MXMediaDownloadWorkerTask> mPendingDownloadByUrl = new HashMap<>(); /** * List of unreachable media urls. */ private static final ArrayList<String> mUnreachableUrls = new ArrayList<>(); // avoid sync on "this" because it might differ if there is a timer. private static final Object mSyncObject = new Object(); /** * The medias cache */ private static LruCache<String, Bitmap> mBitmapByUrlCache = null; /** * The downloaded media callbacks. */ private final ArrayList<IMXMediaDownloadListener> mDownloadListeners = new ArrayList<>(); /** * The ImageView list to refresh when the media is downloaded. */ private final ArrayList<WeakReference<ImageView>> mImageViewReferences; /** * The media URL. */ private String mUrl; /** * The media mime type */ private String mMimeType; /** * The application context */ private Context mApplicationContext; /** * The directory in which the media must be stored. */ private File mDirectoryFile = null; /** * The rotation to apply. */ private int mRotation = 0; /** * The download stats. */ private IMXMediaDownloadListener.DownloadStats mDownloadStats; /** * Tells the download has been cancelled. */ private boolean mIsDownloadCancelled = false; /** * Tells if the download has been completed */ private boolean mIsDone = false; /** * Error message. */ private JsonElement mErrorAsJsonElement; /** * The home server config. */ private final HomeserverConnectionConfig mHsConfig; /** * The bitmap to use when the URL is unreachable. */ private Bitmap mDefaultBitmap; /** * the encrypted file information */ private EncryptedFileInfo mEncryptedFileInfo; /** * Download constants */ private static final int DOWNLOAD_TIME_OUT = 10 * 1000; private static final int DOWNLOAD_BUFFER_READ_SIZE = 1024 * 32; //============================================================================================================== // static methods //============================================================================================================== /** * Clear the internal cache. */ public static void clearBitmapsCache() { // sMemoryCache can be null if no bitmap have been downloaded. if (null != mBitmapByUrlCache) { mBitmapByUrlCache.evictAll(); } } /** * Check if there is a pending download for the url. * @param url The url to check the existence * @return the dedicated MXMediaDownloadWorkerTask if it exists. */ public static MXMediaDownloadWorkerTask getMediaDownloadWorkerTask(String url) { if ((url != null) && mPendingDownloadByUrl.containsKey(url)) { MXMediaDownloadWorkerTask task; synchronized (mPendingDownloadByUrl) { task = mPendingDownloadByUrl.get(url); } return task; } else { return null; } } /** * Generate an unique ID for a string * @param input the string * @return the unique ID */ private static String uniqueId(String input) { String uniqueId = null; try { MessageDigest mDigest = MessageDigest.getInstance("SHA1"); byte[] result = mDigest.digest(input.getBytes()); StringBuffer sb = new StringBuffer(); for (int i = 0; i < result.length; i++) { sb.append(Integer.toString((result[i] & 0xff) + 0x100, 16).substring(1)); } uniqueId = sb.toString(); } catch (Exception e) { Log.e(LOG_TAG, "uniqueId failed " + e.getLocalizedMessage()); } if (null == uniqueId) { uniqueId = "" + Math.abs(input.hashCode() + (System.currentTimeMillis() + "").hashCode()); } return uniqueId; } /** * Build a filename from an url * @param Url the media url * @param mimeType the mime type; * @return the cache filename */ public static String buildFileName(String Url, String mimeType) { String name = "file_" + MXMediaDownloadWorkerTask.uniqueId(Url); if (!TextUtils.isEmpty(mimeType)) { String fileExtension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType); // some devices don't support .jpeg files if ("jpeg".equals(fileExtension)) { fileExtension = "jpg"; } if (null != fileExtension) { name += "." + fileExtension; } } return name; } /** * Tell if the media is cached * @param url the media url * @return true if the media is cached */ public static boolean isUrlCached(String url) { boolean res = false; if ((null != mBitmapByUrlCache) && (null != url)) { synchronized (mSyncObject) { res = (null != mBitmapByUrlCache.get(url)); } } return res; } /** * Tells if the media URL is unreachable. * @param url the url to test. * @return true if the media URL is unreachable. */ public static boolean isMediaUrlUnreachable(String url) { boolean res = true; if (!TextUtils.isEmpty(url)) { synchronized (mUnreachableUrls) { res = mUnreachableUrls.indexOf(url) >= 0; } } return res; } /** * Search a cached bitmap from an url. * rotationAngle is set to Integer.MAX_VALUE when undefined : the EXIF metadata must be checked. * * @param baseFile the base file * @param url the media url * @param rotation the bitmap rotation * @param mimeType the mime type * @return the cached bitmap */ public static Bitmap bitmapForURL(Context context, File baseFile, String url, int rotation, String mimeType) { Bitmap bitmap = null; // sanity check if (null != url) { if (null == mBitmapByUrlCache) { int lruSize = Math.min(20 * 1024 * 1024, (int) Runtime.getRuntime().maxMemory() / 8); Log.d(LOG_TAG, "bitmapForURL lruSize : " + lruSize); mBitmapByUrlCache = new LruCache<String, Bitmap>(lruSize) { @Override protected int sizeOf(String key, Bitmap bitmap) { return bitmap.getRowBytes() * bitmap.getHeight(); // size in bytes } }; } // the image is downloading in background if (null != getMediaDownloadWorkerTask(url)) { return null; } // the url is invalid if (isMediaUrlUnreachable(url)) { return null; } synchronized (mSyncObject) { bitmap = mBitmapByUrlCache.get(url); } // check if the image has not been saved in file system if ((null == bitmap) && (null != baseFile)) { String filename = null; // the url is a file one if (url.startsWith("file:")) { // try to parse it try { Uri uri = Uri.parse(url); filename = uri.getPath(); } catch (Exception e) { Log.e(LOG_TAG, "bitmapForURL #1 : " + e.getLocalizedMessage()); } // cannot extract the filename -> sorry if (null == filename) { return null; } } // not a valid file name if (null == filename) { filename = buildFileName(url, mimeType); } try { File file = filename.startsWith(File.separator) ? new File(filename) : new File(baseFile, filename); if (!file.exists()) { Log.d(LOG_TAG, "bitmapForURL() : " + filename + " does not exist"); return null; } InputStream fis = new FileInputStream(file); // read the metadata if (Integer.MAX_VALUE == rotation) { rotation = ImageUtils.getRotationAngleForBitmap(context, Uri.fromFile(file)); } if (null != fis) { BitmapFactory.Options options = new BitmapFactory.Options(); options.inPreferredConfig = Bitmap.Config.ARGB_8888; try { bitmap = BitmapFactory.decodeStream(fis, null, options); } catch (OutOfMemoryError error) { System.gc(); Log.e(LOG_TAG, "bitmapForURL() : Out of memory 1 " + error); } // try again if (null == bitmap) { try { bitmap = BitmapFactory.decodeStream(fis, null, options); } catch (OutOfMemoryError error) { Log.e(LOG_TAG, "bitmapForURL() Out of memory 2" + error); } } if (null != bitmap) { synchronized (mSyncObject) { if (0 != rotation) { try { android.graphics.Matrix bitmapMatrix = new android.graphics.Matrix(); bitmapMatrix.postRotate(rotation); Bitmap transformedBitmap = Bitmap.createBitmap(bitmap, 0, 0, bitmap.getWidth(), bitmap.getHeight(), bitmapMatrix, false); bitmap.recycle(); bitmap = transformedBitmap; } catch (OutOfMemoryError ex) { Log.e(LOG_TAG, "bitmapForURL rotation error : " + ex.getLocalizedMessage()); } } // cache only small images // caching large images does not make sense // it would replace small ones. // let assume that the application must be faster when showing the chat history. if ((bitmap.getWidth() < 1000) && (bitmap.getHeight() < 1000)) { mBitmapByUrlCache.put(url, bitmap); } } } fis.close(); } } catch (FileNotFoundException e) { Log.d(LOG_TAG, "bitmapForURL() : " + filename + " does not exist"); } catch (Exception e) { Log.e(LOG_TAG, "bitmapForURL() " + e); } } } return bitmap; } //============================================================================================================== // class methods //============================================================================================================== /** * Shared initialization methods. * @param appContext the application context. * @param url the media URL. * @param mimeType the mime type. */ private void commonInit(Context appContext, String url, String mimeType) { mApplicationContext = appContext; mUrl = url; synchronized (mPendingDownloadByUrl) { mPendingDownloadByUrl.put(url, this); } mMimeType = mimeType; mRotation = 0; } /** * MXMediaDownloadWorkerTask creator * @param appContext the context * @param hsConfig the home server config. * @param directoryFile the directory in which the media must be stored * @param url the media url * @param mimeType the mime type. * @param encryptedFileInfo the encryption information */ public MXMediaDownloadWorkerTask(Context appContext, HomeserverConnectionConfig hsConfig, File directoryFile, String url, String mimeType, EncryptedFileInfo encryptedFileInfo) { commonInit(appContext, url, mimeType); mDirectoryFile = directoryFile; mImageViewReferences = new ArrayList<>(); mHsConfig = hsConfig; mEncryptedFileInfo = encryptedFileInfo; } /** * MXMediaDownloadWorkerTask creator * @param appContext the context * @param hsConfig the home server config * @param directoryFile the directory in which the media must be stored * @param url the media url * @param rotation the rotation * @param mimeType the mime type. * @param encryptedFileInfo the encryption information */ public MXMediaDownloadWorkerTask(Context appContext, HomeserverConnectionConfig hsConfig, File directoryFile, String url, int rotation, String mimeType, EncryptedFileInfo encryptedFileInfo) { commonInit(appContext, url, mimeType); mImageViewReferences = new ArrayList<>(); mDirectoryFile = directoryFile; mRotation = rotation; mHsConfig = hsConfig; mEncryptedFileInfo = encryptedFileInfo; } /** * MXMediaDownloadWorkerTask creator * @param task another bitmap task */ public MXMediaDownloadWorkerTask(MXMediaDownloadWorkerTask task) { mApplicationContext = task.mApplicationContext; mUrl = task.mUrl; mRotation = task.mRotation; synchronized (mPendingDownloadByUrl) { mPendingDownloadByUrl.put(mUrl, this); } mMimeType = task.mMimeType; mImageViewReferences = task.mImageViewReferences; mHsConfig = task.mHsConfig; mEncryptedFileInfo = task.mEncryptedFileInfo; } /** * Cancels the current download. */ public synchronized void cancelDownload() { mIsDownloadCancelled = true; } /** * @return tells if the current download has been cancelled. */ public synchronized boolean isDownloadCancelled() { return mIsDownloadCancelled; } /** * @return the media URL. */ public String getUrl() { return mUrl; } /** * Add an imageView to the list to refresh when the bitmap is downloaded. * @param imageView an image view instance to refresh. */ public void addImageView(ImageView imageView) { mImageViewReferences.add(new WeakReference<>(imageView)); } /** * Set the default bitmap to use when the Url is unreachable. * @param aBitmap the bitmap. */ public void setDefaultBitmap(Bitmap aBitmap) { mDefaultBitmap = aBitmap; } /** * Add a download listener. * @param listener the listener to add. */ public void addDownloadListener(IMXMediaDownloadListener listener) { mDownloadListeners.add(listener); } /** * Returns the download progress. * @return the download progress */ public int getProgress() { if (null != mDownloadStats) { return mDownloadStats.mProgress; } return -1; } /** * @return the download stats */ public IMXMediaDownloadListener.DownloadStats getDownloadStats() { return mDownloadStats; } /** * @return true if the current task is an image one. */ private boolean isBitmapDownloadTask() { return (null != mMimeType) && mMimeType.startsWith("image/"); } /** * Push the download progress. * @param startDownloadTime the start download time. */ private void publishProgress(long startDownloadTime) { mDownloadStats.mElapsedTime = (int) ((System.currentTimeMillis() - startDownloadTime) / 1000); if (mDownloadStats.mFileSize > 0) { if (mDownloadStats.mDownloadedSize >= mDownloadStats.mFileSize) { mDownloadStats.mProgress = 99; } else { mDownloadStats.mProgress = (int) (mDownloadStats.mDownloadedSize * 100L / mDownloadStats.mFileSize); } } else { mDownloadStats.mProgress = -1; } // avoid zero div if (System.currentTimeMillis() != startDownloadTime) { mDownloadStats.mBitRate = (int) (mDownloadStats.mDownloadedSize * 1000L / (System.currentTimeMillis() - startDownloadTime) / 1024); } else { mDownloadStats.mBitRate = -1; } if ((0 != mDownloadStats.mBitRate) && (mDownloadStats.mFileSize > 0) && (mDownloadStats.mFileSize > mDownloadStats.mDownloadedSize)) { mDownloadStats.mEstimatedRemainingTime = (mDownloadStats.mFileSize - mDownloadStats.mDownloadedSize) / 1024 / mDownloadStats.mBitRate; } else { mDownloadStats.mEstimatedRemainingTime = -1; } Log.d(LOG_TAG, "publishProgress " + this + " : " + mDownloadStats.mProgress); publishProgress(mDownloadStats); } // Decode image in background. @Override protected Void doInBackground(Integer... params) { try { URL url = new URL(mUrl); Log.d(LOG_TAG, "MXMediaDownloadWorkerTask " + this + " starts"); mDownloadStats = new IMXMediaDownloadListener.DownloadStats(); // don't known yet mDownloadStats.mEstimatedRemainingTime = -1; InputStream stream = null; int filelen = -1; URLConnection connection = null; try { connection = url.openConnection(); if (mHsConfig != null && connection instanceof HttpsURLConnection) { // Add SSL Socket factory. HttpsURLConnection sslConn = (HttpsURLConnection) connection; try { Pair<SSLSocketFactory, X509TrustManager> pair = CertUtil .newPinnedSSLSocketFactory(mHsConfig); sslConn.setSSLSocketFactory(pair.first); sslConn.setHostnameVerifier(CertUtil.newHostnameVerifier(mHsConfig)); } catch (Exception e) { Log.e(LOG_TAG, "doInBackground SSL exception " + e.getLocalizedMessage()); } } // add a timeout to avoid infinite loading display. connection.setReadTimeout(DOWNLOAD_TIME_OUT); filelen = connection.getContentLength(); stream = connection.getInputStream(); } catch (Exception e) { Log.e(LOG_TAG, "bitmapForURL : fail to open the connection " + e.getMessage()); InputStream errorStream = ((HttpsURLConnection) connection).getErrorStream(); if (null != errorStream) { try { BufferedReader streamReader = new BufferedReader( new InputStreamReader(errorStream, "UTF-8")); StringBuilder responseStrBuilder = new StringBuilder(); String inputStr; while ((inputStr = streamReader.readLine()) != null) { responseStrBuilder.append(inputStr); } mErrorAsJsonElement = new JsonParser().parse(responseStrBuilder.toString()); } catch (Exception ee) { Log.e(LOG_TAG, "bitmapForURL : Error parsing error " + ee.getLocalizedMessage()); } } // privacy //Log.d(LOG_TAG, "MediaWorkerTask " + mUrl + " does not exist"); Log.d(LOG_TAG, "MediaWorkerTask an url does not exist"); // if some medias are not found // do not try to reload them until the next application launch. synchronized (mUnreachableUrls) { mUnreachableUrls.add(mUrl); } } dispatchDownloadStart(); // test if the download has not been cancelled if (!isDownloadCancelled() && (null == mErrorAsJsonElement)) { final long startDownloadTime = System.currentTimeMillis(); String filename = MXMediaDownloadWorkerTask.buildFileName(mUrl, mMimeType) + ".tmp"; FileOutputStream fos = new FileOutputStream(new File(mDirectoryFile, filename)); mDownloadStats.mDownloadId = mUrl; mDownloadStats.mProgress = 0; mDownloadStats.mDownloadedSize = 0; mDownloadStats.mFileSize = filelen; mDownloadStats.mElapsedTime = 0; mDownloadStats.mEstimatedRemainingTime = -1; mDownloadStats.mBitRate = 0; final android.os.Handler uiHandler = new android.os.Handler(Looper.getMainLooper()); final Timer refreshTimer = new Timer(); uiHandler.post(new Runnable() { @Override public void run() { refreshTimer.scheduleAtFixedRate(new TimerTask() { @Override public void run() { uiHandler.post(new Runnable() { @Override public void run() { if (!mIsDone) { publishProgress(startDownloadTime); } } }); } }, new java.util.Date(), 100); } }); try { byte[] buf = new byte[DOWNLOAD_BUFFER_READ_SIZE]; int len; while (!isDownloadCancelled() && (len = stream.read(buf)) != -1) { fos.write(buf, 0, len); mDownloadStats.mDownloadedSize += len; } if (!isDownloadCancelled()) { mDownloadStats.mProgress = 100; } } catch (OutOfMemoryError outOfMemoryError) { Log.e(LOG_TAG, "doInBackground: out of memory"); } catch (Exception e) { Log.e(LOG_TAG, "doInBackground fail to read image " + e.getMessage()); } mIsDone = true; close(stream); fos.flush(); fos.close(); if (null != mEncryptedFileInfo) { File file = new File(mDirectoryFile, filename); FileInputStream fis = new FileInputStream(file); InputStream is = MXEncryptedAttachments.decryptAttachment(fis, mEncryptedFileInfo); fis.close(); // if the decryption succeeds, replace the encrypted file content by the unencrypted one if (null != is) { mApplicationContext.deleteFile(filename); fos = new FileOutputStream(file); byte[] buf = new byte[DOWNLOAD_BUFFER_READ_SIZE]; int len; while ((len = is.read(buf)) != -1) { fos.write(buf, 0, len); } } else { mDownloadStats.mProgress = 0; } } uiHandler.post(new Runnable() { @Override public void run() { refreshTimer.cancel(); } }); if ((null != connection) && (connection instanceof HttpsURLConnection)) { ((HttpsURLConnection) connection).disconnect(); } // the file has been successfully downloaded if (mDownloadStats.mProgress == 100) { try { File originalFile = new File(mDirectoryFile, filename); String newFileName = MXMediaDownloadWorkerTask.buildFileName(mUrl, mMimeType); File newFile = new File(mDirectoryFile, newFileName); if (newFile.exists()) { // Or you could throw here. mApplicationContext.deleteFile(newFileName); } originalFile.renameTo(newFile); } catch (Exception e) { Log.e(LOG_TAG, "doInBackground : renaming error " + e.getLocalizedMessage()); } } } if (mDownloadStats.mProgress == 100) { Log.d(LOG_TAG, "The download " + this + " is done."); } else { if (null != mErrorAsJsonElement) { Log.d(LOG_TAG, "The download " + this + " failed : mErrorAsJsonElement " + mErrorAsJsonElement.toString()); } else { Log.d(LOG_TAG, "The download " + this + " failed."); } } } catch (Exception e) { Log.e(LOG_TAG, "Unable to download media " + this); } // remove the image from the loading one synchronized (mPendingDownloadByUrl) { mPendingDownloadByUrl.remove(mUrl); } return null; } /** * Close the stream. * @param stream the stream to close. */ private void close(InputStream stream) { try { stream.close(); } catch (Exception e) { Log.e(LOG_TAG, "close error " + e.getLocalizedMessage()); } } @Override protected void onProgressUpdate(IMXMediaDownloadListener.DownloadStats... progress) { super.onProgressUpdate(progress); dispatchOnDownloadProgress(mDownloadStats); } // Once complete, see if ImageView is still around and set bitmap. @Override protected void onPostExecute(Void nothing) { if (null != mErrorAsJsonElement) { dispatchOnDownloadError(mErrorAsJsonElement); } else if (isDownloadCancelled()) { dispatchDownloadCancel(); } else { dispatchOnDownloadComplete(); // image download // update the linked ImageViews. if (isBitmapDownloadTask()) { // retrieve the bitmap from the file s Bitmap bitmap = MXMediaDownloadWorkerTask.bitmapForURL(mApplicationContext, mDirectoryFile, mUrl, mRotation, mMimeType); if (null == bitmap) { bitmap = mDefaultBitmap; } // update the imageViews image if (bitmap != null) { for (WeakReference<ImageView> weakRef : mImageViewReferences) { final ImageView imageView = weakRef.get(); if (imageView != null && TextUtils.equals(mUrl, (String) imageView.getTag())) { imageView.setBackgroundColor(Color.TRANSPARENT); imageView.setImageBitmap(bitmap); } } } } } } //============================================================================================================== // Dispatchers //============================================================================================================== /** * Dispatch start event to the callbacks. */ private void dispatchDownloadStart() { for (IMXMediaDownloadListener callback : mDownloadListeners) { try { callback.onDownloadStart(mUrl); } catch (Exception e) { Log.e(LOG_TAG, "dispatchDownloadStart error " + e.getLocalizedMessage()); } } } /** * Dispatch stats update to the callbacks. * @param stats the new stats value */ private void dispatchOnDownloadProgress(IMXMediaDownloadListener.DownloadStats stats) { for (IMXMediaDownloadListener callback : mDownloadListeners) { try { callback.onDownloadProgress(mUrl, stats); } catch (Exception e) { Log.e(LOG_TAG, "dispatchOnDownloadProgress error " + e.getLocalizedMessage()); } } } /** * Dispatch error message. * @param jsonElement the Json error */ private void dispatchOnDownloadError(JsonElement jsonElement) { for (IMXMediaDownloadListener callback : mDownloadListeners) { try { callback.onDownloadError(mUrl, jsonElement); } catch (Exception e) { Log.e(LOG_TAG, "dispatchOnDownloadError error " + e.getLocalizedMessage()); } } } /** * Dispatch end of download */ private void dispatchOnDownloadComplete() { for (IMXMediaDownloadListener callback : mDownloadListeners) { try { callback.onDownloadComplete(mUrl); } catch (Exception e) { Log.e(LOG_TAG, "dispatchOnDownloadComplete error " + e.getLocalizedMessage()); } } } /** * Dispatch download cancel */ private void dispatchDownloadCancel() { for (IMXMediaDownloadListener callback : mDownloadListeners) { try { callback.onDownloadCancel(mUrl); } catch (Exception e) { Log.e(LOG_TAG, "dispatchDownloadCancel error " + e.getLocalizedMessage()); } } } }