Java tutorial
/* * Copyright (c) 2015 Layer. All rights reserved. * * 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.layer.atlas; 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.lang.reflect.Method; import java.text.DateFormat; import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Calendar; import java.util.Comparator; import java.util.Date; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import org.apache.http.HttpResponse; import org.apache.http.HttpStatus; import org.apache.http.client.methods.HttpGet; import org.apache.http.impl.client.DefaultHttpClient; import org.json.JSONException; import org.json.JSONObject; import android.content.Context; import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.graphics.Canvas; import android.graphics.Color; import android.graphics.Movie; import android.graphics.Paint; import android.graphics.Rect; import android.graphics.RectF; import android.graphics.drawable.BitmapDrawable; import android.graphics.drawable.Drawable; import android.util.DisplayMetrics; import android.util.Log; import android.util.TypedValue; import android.view.MotionEvent; import android.view.View; import android.view.View.MeasureSpec; import android.view.ViewGroup; import android.view.Window; import com.layer.atlas.cells.GIFCell; import com.layer.atlas.cells.GeoCell; import com.layer.atlas.cells.ImageCell; import com.layer.sdk.LayerClient; import com.layer.sdk.messaging.Conversation; import com.layer.sdk.messaging.Message; import com.layer.sdk.messaging.MessagePart; /** * @author Oleg Orlov * @since 12 May 2015 */ public class Atlas { private static final String TAG = Atlas.class.getSimpleName(); private static final boolean debug = false; public static final String METADATA_KEY_CONVERSATION_TITLE = "conversationName"; public static final String MIME_TYPE_ATLAS_LOCATION = "location/coordinate"; public static final String MIME_TYPE_TEXT = "text/plain"; public static final String MIME_TYPE_IMAGE_JPEG = "image/jpeg"; public static final String MIME_TYPE_IMAGE_JPEG_PREVIEW = "image/jpeg+preview"; public static final String MIME_TYPE_IMAGE_PNG = "image/png"; public static final String MIME_TYPE_IMAGE_PNG_PREVIEW = "image/png+preview"; public static final String MIME_TYPE_IMAGE_GIF = "image/gif"; public static final String MIME_TYPE_IMAGE_GIF_PREVIEW = "image/gif+preview"; public static final String MIME_TYPE_IMAGE_DIMENSIONS = "application/json+imageSize"; public static final ImageLoader imageLoader = new ImageLoader(); public static final Atlas.DownloadQueue downloadQueue = new DownloadQueue(); public static String getInitials(Participant p) { StringBuilder sb = new StringBuilder(); sb.append( p.getFirstName() != null && p.getFirstName().trim().length() > 0 ? p.getFirstName().trim().charAt(0) : ""); sb.append(p.getLastName() != null && p.getLastName().trim().length() > 0 ? p.getLastName().trim().charAt(0) : ""); return sb.toString(); } public static String getFirstNameLastInitial(Participant p) { StringBuilder sb = new StringBuilder(); if (p.getFirstName() != null && p.getFirstName().trim().length() > 0) { sb.append(p.getFirstName().trim()); } if (p.getLastName() != null && p.getLastName().trim().length() > 0) { sb.append(" ").append(p.getLastName().trim().charAt(0)); sb.append("."); } return sb.toString(); } public static String getFullName(Participant p) { StringBuilder sb = new StringBuilder(); if (p.getFirstName() != null && p.getFirstName().trim().length() > 0) { sb.append(p.getFirstName().trim()); } if (p.getLastName() != null && p.getLastName().trim().length() > 0) { sb.append(" ").append(p.getLastName().trim()); } return sb.toString(); } public static void setTitle(Conversation conversation, String title) { conversation.putMetadataAtKeyPath(Atlas.METADATA_KEY_CONVERSATION_TITLE, title); } public static String getTitle(Conversation conversation) { return (String) conversation.getMetadata().get(Atlas.METADATA_KEY_CONVERSATION_TITLE); } public static String getTitle(Conversation conversation, ParticipantProvider provider, String userId) { String conversationTitle = getTitle(conversation); if (conversationTitle != null && conversationTitle.trim().length() > 0) return conversationTitle.trim(); StringBuilder sb = new StringBuilder(); for (String participantId : conversation.getParticipants()) { if (participantId.equals(userId)) continue; Participant participant = provider.getParticipant(participantId); if (participant == null) continue; String initials = conversation.getParticipants().size() > 2 ? getFirstNameLastInitial(participant) : getFullName(participant); if (sb.length() > 0) sb.append(", "); sb.append(initials); } return sb.toString().trim(); } /** * @param imageFile - to create a preview of * @param layerClient - required to create {@link MessagePart} * @param tempDir - required to store preview file until it is picked by LayerClient * @return MessagePart[] {previewBytes, json_with_dimensions} or null if preview cannot be built */ public static MessagePart[] buildPreviewAndSize(final File imageFile, final LayerClient layerClient, File tempDir) throws IOException { if (imageFile == null) throw new IllegalArgumentException("imageFile cannot be null"); if (layerClient == null) throw new IllegalArgumentException("layerClient cannot be null"); if (tempDir == null) throw new IllegalArgumentException("tempDir cannot be null"); if (!tempDir.exists()) throw new IllegalArgumentException("tempDir doesn't exist"); if (!tempDir.isDirectory()) throw new IllegalArgumentException("tempDir must be a directory"); // prepare preview BitmapFactory.Options optOriginal = new BitmapFactory.Options(); optOriginal.inJustDecodeBounds = true; //BitmapFactory.decodeFile(photoFile.getAbsolutePath(), optOriginal); BitmapFactory.decodeStream(new FileInputStream(imageFile), null, optOriginal); if (debug) Log.w(TAG, "buildPreviewAndSize() original: " + optOriginal.outWidth + "x" + optOriginal.outHeight); int previewWidthMax = 512; int previewHeightMax = 512; int previewWidth; int previewHeight; int sampleSize; if (optOriginal.outWidth > optOriginal.outHeight) { sampleSize = optOriginal.outWidth / previewWidthMax; previewWidth = previewWidthMax; previewHeight = (int) (1.0 * previewWidth * optOriginal.outHeight / optOriginal.outWidth); if (debug) Log.w(TAG, "buildPreviewAndSize() sampleSize: " + sampleSize + ", orig: " + optOriginal.outWidth + "x" + optOriginal.outHeight + ", preview: " + previewWidth + "x" + previewHeight); } else { sampleSize = optOriginal.outHeight / previewHeightMax; previewHeight = previewHeightMax; previewWidth = (int) (1.0 * previewHeight * optOriginal.outWidth / optOriginal.outHeight); if (debug) Log.w(TAG, "buildPreviewAndSize() sampleSize: " + sampleSize + ", orig: " + optOriginal.outWidth + "x" + optOriginal.outHeight + ", preview: " + previewWidth + "x" + previewHeight); } BitmapFactory.Options optsPreview = new BitmapFactory.Options(); optsPreview.inSampleSize = sampleSize; //Bitmap decodedBmp = BitmapFactory.decodeFile(photoFile.getAbsolutePath(), optsPreview); Bitmap decodedBmp = BitmapFactory.decodeStream(new FileInputStream(imageFile), null, optsPreview); if (decodedBmp == null) { if (debug) Log.w(TAG, "buildPreviewAndSize() taking photo, but photo file cannot be decoded: " + imageFile.getPath()); return null; } if (debug) Log.w(TAG, "buildPreviewAndSize() decoded bitmap: " + decodedBmp.getWidth() + "x" + decodedBmp.getHeight() + ", " + decodedBmp.getByteCount() + " bytes "); Bitmap bmp = Bitmap.createScaledBitmap(decodedBmp, previewWidth, previewHeight, false); if (debug) Log.w(TAG, "buildPreviewAndSize() preview bitmap: " + bmp.getWidth() + "x" + bmp.getHeight() + ", " + bmp.getByteCount() + " bytes "); String fileName = "atlasPreview" + System.currentTimeMillis() + ".jpg"; final File previewFile = new File(tempDir, fileName); FileOutputStream fos = new FileOutputStream(previewFile); bmp.compress(Bitmap.CompressFormat.JPEG, 50, fos); fos.close(); FileInputStream fisPreview = new FileInputStream(previewFile) { public void close() throws IOException { super.close(); boolean deleted = previewFile.delete(); if (debug) Log.w(TAG, "buildPreviewAndSize() preview file is" + (!deleted ? " not" : "") + " removed: " + previewFile.getName()); } }; final MessagePart previewPart = layerClient.newMessagePart(MIME_TYPE_IMAGE_JPEG_PREVIEW, fisPreview, previewFile.length()); // prepare dimensions JSONObject joDimensions = new JSONObject(); try { joDimensions.put("width", optOriginal.outWidth); joDimensions.put("height", optOriginal.outHeight); joDimensions.put("orientation", 0); } catch (JSONException e) { throw new IllegalStateException("Cannot create JSON Object", e); } if (debug) Log.w(TAG, "buildPreviewAndSize() dimensions: " + joDimensions); final MessagePart dimensionsPart = layerClient.newMessagePart(MIME_TYPE_IMAGE_DIMENSIONS, joDimensions.toString().getBytes()); MessagePart[] previewAndSize = new MessagePart[] { previewPart, dimensionsPart }; return previewAndSize; } /** @return if Today: time. If Yesterday: "Yesterday", if within one week: day of week, otherwise: dateFormat.format() */ public static String formatTimeShort(Date dateTime, DateFormat timeFormat, DateFormat dateFormat) { Calendar cal = Calendar.getInstance(); cal.set(Calendar.HOUR_OF_DAY, 0); cal.set(Calendar.MINUTE, 0); cal.set(Calendar.SECOND, 0); long todayMidnight = cal.getTimeInMillis(); long yesterMidnight = todayMidnight - Tools.TIME_HOURS_24; long weekAgoMidnight = todayMidnight - Tools.TIME_HOURS_24 * 7; String timeText = null; if (dateTime.getTime() > todayMidnight) { timeText = timeFormat.format(dateTime.getTime()); } else if (dateTime.getTime() > yesterMidnight) { timeText = "Yesterday"; } else if (dateTime.getTime() > weekAgoMidnight) { cal.setTime(dateTime); timeText = Tools.TIME_WEEKDAYS_NAMES[cal.get(Calendar.DAY_OF_WEEK) - 1]; } else { timeText = dateFormat.format(dateTime); } return timeText; } /** Today, Yesterday, Weekday or Weekday + date */ public static String formatTimeDay(Date sentAt) { Calendar cal = Calendar.getInstance(); cal.set(Calendar.HOUR_OF_DAY, 0); cal.set(Calendar.MINUTE, 0); cal.set(Calendar.SECOND, 0); long todayMidnight = cal.getTimeInMillis(); long yesterMidnight = todayMidnight - Tools.TIME_HOURS_24; long weekAgoMidnight = todayMidnight - Tools.TIME_HOURS_24 * 7; String timeBarDayText = null; if (sentAt.getTime() > todayMidnight) { timeBarDayText = "Today"; } else if (sentAt.getTime() > yesterMidnight) { timeBarDayText = "Yesterday"; } else if (sentAt.getTime() > weekAgoMidnight) { cal.setTime(sentAt); timeBarDayText = Tools.TIME_WEEKDAYS_NAMES[cal.get(Calendar.DAY_OF_WEEK) - 1]; } else { timeBarDayText = Tools.sdfDayOfWeek.format(sentAt); } return timeBarDayText; } public static final class Tools { /** Millis in 24 Hours */ public static final int TIME_HOURS_24 = 24 * 60 * 60 * 1000; // TODO: localization required to all time based constants below public static final SimpleDateFormat sdf = new SimpleDateFormat("HH:mm a"); public static final SimpleDateFormat sdfDayOfWeek = new SimpleDateFormat("EEE, LLL dd,"); /** Ensure you decrease value returned by Calendar.get(Calendar.DAY_OF_WEEK) by 1. Calendar's days starts from 1. */ public static final String[] TIME_WEEKDAYS_NAMES = new String[] { "Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday" }; public static final BitmapDrawable EMPTY_DRAWABLE = new BitmapDrawable( Bitmap.createBitmap(new int[] { Color.TRANSPARENT }, 1, 1, Bitmap.Config.ALPHA_8)); public static String toString(Message msg) { StringBuilder sb = new StringBuilder(); for (MessagePart mp : msg.getMessageParts()) { if (MIME_TYPE_TEXT.equals(mp.getMimeType())) { sb.append(new String(mp.getData())); } else if (MIME_TYPE_ATLAS_LOCATION.equals(mp.getMimeType())) { sb.append("Attachment: Location"); } else { sb.append("Attachment: Image"); break; } } return sb.toString(); } public static String toString(MotionEvent event) { StringBuilder sb = new StringBuilder(); sb.append("action: "); switch (event.getAction() & MotionEvent.ACTION_MASK) { case MotionEvent.ACTION_DOWN: sb.append("DOWN"); break; case MotionEvent.ACTION_UP: sb.append("UP "); break; case MotionEvent.ACTION_MOVE: sb.append("MOVE"); break; case MotionEvent.ACTION_CANCEL: sb.append("CANCEL"); break; case MotionEvent.ACTION_SCROLL: sb.append("SCROLL"); break; case MotionEvent.ACTION_POINTER_UP: { sb.append("ACTION_POINTER_UP"); final int pointerIndex = (event.getAction() & MotionEvent.ACTION_POINTER_INDEX_MASK) >> MotionEvent.ACTION_POINTER_INDEX_SHIFT; sb.append(" pointer: ").append(pointerIndex); break; } case MotionEvent.ACTION_POINTER_DOWN: { sb.append("ACTION_POINTER_DOWN"); final int pointerIndex = (event.getAction() & MotionEvent.ACTION_POINTER_INDEX_MASK) >> MotionEvent.ACTION_POINTER_INDEX_SHIFT; sb.append(" pointer: ").append(pointerIndex); break; } default: sb.append(event.getAction()); break; } sb.append(", pts: ["); for (int i = 0; i < event.getPointerCount(); i++) { sb.append(i > 0 ? ", " : ""); sb.append(i).append(": ").append(String.format("%.1fx%.1f", event.getX(i), event.getY(i))); } sb.append("]"); return sb.toString(); } public static float[] getRoundRectRadii(float[] cornerRadiusDp, final DisplayMetrics displayMetrics) { float[] result = new float[8]; return getRoundRectRadii(cornerRadiusDp, displayMetrics, result); } public static float[] getRoundRectRadii(float[] cornerRadiusDp, final DisplayMetrics displayMetrics, float[] result) { if (result.length < cornerRadiusDp.length * 2) throw new IllegalArgumentException("result[] is shorter than required. result: " + result.length + ", required: " + cornerRadiusDp.length * 2); for (int i = 0; i < cornerRadiusDp.length; i++) { result[i * 2] = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, cornerRadiusDp[i], displayMetrics); result[i * 2 + 1] = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, cornerRadiusDp[i], displayMetrics); } return result; } public static float getPxFromDp(float dp, Context context) { return getPxFromDp(dp, context.getResources().getDisplayMetrics()); } public static float getPxFromDp(float dp, DisplayMetrics displayMetrics) { return TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, displayMetrics); } public static View findChildById(ViewGroup group, int id) { for (int i = 0; i < group.getChildCount(); i++) { View child = group.getChildAt(i); if (child.getId() == id) return child; } return null; } public static void closeQuietly(InputStream stream) { if (stream == null) return; try { stream.close(); } catch (Throwable ignoredQueitly) { } } public static void closeQuietly(OutputStream stream) { if (stream == null) return; try { stream.close(); } catch (Throwable ignoredQueitly) { } } /** * @return number of copied bytes */ public static int streamCopyAndClose(InputStream from, OutputStream to) throws IOException { int totalBytes = streamCopy(from, to); from.close(); to.close(); return totalBytes; } /** * @return number of copied bytes */ public static int streamCopy(InputStream from, OutputStream to) throws IOException { byte[] buffer = new byte[65536]; int bytesRead = 0; int totalBytes = 0; for (; (bytesRead = from.read(buffer)) != -1; totalBytes += bytesRead) { to.write(buffer, 0, bytesRead); } return totalBytes; } public static String toStringSpec(int measureSpec) { switch (MeasureSpec.getMode(measureSpec)) { case MeasureSpec.AT_MOST: return "" + MeasureSpec.getSize(measureSpec) + ".A"; case MeasureSpec.EXACTLY: return "" + MeasureSpec.getSize(measureSpec) + ".E"; default: return "" + MeasureSpec.getSize(measureSpec) + ".U"; } } public static String toStringSpec(int widthSpec, int heightSpec) { return toStringSpec(widthSpec) + "|" + toStringSpec(heightSpec); } public static boolean downloadHttpToFile(String url, File file) { HttpGet get = new HttpGet(url); HttpResponse response; try { response = (new DefaultHttpClient()).execute(get); if (HttpStatus.SC_OK != response.getStatusLine().getStatusCode()) { Log.e(TAG, String.format("Expected status 200, but got %d", response.getStatusLine().getStatusCode())); return false; } } catch (Exception e) { Log.e(TAG, "downloadToFile() cannot execute http request: " + url, e); return false; } File dir = file.getParentFile(); if (!dir.exists() && !dir.mkdirs()) { Log.e(TAG, String.format("Could not create directories for `%s`", dir.getAbsolutePath())); return false; } File tempFile = new File(file.getAbsolutePath() + ".tmp"); try { streamCopyAndClose(response.getEntity().getContent(), new FileOutputStream(tempFile, false)); response.getEntity().consumeContent(); } catch (IOException e) { if (debug) Log.e(TAG, "downloadToFile() cannot extract content from http response: " + url, e); } if (tempFile.length() != response.getEntity().getContentLength()) { tempFile.delete(); Log.e(TAG, String.format("downloadToFile() File size mismatch for `%s` (%d vs %d)", tempFile.getAbsolutePath(), tempFile.length(), response.getEntity().getContentLength())); return false; } // last step if (tempFile.renameTo(file)) { if (debug) Log.w(TAG, "downloadToFile() Successfully downloaded file: " + file.getAbsolutePath()); return true; } else { Log.e(TAG, "downloadToFile() Could not rename temp file: " + tempFile.getAbsolutePath() + " to: " + file.getAbsolutePath()); return false; } } /** * @param dumpPathPrefix - final path is constructed as <code>dumpPathPrefix + path from partId</code> */ public static void dumpPart(MessagePart part, String dumpPathPrefix) { try { String path = dumpPathPrefix + escapePath(part.getId().toString()); if (debug) Log.w(TAG, "onProgressComplete() dumping part into " + path + ", " + part.getMimeType() + ", id: " + part.getId()); Tools.streamCopyAndClose(part.getDataStream(), new FileOutputStream(path)); } catch (Exception e) { Log.e(TAG, "onProgressComplete() cannot dump part: " + part.getMimeType() + ", id: " + part.getId(), e); } } /** escape characters of part.id if they are invalid for filePath */ public static String escapePath(String partId) { return partId.replaceAll("[:/\\+]", "_"); } /** draws lines between opposite corners of provided rect */ public static void drawX(float left, float top, float right, float bottom, Paint paint, Canvas canvas) { canvas.drawLine(left, top, right, bottom, paint); canvas.drawLine(left, bottom, right, top, paint); } /** @see #drawX(float, float, float, float, Paint, Canvas)*/ public static void drawX(Rect rect, Paint paint, Canvas canvas) { drawX(rect.left, rect.top, rect.right, rect.bottom, paint, canvas); } /** @see #drawX(float, float, float, float, Paint, Canvas)*/ public static void drawX(RectF rect, Paint paint, Canvas canvas) { drawX(rect.left, rect.top, rect.right, rect.bottom, paint, canvas); } public static void drawPlus(float left, float top, float right, float bottom, Paint paint, Canvas canvas) { canvas.drawLine(0.5f * (left + right), top, 0.5f * (left + right), bottom, paint); canvas.drawLine(left, 0.5f * (top + bottom), right, 0.5f * (top + bottom), paint); } public static void drawPlus(Rect rect, Paint paint, Canvas canvas) { drawPlus(rect.left, rect.top, rect.right, rect.bottom, paint, canvas); } public static void drawPlus(RectF rect, Paint paint, Canvas canvas) { drawPlus(rect.left, rect.top, rect.right, rect.bottom, paint, canvas); } public static void drawPlus(float xCenter, float yCenter, Canvas canvas, Paint paint) { canvas.drawLine(xCenter, -10000, xCenter, 10000, paint); canvas.drawLine(-10000, yCenter, 10000, yCenter, paint); } public static void drawPlusCircle(float xCenter, float yCenter, float radius, Paint paint, Canvas canvas) { drawPlus(xCenter - 1.1f * radius, yCenter - 1.1f * radius, xCenter + 1.1f * radius, yCenter + 1.1f * radius, paint, canvas); canvas.drawCircle(xCenter, yCenter, radius, paint); } /** Window flags for translucency are available on Android 5.0+ */ public static final int FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS = 0x80000000; public static final int FLAG_TRANSLUCENT_STATUS = 0x04000000; /** Changes Status Bar color On Android 5.0+ devices. Do nothing on devices without translucency support */ public static void setStatusBarColor(Window wnd, int color) { try { final Method mthd_setStatusBarColor = wnd.getClass().getMethod("setStatusBarColor", int.class); if (mthd_setStatusBarColor != null) { mthd_setStatusBarColor.invoke(wnd, color); wnd.addFlags(FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS); wnd.clearFlags(FLAG_TRANSLUCENT_STATUS); } } catch (Exception ignored) { } } } /** * Participant allows Atlas classes to display information about users, like Message senders, * Conversation participants, TypingIndicator users, etc. */ public interface Participant { /** * Returns the first name of this Participant. * * @return The first name of this Participant */ String getFirstName(); /** * Returns the last name of this Participant. * * @return The last name of this Participant */ String getLastName(); /** * Returns drawable to be used as paprticipant's avatar in Atlas Views. * If undefined, initials would be used instead. * * @return drawable, or null */ Drawable getAvatarDrawable(); public static Comparator<Participant> COMPARATOR = new FilteringComparator(""); } /** * ParticipantProvider provides Atlas classes with Participant data. */ public interface ParticipantProvider { /** * Returns a map of all Participants by their unique ID who match the provided `filter`, or * all Participants if `filter` is `null`. If `result` is provided, it is operated on and * returned. If `result` is `null`, a new Map is created and returned. * * @param filter The filter to apply to Participants * @param result The Map to operate on * @return A Map of all matching Participants keyed by ID. */ Map<String, Participant> getParticipants(String filter, Map<String, Participant> result); /** * Returns the Participant with the given ID, or `null` if the participant is not yet * available. * * @return The Participant with the given ID, or `null` if not available. */ Atlas.Participant getParticipant(String userId); } public static final class FilteringComparator implements Comparator<Atlas.Participant> { private final String filter; /** * @param filter - the less indexOf(filter) the less order of participant */ public FilteringComparator(String filter) { this.filter = filter; } @Override public int compare(Atlas.Participant lhs, Atlas.Participant rhs) { int result = subCompareCaseInsensitive(lhs.getFirstName(), rhs.getFirstName()); if (result != 0) return result; return subCompareCaseInsensitive(lhs.getLastName(), rhs.getLastName()); } private int subCompareCaseInsensitive(String lhs, String rhs) { int left = lhs != null ? lhs.toLowerCase().indexOf(filter) : -1; int right = rhs != null ? rhs.toLowerCase().indexOf(filter) : -1; if (left == -1 && right == -1) return 0; if (left != -1 && right == -1) return -1; if (left == -1 && right != -1) return 1; if (left - right != 0) return left - right; return String.CASE_INSENSITIVE_ORDER.compare(lhs, rhs); } } /** * TODO: * * - imageCache should accept any "Downloader" that download something with progress * - imageCache should reschedule image if decoding failed * - imageCache should reschedule image if decoded width was cut due to OOM (-> sampleSize > 1) * - maximum retries should be configurable * */ public static class ImageLoader { private static final String TAG = Atlas.ImageLoader.class.getSimpleName(); private static final boolean debug = false; private static final int BITMAP_DECODE_RETRIES = 10; private static final double MEMORY_THRESHOLD = 0.7; private volatile boolean shutdownLoader = false; private final Thread processingThread; private final Object lock = new Object(); private final ArrayList<ImageSpec> queue = new ArrayList<ImageSpec>(); /** image_id -> Bitmap | Movie */ private LinkedHashMap<Object, Object> cache = new LinkedHashMap<Object, Object>(40, 1f, true) { private static final long serialVersionUID = 1L; protected boolean removeEldestEntry(Entry<Object, Object> eldest) { // calculate available memory long maxMemory = Runtime.getRuntime().maxMemory(); long usedMemory = Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory(); boolean cleaningRequired = 1.0 * usedMemory / maxMemory > MEMORY_THRESHOLD; final Object id = eldest.getKey(); if (cleaningRequired) if (debug) Log.w(TAG, "removeEldestEntry() cleaning bitmap for: " + id + ", size: " + cache.size() + ", queue: " + queue.size()); else if (debug) Log.w(TAG, "removeEldestEntry() " + " nothing, size: " + cache.size() + ", queue: " + queue.size()); return cleaningRequired; } }; public ImageLoader() { // launching thread processingThread = new Decoder("AtlasImageLoader"); processingThread.start(); } private final class Decoder extends Thread { public Decoder(String threadName) { super(threadName); } public void run() { if (debug) Log.w(TAG, "ImageLoader.run() started"); while (!shutdownLoader) { ImageSpec spec = null; // search bitmap ready to inflate // wait for queue synchronized (lock) { while (spec == null && !shutdownLoader) { try { lock.wait(); if (shutdownLoader) return; // picking from queue for (int i = 0; i < queue.size(); i++) { if (queue.get(i).inputStreamProvider.ready()) { // ready to inflate spec = queue.remove(i); break; } } } catch (InterruptedException e) { } } } Object result = null; if (spec.gif) { InputStream is = spec.inputStreamProvider.getInputStream(); Movie mov = Movie.decodeStream(is); if (debug) Log.w(TAG, "decodeImage() decoded GIF " + mov.width() + "x" + mov.height() + ":" + mov.duration() + "ms"); Tools.closeQuietly(is); result = mov; } else { // decode dimensions long started = System.currentTimeMillis(); InputStream streamForBounds = spec.inputStreamProvider.getInputStream(); if (streamForBounds == null) { Log.e(TAG, "decodeImage() stream is null! Request cancelled. Spec: " + spec.id + ", provider: " + spec.inputStreamProvider.getClass().getSimpleName()); return; } BitmapFactory.Options originalOpts = new BitmapFactory.Options(); originalOpts.inJustDecodeBounds = true; BitmapFactory.decodeStream(streamForBounds, null, originalOpts); Tools.closeQuietly(streamForBounds); // update spec if width and height are unknown spec.originalWidth = originalOpts.outWidth; spec.originalHeight = originalOpts.outHeight; // if required dimensions are not defined or bigger than original - use original dimensions int requiredWidth = spec.requiredWidth > 0 ? Math.min(spec.requiredWidth, originalOpts.outWidth) : originalOpts.outWidth; int requiredHeight = spec.requiredHeight > 0 ? Math.min(spec.requiredHeight, originalOpts.outHeight) : originalOpts.outHeight; int sampleSize = 1; // Use dimension with higher quality to meet both requirements float widthSampleSize = sampleSize(originalOpts.outWidth, requiredWidth); float heightSampleSize = sampleSize(originalOpts.outHeight, requiredHeight); sampleSize = (int) Math.min(widthSampleSize, heightSampleSize); if (debug) Log.w(TAG, "decodeImage() sampleSize: " + sampleSize + ", original: " + spec.originalWidth + "x" + spec.originalHeight + " required: " + spec.requiredWidth + "x" + spec.requiredHeight); BitmapFactory.Options decodeOpts = new BitmapFactory.Options(); decodeOpts.inSampleSize = sampleSize; Bitmap bmp = null; InputStream streamForBitmap = spec.inputStreamProvider.getInputStream(); try { bmp = BitmapFactory.decodeStream(streamForBitmap, null, decodeOpts); } catch (OutOfMemoryError e) { if (debug) Log.w(TAG, "decodeImage() out of memory. remove eldest"); removeEldest(); System.gc(); } Tools.closeQuietly(streamForBitmap); if (bmp != null) { if (debug) Log.d(TAG, "decodeImage() decoded " + bmp.getWidth() + "x" + bmp.getHeight() + " " + bmp.getByteCount() + " bytes" + " req: " + spec.requiredWidth + "x" + spec.requiredHeight + " original: " + originalOpts.outWidth + "x" + originalOpts.outHeight + " sampleSize: " + sampleSize + " in " + (System.currentTimeMillis() - started) + "ms from: " + spec.id); } else { if (debug) Log.d(TAG, "decodeImage() not decoded " + " req: " + requiredWidth + "x" + requiredHeight + " in " + (System.currentTimeMillis() - started) + "ms from: " + spec.id); } result = bmp; } // decoded synchronized (lock) { if (result != null) { cache.put(spec.id, result); if (spec.listener != null) spec.listener.onImageLoaded(spec); } else if (spec.retries < BITMAP_DECODE_RETRIES) { spec.retries++; queue.add(0, spec); // schedule retry lock.notifyAll(); } /*else forget about this image, never put it back in queue */ } if (debug) Log.w(TAG, "decodeImage() cache: " + cache.size() + ", queue: " + queue.size() + ", id: " + spec.id); } } } /** * * Return maximum possible sampleSize to decode bitmap with dimensions >= minRequired * * <p> * Despite {@link BitmapFactory.Options#inSampleSize} documentation, sampleSize * handles properly only 2^n values. Other values are handled as nearest lower 2^n.<p> * I.e. result bitmap with <br> * <code> * opts.sampleSize = 3 is the same as opts.sampleSize = 2<br> * opts.sampleSize = 5 is the same as opts.sampleSize = 4 * </code> * * @return bitmap sampleSize values [1, 2, 4, 8, .. 2^n] */ private static int sampleSize(int originalDimension, int minRequiredDimension) { int sampleSize = 1; while (originalDimension / (sampleSize * 2) > minRequiredDimension) { sampleSize *= 2; if (sampleSize >= 32) break; } return sampleSize; } public Object getImageFromCache(Object id) { return cache.get(id); } /** * @return - byteCount of removed bitmap if bitmap found. <bold>-1</bold> otherwise */ private int removeEldest() { synchronized (lock) { if (cache.size() > 0) { Map.Entry<Object, Object> entry = cache.entrySet().iterator().next(); Object bmp = entry.getValue(); cache.remove(entry.getKey()); int releasedBytes = (bmp instanceof Bitmap) ? ((Bitmap) bmp).getByteCount() : 0; /*((Movie)bmp).byteCount(); */ if (debug) Log.w(TAG, "removeEldest() id: " + entry.getKey() + ", bytes: " + releasedBytes); return releasedBytes; } else { if (debug) Log.w(TAG, "removeEldest() nothing to remove..."); return -1; } } } /** * @see #requestImage(Object, InputStreamProvider, int, int, boolean, ImageLoadListener) */ public ImageSpec requestImage(Object id, InputStreamProvider streamProvider, ImageLoader.ImageLoadListener loadListener) { return requestImage(id, streamProvider, 0, 0, false, loadListener); } /** * @see #requestImage(Object, InputStreamProvider, int, int, boolean, ImageLoadListener) */ public ImageSpec requestImage(Object id, InputStreamProvider streamProvider, boolean gif, ImageLoader.ImageLoadListener loadListener) { return requestImage(id, streamProvider, 0, 0, gif, loadListener); } /** * @param id - something you will use to get image from cache later * @param streamProvider - something that provides raw bytes. See {@link Atlas.FileStreamProvider} or {@link Atlas.MessagePartStreamProvider} * @param requiredWidth - * @param requiredHeight - provide image dimensions you need to save memory if original dimensions are bigger * @param gif - android.graphics.Movie would be decoded instead of Bitmap. <b>Warning!</b> {@link Atlas.MessagePartBufferedStreamProvider} must be used * @param loadListener - something you can use to be notified when image is loaded */ public ImageSpec requestImage(Object id, InputStreamProvider streamProvider, int requiredWidth, int requiredHeight, boolean gif, ImageLoader.ImageLoadListener loadListener) { ImageSpec spec = null; synchronized (lock) { for (int i = 0; i < queue.size(); i++) { if (queue.get(i).id.equals(id)) { spec = queue.remove(i); break; } } if (spec == null) { spec = new ImageSpec(); spec.id = id; spec.inputStreamProvider = streamProvider; spec.requiredHeight = requiredHeight; spec.requiredWidth = requiredWidth; spec.listener = loadListener; spec.gif = gif; } queue.add(0, spec); lock.notifyAll(); } if (debug) Log.w(TAG, "requestBitmap() cache: " + cache.size() + ", queue: " + queue.size() + ", id: " + id + ", reqs: " + requiredWidth + "x" + requiredHeight); return spec; } public static class ImageSpec { public Object id; public InputStreamProvider inputStreamProvider; public int requiredWidth; public int requiredHeight; public int originalWidth; public int originalHeight; public boolean gif; public int downloadProgress; public int retries = 0; public ImageLoader.ImageLoadListener listener; } public interface ImageLoadListener { public void onImageLoaded(ImageSpec spec); } public static abstract class InputStreamProvider { public abstract InputStream getInputStream(); public abstract boolean ready(); } } public static class DownloadQueue { private static final String TAG = DownloadQueue.class.getSimpleName(); final ArrayList<Entry> queue = new ArrayList<Atlas.DownloadQueue.Entry>(); final HashMap<String, Entry> url2Entry = new HashMap<String, Entry>(); private volatile Entry inProgress = null; public DownloadQueue() { workingThread.setDaemon(true); workingThread.setName("Atlas-HttpDownloadQueue"); workingThread.start(); } public void schedule(String url, File to, CompleteListener onComplete) { if (inProgress != null && inProgress.url.equals(url)) { return; } synchronized (queue) { Entry existing = url2Entry.get(url); if (existing != null) { queue.remove(existing); queue.add(existing); } else { Entry toSchedule = new Entry(url, to, onComplete); queue.add(toSchedule); url2Entry.put(toSchedule.url, toSchedule); } queue.notifyAll(); } } private Thread workingThread = new Thread(new Runnable() { public void run() { while (true) { Entry next = null; synchronized (queue) { while (queue.size() == 0) { try { queue.wait(); } catch (InterruptedException ignored) { } } next = queue.remove(queue.size() - 1); // get last url2Entry.remove(next.url); inProgress = next; } try { if (Tools.downloadHttpToFile(next.url, next.file)) { if (next.completeListener != null) { next.completeListener.onDownloadComplete(next.url, next.file); } } ; } catch (Throwable e) { Log.e(TAG, "onComplete() thrown an exception for: " + next.url, e); } inProgress = null; } } }); private static class Entry { String url; File file; CompleteListener completeListener; public Entry(String url, File file, CompleteListener listener) { if (url == null) throw new IllegalArgumentException("url cannot be null"); if (file == null) throw new IllegalArgumentException("file cannot be null"); this.url = url; this.file = file; this.completeListener = listener; } } public interface CompleteListener { public void onDownloadComplete(String url, File file); } } public static class MessagePartStreamProvider extends ImageLoader.InputStreamProvider { public final MessagePart part; public MessagePartStreamProvider(MessagePart part) { if (part == null) throw new IllegalStateException("MessagePart cannot be null"); this.part = part; } public InputStream getInputStream() { return part.getDataStream(); } public boolean ready() { return part.isContentReady(); } } /** * Provides BufferedInputStream on top of messagePart.dataStream, with 16k buffer * and mark set to 0 with 16k read limit. <p> * * Used for GIF purposes, because it calls <code>.reset()</code> stream during execution */ public static class MessagePartBufferedStreamProvider extends ImageLoader.InputStreamProvider { public final MessagePart part; public MessagePartBufferedStreamProvider(MessagePart part) { if (part == null) throw new IllegalStateException("MessagePart cannot be null"); this.part = part; } public InputStream getInputStream() { BufferedInputStream stream = new BufferedInputStream(part.getDataStream(), 16 * 1024); stream.mark(16 * 1024); return stream; } public boolean ready() { return part.isContentReady(); } } public static class FileStreamProvider extends ImageLoader.InputStreamProvider { final File file; public FileStreamProvider(File file) { if (file == null) throw new IllegalStateException("File cannot be null"); if (!file.exists()) throw new IllegalStateException("File must exist!"); this.file = file; } public InputStream getInputStream() { try { return new FileInputStream(file); } catch (FileNotFoundException e) { Log.e(ImageLoader.TAG, "FileStreamProvider.getStream() cannot open file. file: " + file, e); return null; } } public boolean ready() { if (ImageLoader.debug) Log.w(ImageLoader.TAG, "ready() FileStreamProvider, file ready: " + file.getAbsolutePath()); return true; } } /** * Basic {@link AtlasMessagesList.CellFactory} supports mime-types * <li> {@link Atlas#MIME_TYPE_TEXT} * <li> {@link Atlas#MIME_TYPE_ATLAS_LOCATION} * <li> {@link Atlas#MIME_TYPE_IMAGE_JPEG} * <li> {@link Atlas#MIME_TYPE_IMAGE_GIF} * <li> {@link Atlas#MIME_TYPE_IMAGE_PNG} * ... including 3-part images with preview and dimensions */ public static class DefaultCellFactory extends AtlasMessagesList.CellFactory { public final AtlasMessagesList messagesList; public DefaultCellFactory(AtlasMessagesList messagesList) { this.messagesList = messagesList; } /** * Scan message and messageParts and build corresponding Cell(s). Put them into result list * @param msg - message to build Cell(s) for * @param destination - result list of Cells */ public void buildCellForMessage(Message msg, List<AtlasMessagesList.Cell> destination) { final ArrayList<MessagePart> parts = new ArrayList<MessagePart>(msg.getMessageParts()); for (int partNo = 0; partNo < parts.size(); partNo++) { final MessagePart part = parts.get(partNo); final String mimeType = part.getMimeType(); if (MIME_TYPE_IMAGE_PNG.equals(mimeType) || MIME_TYPE_IMAGE_JPEG.equals(mimeType) || MIME_TYPE_IMAGE_GIF.equals(mimeType)) { // 3 parts image support if ((partNo + 2 < parts.size()) && MIME_TYPE_IMAGE_DIMENSIONS.equals(parts.get(partNo + 2).getMimeType())) { String jsonDimensions = new String(parts.get(partNo + 2).getData()); try { JSONObject jo = new JSONObject(jsonDimensions); int orientation = jo.getInt("orientation"); int width = jo.getInt("width"); int height = jo.getInt("height"); if (orientation == 1 || orientation == 3) { width = jo.getInt("height"); height = jo.getInt("width"); } AtlasMessagesList.Cell imageCell = mimeType.equals(MIME_TYPE_IMAGE_GIF) ? new GIFCell(part, parts.get(partNo + 1), width, height, orientation, messagesList) : new ImageCell(part, parts.get(partNo + 1), width, height, orientation, messagesList); destination.add(imageCell); if (debug) Log.w(TAG, "cellForMessage() 3-image part found at partNo: " + partNo + ", " + width + "x" + height + "@" + orientation); partNo++; // skip preview partNo++; // skip dimensions part } catch (JSONException e) { Log.e(TAG, "cellForMessage() cannot parse 3-part image", e); } } else { // regular image AtlasMessagesList.Cell cell = mimeType.equals(MIME_TYPE_IMAGE_GIF) ? new GIFCell(part, messagesList) : new ImageCell(part, messagesList); destination.add(cell); if (debug) Log.w(TAG, "cellForMessage() single-image part found at partNo: " + partNo); } } else if (MIME_TYPE_ATLAS_LOCATION.equals(part.getMimeType())) { destination.add(new GeoCell(part, messagesList)); } else { AtlasMessagesList.Cell cellData = new AtlasMessagesList.TextCell(part, messagesList); destination.add(cellData); } } } } }