diff options
Diffstat (limited to 'src/com/android/messaging/datamodel/media')
38 files changed, 4744 insertions, 0 deletions
diff --git a/src/com/android/messaging/datamodel/media/AsyncMediaRequestWrapper.java b/src/com/android/messaging/datamodel/media/AsyncMediaRequestWrapper.java new file mode 100644 index 0000000..380d93c --- /dev/null +++ b/src/com/android/messaging/datamodel/media/AsyncMediaRequestWrapper.java @@ -0,0 +1,74 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel.media; + +import com.android.messaging.datamodel.media.MediaResourceManager.MediaResourceLoadListener; + +import java.util.List; + +/** + * A mix-in style class that wraps around a normal, threading-agnostic MediaRequest object with + * functionalities offered by {@link BindableMediaRequest} to allow for async processing. + */ +class AsyncMediaRequestWrapper<T extends RefCountedMediaResource> extends BindableMediaRequest<T> { + + /** + * Create a new async media request wrapper instance given the listener. + */ + public static <T extends RefCountedMediaResource> AsyncMediaRequestWrapper<T> + createWith(final MediaRequest<T> wrappedRequest, + final MediaResourceLoadListener<T> listener) { + return new AsyncMediaRequestWrapper<T>(listener, wrappedRequest); + } + + private final MediaRequest<T> mWrappedRequest; + + private AsyncMediaRequestWrapper(final MediaResourceLoadListener<T> listener, + final MediaRequest<T> wrappedRequest) { + super(listener); + mWrappedRequest = wrappedRequest; + } + + @Override + public String getKey() { + return mWrappedRequest.getKey(); + } + + @Override + public MediaCache<T> getMediaCache() { + return mWrappedRequest.getMediaCache(); + } + + @Override + public int getRequestType() { + return mWrappedRequest.getRequestType(); + } + + @Override + public T loadMediaBlocking(List<MediaRequest<T>> chainedTask) throws Exception { + return mWrappedRequest.loadMediaBlocking(chainedTask); + } + + @Override + public int getCacheId() { + return mWrappedRequest.getCacheId(); + } + + @Override + public MediaRequestDescriptor<T> getDescriptor() { + return mWrappedRequest.getDescriptor(); + } +}
\ No newline at end of file diff --git a/src/com/android/messaging/datamodel/media/AvatarGroupRequestDescriptor.java b/src/com/android/messaging/datamodel/media/AvatarGroupRequestDescriptor.java new file mode 100644 index 0000000..719b296 --- /dev/null +++ b/src/com/android/messaging/datamodel/media/AvatarGroupRequestDescriptor.java @@ -0,0 +1,159 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel.media; + +import android.content.Context; +import android.graphics.RectF; +import android.net.Uri; + +import com.android.messaging.util.Assert; +import com.android.messaging.util.AvatarUriUtil; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +public class AvatarGroupRequestDescriptor extends CompositeImageRequestDescriptor { + private static final int MAX_GROUP_SIZE = 4; + + public AvatarGroupRequestDescriptor(final Uri uri, final int desiredWidth, + final int desiredHeight) { + this(convertToDescriptor(uri, desiredWidth, desiredHeight), desiredWidth, desiredHeight); + } + + public AvatarGroupRequestDescriptor(final List<? extends ImageRequestDescriptor> descriptors, + final int desiredWidth, final int desiredHeight) { + super(descriptors, desiredWidth, desiredHeight); + Assert.isTrue(descriptors.size() <= MAX_GROUP_SIZE); + } + + private static List<? extends ImageRequestDescriptor> convertToDescriptor(final Uri uri, + final int desiredWidth, final int desiredHeight) { + final List<String> participantUriStrings = AvatarUriUtil.getGroupParticipantUris(uri); + final List<AvatarRequestDescriptor> avatarDescriptors = + new ArrayList<AvatarRequestDescriptor>(participantUriStrings.size()); + for (final String uriString : participantUriStrings) { + final AvatarRequestDescriptor descriptor = new AvatarRequestDescriptor( + Uri.parse(uriString), desiredWidth, desiredHeight); + avatarDescriptors.add(descriptor); + } + return avatarDescriptors; + } + + @Override + public CompositeImageRequest<?> buildBatchImageRequest(final Context context) { + return new CompositeImageRequest<AvatarGroupRequestDescriptor>(context, this); + } + + @Override + public List<RectF> getChildRequestTargetRects() { + return Arrays.asList(generateDestRectArray()); + } + + /** + * Generates an array of {@link RectF} which represents where each of the individual avatar + * should be located in the final group avatar image. The location of each avatar depends on + * the size of the group and the size of the overall group avatar size. + */ + private RectF[] generateDestRectArray() { + final int groupSize = mDescriptors.size(); + final float width = desiredWidth; + final float height = desiredHeight; + final float halfWidth = width / 2F; + final float halfHeight = height / 2F; + final RectF[] destArray = new RectF[groupSize]; + switch (groupSize) { + case 2: + /** + * +-------+ + * | 0 | | + * +-------+ + * | | 1 | + * +-------+ + * + * We want two circles which touches in the center. To get this we know that the + * diagonal of the overall group avatar is squareRoot(2) * w We also know that the + * two circles touches the at the center of the overall group avatar and the + * distance from the center of the circle to the corner of the group avatar is + * radius * squareRoot(2). Therefore, the following emerges. + * + * w * squareRoot(2) = 2 (radius + radius * squareRoot(2)) + * Solving for radius we get: + * d = 2 * radius = ( squareRoot(2) / (squareRoot(2) + 1)) * w + * d = (2 - squareRoot(2)) * w + */ + final float diameter = (float) ((2 - Math.sqrt(2)) * width); + destArray[0] = new RectF(0, 0, diameter, diameter); + destArray[1] = new RectF(width - diameter, height - diameter, width, height); + break; + case 3: + /** + * +-------+ + * | | 0 | | + * +-------+ + * | 1 | 2 | + * +-------+ + * i0 + * |\ + * a | \ c + * --- i2 + * b + * + * a = radius * squareRoot(3) due to the triangle being a 30-60-90 right triangle. + * b = radius of circle + * c = 2 * radius of circle + * + * All three of the images are circles and therefore image zero will not touch + * image one or image two. Move image zero down so it touches image one and image + * two. This can be done by keeping image zero in the center and moving it down + * slightly. The amount to move down can be calculated by solving a right triangle. + * We know that the center x of image two to the center x of image zero is the + * radius of the circle, this is the length of edge b. Also we know that the + * distance from image zero to image two's center is 2 * radius, edge c. From this + * we know that the distance from center y of image two to center y of image one, + * edge a, is equal to radius * squareRoot(3) due to this triangle being a 30-60-90 + * right triangle. + */ + final float quarterWidth = width / 4F; + final float threeQuarterWidth = 3 * quarterWidth; + final float radius = height / 4F; + final float imageTwoCenterY = height - radius; + final float lengthOfEdgeA = (float) (radius * Math.sqrt(3)); + final float imageZeroCenterY = imageTwoCenterY - lengthOfEdgeA; + final float imageZeroTop = imageZeroCenterY - radius; + final float imageZeroBottom = imageZeroCenterY + radius; + destArray[0] = new RectF( + quarterWidth, imageZeroTop, threeQuarterWidth, imageZeroBottom); + destArray[1] = new RectF(0, halfHeight, halfWidth, height); + destArray[2] = new RectF(halfWidth, halfHeight, width, height); + break; + default: + /** + * +-------+ + * | 0 | 1 | + * +-------+ + * | 2 | 3 | + * +-------+ + */ + destArray[0] = new RectF(0, 0, halfWidth, halfHeight); + destArray[1] = new RectF(halfWidth, 0, width, halfHeight); + destArray[2] = new RectF(0, halfHeight, halfWidth, height); + destArray[3] = new RectF(halfWidth, halfHeight, width, height); + break; + } + return destArray; + } +} diff --git a/src/com/android/messaging/datamodel/media/AvatarRequest.java b/src/com/android/messaging/datamodel/media/AvatarRequest.java new file mode 100644 index 0000000..22d5ccc --- /dev/null +++ b/src/com/android/messaging/datamodel/media/AvatarRequest.java @@ -0,0 +1,189 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel.media; + +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Matrix; +import android.graphics.Paint; +import android.graphics.Rect; +import android.graphics.RectF; +import android.graphics.Typeface; +import android.graphics.drawable.BitmapDrawable; +import android.media.ExifInterface; +import android.net.Uri; + +import com.android.messaging.R; +import com.android.messaging.util.Assert; +import com.android.messaging.util.AvatarUriUtil; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.UriUtil; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.util.List; + +public class AvatarRequest extends UriImageRequest<AvatarRequestDescriptor> { + private static Bitmap sDefaultPersonBitmap; + private static Bitmap sDefaultPersonBitmapLarge; + + public AvatarRequest(final Context context, + final AvatarRequestDescriptor descriptor) { + super(context, descriptor); + } + + @Override + protected InputStream getInputStreamForResource() throws FileNotFoundException { + if (UriUtil.isLocalResourceUri(mDescriptor.uri)) { + return super.getInputStreamForResource(); + } else { + final Uri primaryUri = AvatarUriUtil.getPrimaryUri(mDescriptor.uri); + Assert.isTrue(UriUtil.isLocalResourceUri(primaryUri)); + return mContext.getContentResolver().openInputStream(primaryUri); + } + } + + /** + * We can load multiple types of images for avatars depending on the uri. The uri should be + * built by {@link com.android.messaging.util.AvatarUriUtil} which will decide on + * what uri to build based on the available profile photo and name. Here we will check if the + * image is a local resource (ie profile photo uri), if the resource isn't a local one we will + * generate a tile with the first letter of the name. + */ + @Override + protected ImageResource loadMediaInternal(List<MediaRequest<ImageResource>> chainedTasks) + throws IOException { + Assert.isNotMainThread(); + String avatarType = AvatarUriUtil.getAvatarType(mDescriptor.uri); + Bitmap bitmap = null; + int orientation = ExifInterface.ORIENTATION_NORMAL; + final boolean isLocalResourceUri = UriUtil.isLocalResourceUri(mDescriptor.uri) || + AvatarUriUtil.TYPE_LOCAL_RESOURCE_URI.equals(avatarType); + if (isLocalResourceUri) { + try { + ImageResource imageResource = super.loadMediaInternal(chainedTasks); + bitmap = imageResource.getBitmap(); + orientation = imageResource.mOrientation; + } catch (Exception ex) { + // If we encountered any exceptions trying to load the local avatar resource, + // fall back to generated avatar. + LogUtil.w(LogUtil.BUGLE_IMAGE_TAG, "AvatarRequest: failed to load local avatar " + + "resource, switching to fallback rendering", ex); + } + } + + final int width = mDescriptor.desiredWidth; + final int height = mDescriptor.desiredHeight; + // Check to see if we already got the bitmap. If not get a fallback avatar + if (bitmap == null) { + Uri generatedUri = mDescriptor.uri; + if (isLocalResourceUri) { + // If we are here, we just failed to load the local resource. Use the fallback Uri + // if possible. + generatedUri = AvatarUriUtil.getFallbackUri(mDescriptor.uri); + if (generatedUri == null) { + // No fallback Uri was provided, use the default avatar. + generatedUri = AvatarUriUtil.DEFAULT_BACKGROUND_AVATAR; + } + } + + avatarType = AvatarUriUtil.getAvatarType(generatedUri); + if (AvatarUriUtil.TYPE_LETTER_TILE_URI.equals(avatarType)) { + final String name = AvatarUriUtil.getName(generatedUri); + bitmap = renderLetterTile(name, width, height); + } else { + bitmap = renderDefaultAvatar(width, height); + } + } + return new DecodedImageResource(getKey(), bitmap, orientation); + } + + private Bitmap renderDefaultAvatar(final int width, final int height) { + final Bitmap bitmap = getBitmapPool().createOrReuseBitmap(width, height, + getBackgroundColor()); + final Canvas canvas = new Canvas(bitmap); + + if (sDefaultPersonBitmap == null) { + final BitmapDrawable defaultPerson = (BitmapDrawable) mContext.getResources() + .getDrawable(R.drawable.ic_person_light); + sDefaultPersonBitmap = defaultPerson.getBitmap(); + } + if (sDefaultPersonBitmapLarge == null) { + final BitmapDrawable largeDefaultPerson = (BitmapDrawable) mContext.getResources() + .getDrawable(R.drawable.ic_person_light_large); + sDefaultPersonBitmapLarge = largeDefaultPerson.getBitmap(); + } + + Bitmap defaultPerson = null; + if (mDescriptor.isWearBackground) { + final BitmapDrawable wearDefaultPerson = (BitmapDrawable) mContext.getResources() + .getDrawable(R.drawable.ic_person_wear); + defaultPerson = wearDefaultPerson.getBitmap(); + } else { + final boolean isLargeDefault = (width > sDefaultPersonBitmap.getWidth()) || + (height > sDefaultPersonBitmap.getHeight()); + defaultPerson = + isLargeDefault ? sDefaultPersonBitmapLarge : sDefaultPersonBitmap; + } + + final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG); + final Matrix matrix = new Matrix(); + final RectF source = new RectF(0, 0, defaultPerson.getWidth(), defaultPerson.getHeight()); + final RectF dest = new RectF(0, 0, width, height); + matrix.setRectToRect(source, dest, Matrix.ScaleToFit.FILL); + + canvas.drawBitmap(defaultPerson, matrix, paint); + + return bitmap; + } + + private Bitmap renderLetterTile(final String name, final int width, final int height) { + final float halfWidth = width / 2; + final float halfHeight = height / 2; + final int minOfWidthAndHeight = Math.min(width, height); + final Bitmap bitmap = getBitmapPool().createOrReuseBitmap(width, height, + getBackgroundColor()); + final Resources resources = mContext.getResources(); + final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG); + paint.setTypeface(Typeface.create("sans-serif-thin", Typeface.NORMAL)); + paint.setColor(resources.getColor(R.color.letter_tile_font_color)); + final float letterToTileRatio = resources.getFraction(R.dimen.letter_to_tile_ratio, 1, 1); + paint.setTextSize(letterToTileRatio * minOfWidthAndHeight); + + final String firstCharString = name.substring(0, 1).toUpperCase(); + final Rect textBound = new Rect(); + paint.getTextBounds(firstCharString, 0, 1, textBound); + + final Canvas canvas = new Canvas(bitmap); + final float xOffset = halfWidth - textBound.centerX(); + final float yOffset = halfHeight - textBound.centerY(); + canvas.drawText(firstCharString, xOffset, yOffset, paint); + + return bitmap; + } + + private int getBackgroundColor() { + return mContext.getResources().getColor(R.color.primary_color); + } + + @Override + public int getCacheId() { + return BugleMediaCacheManager.AVATAR_IMAGE_CACHE; + } +} diff --git a/src/com/android/messaging/datamodel/media/AvatarRequestDescriptor.java b/src/com/android/messaging/datamodel/media/AvatarRequestDescriptor.java new file mode 100644 index 0000000..9afa9ad --- /dev/null +++ b/src/com/android/messaging/datamodel/media/AvatarRequestDescriptor.java @@ -0,0 +1,60 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.datamodel.media; + +import android.content.Context; +import android.net.Uri; + +import com.android.messaging.util.Assert; +import com.android.messaging.util.AvatarUriUtil; +import com.android.messaging.util.ImageUtils; +import com.android.messaging.util.UriUtil; + +public class AvatarRequestDescriptor extends UriImageRequestDescriptor { + final boolean isWearBackground; + + public AvatarRequestDescriptor(final Uri uri, final int desiredWidth, + final int desiredHeight) { + this(uri, desiredWidth, desiredHeight, true /* cropToCircle */); + } + + public AvatarRequestDescriptor(final Uri uri, final int desiredWidth, + final int desiredHeight, final boolean cropToCircle) { + this(uri, desiredWidth, desiredHeight, cropToCircle, false /* isWearBackground */); + } + + public AvatarRequestDescriptor(final Uri uri, final int desiredWidth, + final int desiredHeight, boolean cropToCircle, boolean isWearBackground) { + super(uri, desiredWidth, desiredHeight, false /* allowCompression */, true /* isStatic */, + cropToCircle, + ImageUtils.DEFAULT_CIRCLE_BACKGROUND_COLOR /* circleBackgroundColor */, + ImageUtils.DEFAULT_CIRCLE_STROKE_COLOR /* circleStrokeColor */); + Assert.isTrue(uri == null || UriUtil.isLocalResourceUri(uri) || + AvatarUriUtil.isAvatarUri(uri)); + this.isWearBackground = isWearBackground; + } + + @Override + public MediaRequest<ImageResource> buildSyncMediaRequest(final Context context) { + final String avatarType = uri == null ? null : AvatarUriUtil.getAvatarType(uri); + if (AvatarUriUtil.TYPE_SIM_SELECTOR_URI.equals(avatarType)) { + return new SimSelectorAvatarRequest(context, this); + } else { + return new AvatarRequest(context, this); + } + } +} diff --git a/src/com/android/messaging/datamodel/media/BindableMediaRequest.java b/src/com/android/messaging/datamodel/media/BindableMediaRequest.java new file mode 100644 index 0000000..36521d5 --- /dev/null +++ b/src/com/android/messaging/datamodel/media/BindableMediaRequest.java @@ -0,0 +1,63 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel.media; + +import com.android.messaging.datamodel.binding.BindableOnceData; +import com.android.messaging.datamodel.media.MediaResourceManager.MediaResourceLoadListener; + +/** + * The {@link MediaRequest} interface is threading-model-blind, allowing the implementations to + * be processed synchronously or asynchronously. + * This is a {@link MediaRequest} implementation that includes functionalities such as binding and + * event callbacks for multi-threaded media request processing. + */ +public abstract class BindableMediaRequest<T extends RefCountedMediaResource> + extends BindableOnceData + implements MediaRequest<T>, MediaResourceLoadListener<T> { + private MediaResourceLoadListener<T> mListener; + + public BindableMediaRequest(final MediaResourceLoadListener<T> listener) { + mListener = listener; + } + + /** + * Delegates the media resource callback to the listener. Performs binding check to ensure + * the listener is still bound to this request. + */ + @Override + public void onMediaResourceLoaded(final MediaRequest<T> request, final T resource, + final boolean cached) { + if (isBound() && mListener != null) { + mListener.onMediaResourceLoaded(request, resource, cached); + } + } + + /** + * Delegates the media resource callback to the listener. Performs binding check to ensure + * the listener is still bound to this request. + */ + @Override + public void onMediaResourceLoadError(final MediaRequest<T> request, final Exception exception) { + if (isBound() && mListener != null) { + mListener.onMediaResourceLoadError(request, exception); + } + } + + @Override + protected void unregisterListeners() { + mListener = null; + } +} diff --git a/src/com/android/messaging/datamodel/media/BugleMediaCacheManager.java b/src/com/android/messaging/datamodel/media/BugleMediaCacheManager.java new file mode 100644 index 0000000..c41ba60 --- /dev/null +++ b/src/com/android/messaging/datamodel/media/BugleMediaCacheManager.java @@ -0,0 +1,54 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel.media; + +import com.android.messaging.util.Assert; + +/** + * An implementation of {@link MediaCacheManager} that creates caches specific to Bugle's needs. + * + * To create a new type of cache, add to the list of cache ids and create a new MediaCache<> + * for your cache id / media resource type in createMediaCacheById(). + */ +public class BugleMediaCacheManager extends MediaCacheManager { + // List of available cache ids. + public static final int DEFAULT_IMAGE_CACHE = 1; + public static final int AVATAR_IMAGE_CACHE = 2; + public static final int VCARD_CACHE = 3; + + // VCard cache size - we compute the size by count, not by bytes. + private static final int VCARD_CACHE_SIZE = 5; + private static final int SHARED_IMAGE_CACHE_SIZE = 1024 * 10; // 10MB + + @Override + protected MediaCache<?> createMediaCacheById(final int id) { + switch (id) { + case DEFAULT_IMAGE_CACHE: + return new PoolableImageCache(SHARED_IMAGE_CACHE_SIZE, id, "DefaultImageCache"); + + case AVATAR_IMAGE_CACHE: + return new PoolableImageCache(id, "AvatarImageCache"); + + case VCARD_CACHE: + return new MediaCache<VCardResource>(VCARD_CACHE_SIZE, id, "VCardCache"); + + default: + Assert.fail("BugleMediaCacheManager: unsupported cache id " + id); + break; + } + return null; + } +} diff --git a/src/com/android/messaging/datamodel/media/CompositeImageRequest.java b/src/com/android/messaging/datamodel/media/CompositeImageRequest.java new file mode 100644 index 0000000..66f1bff --- /dev/null +++ b/src/com/android/messaging/datamodel/media/CompositeImageRequest.java @@ -0,0 +1,109 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel.media; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Matrix; +import android.graphics.Paint; +import android.graphics.RectF; +import android.media.ExifInterface; + +import com.android.messaging.util.Assert; +import com.android.messaging.util.ImageUtils; + +import java.io.FileNotFoundException; +import java.io.InputStream; +import java.util.List; + +/** + * Requests a composite image resource. The composite image resource is constructed by first + * sequentially requesting a number of sub image resources specified by + * {@link CompositeImageRequestDescriptor#getChildRequestDescriptors()}. After this, the + * individual sub images are composed into the final image onto their respective target rects + * returned by {@link CompositeImageRequestDescriptor#getChildRequestTargetRects()}. + */ +public class CompositeImageRequest<D extends CompositeImageRequestDescriptor> + extends ImageRequest<D> { + private final Bitmap mBitmap; + private final Canvas mCanvas; + private final Paint mPaint; + + public CompositeImageRequest(final Context context, final D descriptor) { + super(context, descriptor); + mBitmap = getBitmapPool().createOrReuseBitmap( + mDescriptor.desiredWidth, mDescriptor.desiredHeight); + mCanvas = new Canvas(mBitmap); + mPaint = new Paint(Paint.ANTI_ALIAS_FLAG); + } + + @Override + protected ImageResource loadMediaInternal(List<MediaRequest<ImageResource>> chainedTask) { + final List<? extends ImageRequestDescriptor> descriptors = + mDescriptor.getChildRequestDescriptors(); + final List<RectF> targetRects = mDescriptor.getChildRequestTargetRects(); + Assert.equals(descriptors.size(), targetRects.size()); + Assert.isTrue(descriptors.size() > 1); + + for (int i = 0; i < descriptors.size(); i++) { + final MediaRequest<ImageResource> request = + descriptors.get(i).buildSyncMediaRequest(mContext); + // Synchronously request the child image. + final ImageResource resource = + MediaResourceManager.get().requestMediaResourceSync(request); + if (resource != null) { + try { + final RectF avatarDestOnGroup = targetRects.get(i); + + // Draw the bitmap into a smaller size with a circle mask. + final Bitmap resourceBitmap = resource.getBitmap(); + final RectF resourceRect = new RectF( + 0, 0, resourceBitmap.getWidth(), resourceBitmap.getHeight()); + final Bitmap smallCircleBitmap = getBitmapPool().createOrReuseBitmap( + Math.round(avatarDestOnGroup.width()), + Math.round(avatarDestOnGroup.height())); + final RectF smallCircleRect = new RectF( + 0, 0, smallCircleBitmap.getWidth(), smallCircleBitmap.getHeight()); + final Canvas smallCircleCanvas = new Canvas(smallCircleBitmap); + ImageUtils.drawBitmapWithCircleOnCanvas(resource.getBitmap(), smallCircleCanvas, + resourceRect, smallCircleRect, null /* bitmapPaint */, + false /* fillBackground */, + ImageUtils.DEFAULT_CIRCLE_BACKGROUND_COLOR /* circleBackgroundColor */, + ImageUtils.DEFAULT_CIRCLE_STROKE_COLOR /* circleStrokeColor */); + final Matrix matrix = new Matrix(); + matrix.setRectToRect(smallCircleRect, avatarDestOnGroup, + Matrix.ScaleToFit.FILL); + mCanvas.drawBitmap(smallCircleBitmap, matrix, mPaint); + } finally { + resource.release(); + } + } + } + + return new DecodedImageResource(getKey(), mBitmap, ExifInterface.ORIENTATION_NORMAL); + } + + @Override + public int getCacheId() { + return BugleMediaCacheManager.AVATAR_IMAGE_CACHE; + } + + @Override + protected InputStream getInputStreamForResource() throws FileNotFoundException { + throw new IllegalStateException("Composite image request doesn't support input stream!"); + } +} diff --git a/src/com/android/messaging/datamodel/media/CompositeImageRequestDescriptor.java b/src/com/android/messaging/datamodel/media/CompositeImageRequestDescriptor.java new file mode 100644 index 0000000..071130e --- /dev/null +++ b/src/com/android/messaging/datamodel/media/CompositeImageRequestDescriptor.java @@ -0,0 +1,61 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel.media; + +import android.content.Context; +import android.graphics.RectF; + +import com.google.common.base.Joiner; + +import java.util.List; + +public abstract class CompositeImageRequestDescriptor extends ImageRequestDescriptor { + protected final List<? extends ImageRequestDescriptor> mDescriptors; + private final String mKey; + + public CompositeImageRequestDescriptor(final List<? extends ImageRequestDescriptor> descriptors, + final int desiredWidth, final int desiredHeight) { + super(desiredWidth, desiredHeight); + mDescriptors = descriptors; + + final String[] keyParts = new String[descriptors.size()]; + for (int i = 0; i < descriptors.size(); i++) { + keyParts[i] = descriptors.get(i).getKey(); + } + mKey = Joiner.on(",").skipNulls().join(keyParts); + } + + /** + * Gets a key that uniquely identify all the underlying image resource to be loaded (e.g. Uri or + * file path). + */ + @Override + public String getKey() { + return mKey; + } + + public List<? extends ImageRequestDescriptor> getChildRequestDescriptors(){ + return mDescriptors; + } + + public abstract List<RectF> getChildRequestTargetRects(); + public abstract CompositeImageRequest<?> buildBatchImageRequest(final Context context); + + @Override + public MediaRequest<ImageResource> buildSyncMediaRequest(final Context context) { + return buildBatchImageRequest(context); + } +} diff --git a/src/com/android/messaging/datamodel/media/CustomVCardEntry.java b/src/com/android/messaging/datamodel/media/CustomVCardEntry.java new file mode 100644 index 0000000..aee9fdc --- /dev/null +++ b/src/com/android/messaging/datamodel/media/CustomVCardEntry.java @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel.media; + +import android.accounts.Account; +import android.support.v4.util.ArrayMap; + +import com.android.vcard.VCardEntry; +import com.android.vcard.VCardProperty; + +import java.util.Map; + +/** + * Class which extends VCardEntry to add support for unknown properties. Currently there is a TODO + * to add this in the VCardEntry code, but we have to extend it to add the needed support + */ +public class CustomVCardEntry extends VCardEntry { + // List of properties keyed by their name for easy lookup + private final Map<String, VCardProperty> mAllProperties; + + public CustomVCardEntry(int vCardType, Account account) { + super(vCardType, account); + mAllProperties = new ArrayMap<String, VCardProperty>(); + } + + @Override + public void addProperty(VCardProperty property) { + super.addProperty(property); + mAllProperties.put(property.getName(), property); + } + + public VCardProperty getProperty(String name) { + return mAllProperties.get(name); + } +} diff --git a/src/com/android/messaging/datamodel/media/CustomVCardEntryConstructor.java b/src/com/android/messaging/datamodel/media/CustomVCardEntryConstructor.java new file mode 100644 index 0000000..06b10a3 --- /dev/null +++ b/src/com/android/messaging/datamodel/media/CustomVCardEntryConstructor.java @@ -0,0 +1,133 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel.media; + +import android.accounts.Account; +import com.android.vcard.VCardConfig; +import com.android.vcard.VCardInterpreter; +import com.android.vcard.VCardProperty; + +import java.util.ArrayList; +import java.util.List; + +public class CustomVCardEntryConstructor implements VCardInterpreter { + + public interface EntryHandler { + /** + * Called when the parsing started. + */ + public void onStart(); + + /** + * The method called when one vCard entry is created. Children come before their parent in + * nested vCard files. + * + * e.g. + * In the following vCard, the entry for "entry2" comes before one for "entry1". + * <code> + * BEGIN:VCARD + * N:entry1 + * BEGIN:VCARD + * N:entry2 + * END:VCARD + * END:VCARD + * </code> + */ + public void onEntryCreated(final CustomVCardEntry entry); + + /** + * Called when the parsing ended. + * Able to be use this method for showing performance log, etc. + */ + public void onEnd(); + } + + /** + * Represents current stack of VCardEntry. Used to support nested vCard (vCard 2.1). + */ + private final List<CustomVCardEntry> mEntryStack = new ArrayList<CustomVCardEntry>(); + private CustomVCardEntry mCurrentEntry; + + private final int mVCardType; + private final Account mAccount; + + private final List<EntryHandler> mEntryHandlers = new ArrayList<EntryHandler>(); + + public CustomVCardEntryConstructor() { + this(VCardConfig.VCARD_TYPE_V21_GENERIC, null); + } + + public CustomVCardEntryConstructor(final int vcardType) { + this(vcardType, null); + } + + public CustomVCardEntryConstructor(final int vcardType, final Account account) { + mVCardType = vcardType; + mAccount = account; + } + + public void addEntryHandler(EntryHandler entryHandler) { + mEntryHandlers.add(entryHandler); + } + + @Override + public void onVCardStarted() { + for (EntryHandler entryHandler : mEntryHandlers) { + entryHandler.onStart(); + } + } + + @Override + public void onVCardEnded() { + for (EntryHandler entryHandler : mEntryHandlers) { + entryHandler.onEnd(); + } + } + + public void clear() { + mCurrentEntry = null; + mEntryStack.clear(); + } + + @Override + public void onEntryStarted() { + mCurrentEntry = new CustomVCardEntry(mVCardType, mAccount); + mEntryStack.add(mCurrentEntry); + } + + @Override + public void onEntryEnded() { + mCurrentEntry.consolidateFields(); + for (EntryHandler entryHandler : mEntryHandlers) { + entryHandler.onEntryCreated(mCurrentEntry); + } + + final int size = mEntryStack.size(); + if (size > 1) { + CustomVCardEntry parent = mEntryStack.get(size - 2); + parent.addChild(mCurrentEntry); + mCurrentEntry = parent; + } else { + mCurrentEntry = null; + } + mEntryStack.remove(size - 1); + } + + @Override + public void onPropertyCreated(VCardProperty property) { + mCurrentEntry.addProperty(property); + } +}
\ No newline at end of file diff --git a/src/com/android/messaging/datamodel/media/DecodedImageResource.java b/src/com/android/messaging/datamodel/media/DecodedImageResource.java new file mode 100644 index 0000000..3627ba4 --- /dev/null +++ b/src/com/android/messaging/datamodel/media/DecodedImageResource.java @@ -0,0 +1,254 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel.media; + +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.drawable.Drawable; + +import com.android.messaging.ui.OrientedBitmapDrawable; +import com.android.messaging.util.Assert; +import com.android.messaging.util.Assert.DoesNotRunOnMainThread; +import com.android.messaging.util.ImageUtils; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.OsUtil; + +import java.util.List; + + +/** + * Container class for holding a bitmap resource used by the MediaResourceManager. This resource + * can both be cached (albeit not very storage-efficiently) and directly used by the UI. + */ +public class DecodedImageResource extends ImageResource { + private static final int BITMAP_QUALITY = 100; + private static final int COMPRESS_QUALITY = 50; + + private Bitmap mBitmap; + private final int mOrientation; + private boolean mCacheable = true; + + public DecodedImageResource(final String key, final Bitmap bitmap, int orientation) { + super(key, orientation); + mBitmap = bitmap; + mOrientation = orientation; + } + + /** + * Gets the contained bitmap. + */ + @Override + public Bitmap getBitmap() { + acquireLock(); + try { + return mBitmap; + } finally { + releaseLock(); + } + } + + /** + * Attempt to reuse the bitmap in the image resource and repurpose it for something else. + * After this, the image resource will relinquish ownership on the bitmap resource so that + * it doesn't try to recycle it when getting closed. + */ + @Override + public Bitmap reuseBitmap() { + acquireLock(); + try { + assertSingularRefCount(); + final Bitmap retBitmap = mBitmap; + mBitmap = null; + return retBitmap; + } finally { + releaseLock(); + } + } + + @Override + public boolean supportsBitmapReuse() { + return true; + } + + @Override + public byte[] getBytes() { + acquireLock(); + try { + return ImageUtils.bitmapToBytes(mBitmap, BITMAP_QUALITY); + } catch (final Exception e) { + LogUtil.e(LogUtil.BUGLE_TAG, "Error trying to get the bitmap bytes " + e); + } finally { + releaseLock(); + } + return null; + } + + /** + * Gets the orientation of the image as one of the ExifInterface.ORIENTATION_* constants + */ + @Override + public int getOrientation() { + return mOrientation; + } + + @Override + public int getMediaSize() { + acquireLock(); + try { + Assert.notNull(mBitmap); + if (OsUtil.isAtLeastKLP()) { + return mBitmap.getAllocationByteCount(); + } else { + return mBitmap.getRowBytes() * mBitmap.getHeight(); + } + } finally { + releaseLock(); + } + } + + @Override + protected void close() { + acquireLock(); + try { + if (mBitmap != null) { + mBitmap.recycle(); + mBitmap = null; + } + } finally { + releaseLock(); + } + } + + @Override + public Drawable getDrawable(Resources resources) { + acquireLock(); + try { + Assert.notNull(mBitmap); + return OrientedBitmapDrawable.create(getOrientation(), resources, mBitmap); + } finally { + releaseLock(); + } + } + + @Override + boolean isCacheable() { + return mCacheable; + } + + public void setCacheable(final boolean cacheable) { + mCacheable = cacheable; + } + + @SuppressWarnings("unchecked") + @Override + MediaRequest<? extends RefCountedMediaResource> getMediaEncodingRequest( + final MediaRequest<? extends RefCountedMediaResource> originalRequest) { + Assert.isFalse(isEncoded()); + if (getBitmap().hasAlpha()) { + // We can't compress images with alpha, as JPEG encoding doesn't support this. + return null; + } + return new EncodeImageRequest((MediaRequest<ImageResource>) originalRequest); + } + + /** + * A MediaRequest that encodes the contained image resource. + */ + private class EncodeImageRequest implements MediaRequest<ImageResource> { + private final MediaRequest<ImageResource> mOriginalImageRequest; + + public EncodeImageRequest(MediaRequest<ImageResource> originalImageRequest) { + mOriginalImageRequest = originalImageRequest; + // Hold a ref onto the encoded resource before the request finishes. + DecodedImageResource.this.addRef(); + } + + @Override + public String getKey() { + return DecodedImageResource.this.getKey(); + } + + @Override + @DoesNotRunOnMainThread + public ImageResource loadMediaBlocking(List<MediaRequest<ImageResource>> chainedRequests) + throws Exception { + Assert.isNotMainThread(); + acquireLock(); + Bitmap scaledBitmap = null; + try { + Bitmap bitmap = getBitmap(); + Assert.isFalse(bitmap.hasAlpha()); + final int bitmapWidth = bitmap.getWidth(); + final int bitmapHeight = bitmap.getHeight(); + // The original bitmap was loaded using sub-sampling which was fast in terms of + // loading speed, but not optimized for caching, encoding and rendering (since + // bitmap resizing to fit the UI image views happens on the UI thread and should + // be avoided if possible). Therefore, try to resize the bitmap to the exact desired + // size before compressing it. + if (bitmapWidth > 0 && bitmapHeight > 0 && + mOriginalImageRequest instanceof ImageRequest<?>) { + final ImageRequestDescriptor descriptor = + ((ImageRequest<?>) mOriginalImageRequest).getDescriptor(); + final float targetScale = Math.max( + (float) descriptor.desiredWidth / bitmapWidth, + (float) descriptor.desiredHeight / bitmapHeight); + final int targetWidth = (int) (bitmapWidth * targetScale); + final int targetHeight = (int) (bitmapHeight * targetScale); + // Only try to scale down the image to the desired size. + if (targetScale < 1.0f && targetWidth > 0 && targetHeight > 0 && + targetWidth != bitmapWidth && targetHeight != bitmapHeight) { + scaledBitmap = bitmap = + Bitmap.createScaledBitmap(bitmap, targetWidth, targetHeight, false); + } + } + byte[] encodedBytes = ImageUtils.bitmapToBytes(bitmap, COMPRESS_QUALITY); + return new EncodedImageResource(getKey(), encodedBytes, getOrientation()); + } catch (Exception ex) { + // Something went wrong during bitmap compression, fall back to just using the + // original bitmap. + LogUtil.e(LogUtil.BUGLE_IMAGE_TAG, "Error compressing bitmap", ex); + return DecodedImageResource.this; + } finally { + if (scaledBitmap != null && scaledBitmap != getBitmap()) { + scaledBitmap.recycle(); + scaledBitmap = null; + } + releaseLock(); + release(); + } + } + + @Override + public MediaCache<ImageResource> getMediaCache() { + return mOriginalImageRequest.getMediaCache(); + } + + @Override + public int getCacheId() { + return mOriginalImageRequest.getCacheId(); + } + + @Override + public int getRequestType() { + return REQUEST_ENCODE_MEDIA; + } + + @Override + public MediaRequestDescriptor<ImageResource> getDescriptor() { + return mOriginalImageRequest.getDescriptor(); + } + } +} diff --git a/src/com/android/messaging/datamodel/media/EncodedImageResource.java b/src/com/android/messaging/datamodel/media/EncodedImageResource.java new file mode 100644 index 0000000..0bc94e5 --- /dev/null +++ b/src/com/android/messaging/datamodel/media/EncodedImageResource.java @@ -0,0 +1,162 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel.media; + +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.drawable.Drawable; + +import com.android.messaging.util.Assert; +import com.android.messaging.util.Assert.DoesNotRunOnMainThread; + +import java.util.Arrays; +import java.util.List; + +/** + * A cache-facing image resource that's much more compact than the raw Bitmap objects stored in + * {@link com.android.messaging.datamodel.media.DecodedImageResource}. + * + * This resource is created from a regular Bitmap-based ImageResource before being pushed to + * {@link com.android.messaging.datamodel.media.MediaCache}, if the image request + * allows for resource encoding/compression. + * + * During resource retrieval on cache hit, + * {@link #getMediaDecodingRequest(MediaRequest)} is invoked to create a async + * decode task, which decodes the compressed byte array back to a regular image resource to + * be consumed by the UI. + */ +public class EncodedImageResource extends ImageResource { + private final byte[] mImageBytes; + + public EncodedImageResource(String key, byte[] imageBytes, int orientation) { + super(key, orientation); + mImageBytes = imageBytes; + } + + @Override + @DoesNotRunOnMainThread + public Bitmap getBitmap() { + acquireLock(); + try { + // This should only be called during the decode request. + Assert.isNotMainThread(); + return BitmapFactory.decodeByteArray(mImageBytes, 0, mImageBytes.length); + } finally { + releaseLock(); + } + } + + @Override + public byte[] getBytes() { + acquireLock(); + try { + return Arrays.copyOf(mImageBytes, mImageBytes.length); + } finally { + releaseLock(); + } + } + + @Override + public Bitmap reuseBitmap() { + return null; + } + + @Override + public boolean supportsBitmapReuse() { + return false; + } + + @Override + public int getMediaSize() { + return mImageBytes.length; + } + + @Override + protected void close() { + } + + @Override + public Drawable getDrawable(Resources resources) { + return null; + } + + @Override + boolean isEncoded() { + return true; + } + + @Override + MediaRequest<? extends RefCountedMediaResource> getMediaDecodingRequest( + final MediaRequest<? extends RefCountedMediaResource> originalRequest) { + Assert.isTrue(isEncoded()); + return new DecodeImageRequest(); + } + + /** + * A MediaRequest that decodes the encoded image resource. This class is chained to the + * original media request that requested the image, so it inherits the listener and + * properties such as binding. + */ + private class DecodeImageRequest implements MediaRequest<ImageResource> { + public DecodeImageRequest() { + // Hold a ref onto the encoded resource before the request finishes. + addRef(); + } + + @Override + public String getKey() { + return EncodedImageResource.this.getKey(); + } + + @Override + @DoesNotRunOnMainThread + public ImageResource loadMediaBlocking(List<MediaRequest<ImageResource>> chainedTask) + throws Exception { + Assert.isNotMainThread(); + acquireLock(); + try { + final Bitmap decodedBitmap = BitmapFactory.decodeByteArray(mImageBytes, 0, + mImageBytes.length); + return new DecodedImageResource(getKey(), decodedBitmap, getOrientation()); + } finally { + releaseLock(); + release(); + } + } + + @Override + public MediaCache<ImageResource> getMediaCache() { + // Decoded resource is non-cachable, it's for UI consumption only (for now at least) + return null; + } + + @Override + public int getCacheId() { + return 0; + } + + @Override + public int getRequestType() { + return REQUEST_DECODE_MEDIA; + } + + @Override + public MediaRequestDescriptor<ImageResource> getDescriptor() { + return null; + } + } +} diff --git a/src/com/android/messaging/datamodel/media/FileImageRequest.java b/src/com/android/messaging/datamodel/media/FileImageRequest.java new file mode 100644 index 0000000..31c053a --- /dev/null +++ b/src/com/android/messaging/datamodel/media/FileImageRequest.java @@ -0,0 +1,108 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel.media; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.media.ExifInterface; + +import com.android.messaging.datamodel.media.PoolableImageCache.ReusableImageResourcePool; +import com.android.messaging.util.ImageUtils; +import com.android.messaging.util.LogUtil; + +import java.io.IOException; + +/** + * Serves file system based image requests. Since file paths can be expressed in Uri form, this + * extends regular UriImageRequest but performs additional optimizations such as loading thumbnails + * directly from Exif information. + */ +public class FileImageRequest extends UriImageRequest { + private final String mPath; + private final boolean mCanUseThumbnail; + + public FileImageRequest(final Context context, + final FileImageRequestDescriptor descriptor) { + super(context, descriptor); + mPath = descriptor.path; + mCanUseThumbnail = descriptor.canUseThumbnail; + } + + @Override + protected Bitmap loadBitmapInternal() + throws IOException { + // Before using the FileInputStream, check if the Exif has a thumbnail that we can use. + if (mCanUseThumbnail) { + byte[] thumbnail = null; + try { + final ExifInterface exif = new ExifInterface(mPath); + if (exif.hasThumbnail()) { + thumbnail = exif.getThumbnail(); + } + } catch (final IOException e) { + // Nothing to do + } + + if (thumbnail != null) { + final BitmapFactory.Options options = PoolableImageCache.getBitmapOptionsForPool( + false /* scaled */, 0 /* inputDensity */, 0 /* targetDensity */); + // First, check dimensions of the bitmap. + options.inJustDecodeBounds = true; + BitmapFactory.decodeByteArray(thumbnail, 0, thumbnail.length, options); + + // Calculate inSampleSize + options.inSampleSize = ImageUtils.get().calculateInSampleSize(options, + mDescriptor.desiredWidth, mDescriptor.desiredHeight); + + options.inJustDecodeBounds = false; + + // Actually decode the bitmap, optionally using the bitmap pool. + try { + // Get the orientation. We should be able to get the orientation from + // the thumbnail itself but at least on some phones, the thumbnail + // doesn't have an orientation tag. So use the outer image's orientation + // tag and hope for the best. + mOrientation = ImageUtils.getOrientation(getInputStreamForResource()); + if (com.android.messaging.util.exif.ExifInterface. + getOrientationParams(mOrientation).invertDimensions) { + mDescriptor.updateSourceDimensions(options.outHeight, options.outWidth); + } else { + mDescriptor.updateSourceDimensions(options.outWidth, options.outHeight); + } + final ReusableImageResourcePool bitmapPool = getBitmapPool(); + if (bitmapPool == null) { + return BitmapFactory.decodeByteArray(thumbnail, 0, thumbnail.length, + options); + } else { + final int sampledWidth = options.outWidth / options.inSampleSize; + final int sampledHeight = options.outHeight / options.inSampleSize; + return bitmapPool.decodeByteArray(thumbnail, options, sampledWidth, + sampledHeight); + } + } catch (IOException ex) { + // If the thumbnail is broken due to IOException, this will + // fall back to default bitmap loading. + LogUtil.e(LogUtil.BUGLE_IMAGE_TAG, "FileImageRequest: failed to load " + + "thumbnail from Exif", ex); + } + } + } + + // Fall back to default InputStream-based loading if no thumbnails could be retrieved. + return super.loadBitmapInternal(); + } +} diff --git a/src/com/android/messaging/datamodel/media/FileImageRequestDescriptor.java b/src/com/android/messaging/datamodel/media/FileImageRequestDescriptor.java new file mode 100644 index 0000000..00105f5 --- /dev/null +++ b/src/com/android/messaging/datamodel/media/FileImageRequestDescriptor.java @@ -0,0 +1,68 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel.media; + +import android.content.Context; + +import com.android.messaging.util.ImageUtils; +import com.android.messaging.util.UriUtil; + +/** + * Holds image request info about file system based image resource. + */ +public class FileImageRequestDescriptor extends UriImageRequestDescriptor { + public final String path; + + // Can we use the thumbnail image from Exif data? + public final boolean canUseThumbnail; + + /** + * Convenience constructor for when the image file's dimensions are not known. + */ + public FileImageRequestDescriptor(final String path, final int desiredWidth, + final int desiredHeight, final boolean canUseThumbnail, final boolean canCompress, + final boolean isStatic) { + this(path, desiredWidth, desiredHeight, FileImageRequest.UNSPECIFIED_SIZE, + FileImageRequest.UNSPECIFIED_SIZE, canUseThumbnail, canCompress, isStatic); + } + + /** + * Creates a new file image request with this descriptor. Oftentimes image file metadata + * has information such as the size of the image. Provide these metrics if they are known. + */ + public FileImageRequestDescriptor(final String path, final int desiredWidth, + final int desiredHeight, final int sourceWidth, final int sourceHeight, + final boolean canUseThumbnail, final boolean canCompress, final boolean isStatic) { + super(UriUtil.getUriForResourceFile(path), desiredWidth, desiredHeight, sourceWidth, + sourceHeight, canCompress, isStatic, false /* cropToCircle */, + ImageUtils.DEFAULT_CIRCLE_BACKGROUND_COLOR /* circleBackgroundColor */, + ImageUtils.DEFAULT_CIRCLE_STROKE_COLOR /* circleStrokeColor */); + this.path = path; + this.canUseThumbnail = canUseThumbnail; + } + + @Override + public String getKey() { + final String prefixKey = super.getKey(); + return prefixKey == null ? null : new StringBuilder(prefixKey).append(KEY_PART_DELIMITER) + .append(canUseThumbnail).toString(); + } + + @Override + public MediaRequest<ImageResource> buildSyncMediaRequest(final Context context) { + return new FileImageRequest(context, this); + } +}
\ No newline at end of file diff --git a/src/com/android/messaging/datamodel/media/GifImageResource.java b/src/com/android/messaging/datamodel/media/GifImageResource.java new file mode 100644 index 0000000..d50cf47 --- /dev/null +++ b/src/com/android/messaging/datamodel/media/GifImageResource.java @@ -0,0 +1,110 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel.media; + +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.drawable.Drawable; +import android.media.ExifInterface; +import android.support.rastermill.FrameSequence; +import android.support.rastermill.FrameSequenceDrawable; + +import com.android.messaging.util.Assert; + +import java.io.IOException; +import java.io.InputStream; + +public class GifImageResource extends ImageResource { + private FrameSequence mFrameSequence; + + public GifImageResource(String key, FrameSequence frameSequence) { + // GIF does not support exif tags + super(key, ExifInterface.ORIENTATION_NORMAL); + mFrameSequence = frameSequence; + } + + public static GifImageResource createGifImageResource(String key, InputStream inputStream) { + final FrameSequence frameSequence; + try { + frameSequence = FrameSequence.decodeStream(inputStream); + } finally { + try { + inputStream.close(); + } catch (IOException e) { + // Nothing to do if we fail closing the stream + } + } + if (frameSequence == null) { + return null; + } + return new GifImageResource(key, frameSequence); + } + + @Override + public Drawable getDrawable(Resources resources) { + return new FrameSequenceDrawable(mFrameSequence); + } + + @Override + public Bitmap getBitmap() { + Assert.fail("GetBitmap() should never be called on a gif."); + return null; + } + + @Override + public byte[] getBytes() { + Assert.fail("GetBytes() should never be called on a gif."); + return null; + } + + @Override + public Bitmap reuseBitmap() { + return null; + } + + @Override + public boolean supportsBitmapReuse() { + // FrameSequenceDrawable a.) takes two bitmaps and thus does not fit into the current + // bitmap pool architecture b.) will rarely use bitmaps from one FrameSequenceDrawable to + // the next that are the same sizes since they are used by attachments. + return false; + } + + @Override + public int getMediaSize() { + Assert.fail("GifImageResource should not be used by a media cache"); + // Only used by the media cache, which this does not use. + return 0; + } + + @Override + public boolean isCacheable() { + return false; + } + + @Override + protected void close() { + acquireLock(); + try { + if (mFrameSequence != null) { + mFrameSequence = null; + } + } finally { + releaseLock(); + } + } + +} diff --git a/src/com/android/messaging/datamodel/media/ImageRequest.java b/src/com/android/messaging/datamodel/media/ImageRequest.java new file mode 100644 index 0000000..ab8880d --- /dev/null +++ b/src/com/android/messaging/datamodel/media/ImageRequest.java @@ -0,0 +1,258 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel.media; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.RectF; + +import com.android.messaging.datamodel.data.MessagePartData; +import com.android.messaging.datamodel.media.PoolableImageCache.ReusableImageResourcePool; +import com.android.messaging.util.Assert; +import com.android.messaging.util.ImageUtils; +import com.android.messaging.util.exif.ExifInterface; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.util.List; + +/** + * Base class that serves an image request for resolving, retrieving and decoding bitmap resources. + * + * Subclasses may choose to load images from different medium, such as from the file system or + * from the local content resolver, by overriding the abstract getInputStreamForResource() method. + */ +public abstract class ImageRequest<D extends ImageRequestDescriptor> + implements MediaRequest<ImageResource> { + public static final int UNSPECIFIED_SIZE = MessagePartData.UNSPECIFIED_SIZE; + + protected final Context mContext; + protected final D mDescriptor; + protected int mOrientation; + + /** + * Creates a new image request with the given descriptor. + */ + public ImageRequest(final Context context, final D descriptor) { + mContext = context; + mDescriptor = descriptor; + } + + /** + * Gets a key that uniquely identify the underlying image resource to be loaded (e.g. Uri or + * file path). + */ + @Override + public String getKey() { + return mDescriptor.getKey(); + } + + /** + * Returns the image request descriptor attached to this request. + */ + @Override + public D getDescriptor() { + return mDescriptor; + } + + @Override + public int getRequestType() { + return MediaRequest.REQUEST_LOAD_MEDIA; + } + + /** + * Allows sub classes to specify that they want us to call getBitmapForResource rather than + * getInputStreamForResource + */ + protected boolean hasBitmapObject() { + return false; + } + + protected Bitmap getBitmapForResource() throws IOException { + return null; + } + + /** + * Retrieves an input stream from which image resource could be loaded. + * @throws FileNotFoundException + */ + protected abstract InputStream getInputStreamForResource() throws FileNotFoundException; + + /** + * Loads the image resource. This method is final; to override the media loading behavior + * the subclass should override {@link #loadMediaInternal(List)} + */ + @Override + public final ImageResource loadMediaBlocking(List<MediaRequest<ImageResource>> chainedTask) + throws IOException { + Assert.isNotMainThread(); + final ImageResource loadedResource = loadMediaInternal(chainedTask); + return postProcessOnBitmapResourceLoaded(loadedResource); + } + + protected ImageResource loadMediaInternal(List<MediaRequest<ImageResource>> chainedTask) + throws IOException { + if (!mDescriptor.isStatic() && isGif()) { + final GifImageResource gifImageResource = + GifImageResource.createGifImageResource(getKey(), getInputStreamForResource()); + if (gifImageResource == null) { + throw new RuntimeException("Error decoding gif"); + } + return gifImageResource; + } else { + final Bitmap loadedBitmap = loadBitmapInternal(); + if (loadedBitmap == null) { + throw new RuntimeException("failed decoding bitmap"); + } + return new DecodedImageResource(getKey(), loadedBitmap, mOrientation); + } + } + + protected boolean isGif() throws FileNotFoundException { + return ImageUtils.isGif(getInputStreamForResource()); + } + + /** + * The internal routine for loading the image. The caller may optionally provide the width + * and height of the source image if known so that we don't need to manually decode those. + */ + protected Bitmap loadBitmapInternal() throws IOException { + + final boolean unknownSize = mDescriptor.sourceWidth == UNSPECIFIED_SIZE || + mDescriptor.sourceHeight == UNSPECIFIED_SIZE; + + // If the ImageRequest has a Bitmap object rather than a stream, there's little to do here + if (hasBitmapObject()) { + final Bitmap bitmap = getBitmapForResource(); + if (bitmap != null && unknownSize) { + mDescriptor.updateSourceDimensions(bitmap.getWidth(), bitmap.getHeight()); + } + return bitmap; + } + + mOrientation = ImageUtils.getOrientation(getInputStreamForResource()); + + final BitmapFactory.Options options = PoolableImageCache.getBitmapOptionsForPool( + false /* scaled */, 0 /* inputDensity */, 0 /* targetDensity */); + // First, check dimensions of the bitmap if not already known. + if (unknownSize) { + final InputStream inputStream = getInputStreamForResource(); + if (inputStream != null) { + try { + options.inJustDecodeBounds = true; + BitmapFactory.decodeStream(inputStream, null, options); + // This is called when dimensions of image were unknown to allow db update + if (ExifInterface.getOrientationParams(mOrientation).invertDimensions) { + mDescriptor.updateSourceDimensions(options.outHeight, options.outWidth); + } else { + mDescriptor.updateSourceDimensions(options.outWidth, options.outHeight); + } + } finally { + inputStream.close(); + } + } else { + throw new FileNotFoundException(); + } + } else { + options.outWidth = mDescriptor.sourceWidth; + options.outHeight = mDescriptor.sourceHeight; + } + + // Calculate inSampleSize + options.inSampleSize = ImageUtils.get().calculateInSampleSize(options, + mDescriptor.desiredWidth, mDescriptor.desiredHeight); + Assert.isTrue(options.inSampleSize > 0); + + // Reopen the input stream and actually decode the bitmap. The initial + // BitmapFactory.decodeStream() reads the header portion of the bitmap stream and leave + // the input stream at the last read position. Since this input stream doesn't support + // mark() and reset(), the only viable way to reload the input stream is to re-open it. + // Alternatively, we could decode the bitmap into a byte array first and act on the byte + // array, but that also means the entire bitmap (for example a 10MB image from the gallery) + // without downsampling will have to be loaded into memory up front, which we don't want + // as it gives a much bigger possibility of OOM when handling big images. Therefore, the + // solution here is to close and reopen the bitmap input stream. + // For inline images the size is cached in DB and this hit is only taken once per image + final InputStream inputStream = getInputStreamForResource(); + if (inputStream != null) { + try { + options.inJustDecodeBounds = false; + + // Actually decode the bitmap, optionally using the bitmap pool. + final ReusableImageResourcePool bitmapPool = getBitmapPool(); + if (bitmapPool == null) { + return BitmapFactory.decodeStream(inputStream, null, options); + } else { + final int sampledWidth = (options.outWidth + options.inSampleSize - 1) / + options.inSampleSize; + final int sampledHeight = (options.outHeight + options.inSampleSize - 1) / + options.inSampleSize; + return bitmapPool.decodeSampledBitmapFromInputStream( + inputStream, options, sampledWidth, sampledHeight); + } + } finally { + inputStream.close(); + } + } else { + throw new FileNotFoundException(); + } + } + + private ImageResource postProcessOnBitmapResourceLoaded(final ImageResource loadedResource) { + if (mDescriptor.cropToCircle && loadedResource instanceof DecodedImageResource) { + final int width = mDescriptor.desiredWidth; + final int height = mDescriptor.desiredHeight; + final Bitmap sourceBitmap = loadedResource.getBitmap(); + final Bitmap targetBitmap = getBitmapPool().createOrReuseBitmap(width, height); + final RectF dest = new RectF(0, 0, width, height); + final RectF source = new RectF(0, 0, sourceBitmap.getWidth(), sourceBitmap.getHeight()); + final int backgroundColor = mDescriptor.circleBackgroundColor; + final int strokeColor = mDescriptor.circleStrokeColor; + ImageUtils.drawBitmapWithCircleOnCanvas(sourceBitmap, new Canvas(targetBitmap), source, + dest, null, backgroundColor == 0 ? false : true /* fillBackground */, + backgroundColor, strokeColor); + return new DecodedImageResource(getKey(), targetBitmap, + loadedResource.getOrientation()); + } + return loadedResource; + } + + /** + * Returns the bitmap pool for this image request. + */ + protected ReusableImageResourcePool getBitmapPool() { + return MediaCacheManager.get().getOrCreateBitmapPoolForCache(getCacheId()); + } + + @SuppressWarnings("unchecked") + @Override + public MediaCache<ImageResource> getMediaCache() { + return (MediaCache<ImageResource>) MediaCacheManager.get().getOrCreateMediaCacheById( + getCacheId()); + } + + /** + * Returns the cache id. Subclasses may override this to use a different cache. + */ + @Override + public int getCacheId() { + return BugleMediaCacheManager.DEFAULT_IMAGE_CACHE; + } +} diff --git a/src/com/android/messaging/datamodel/media/ImageRequestDescriptor.java b/src/com/android/messaging/datamodel/media/ImageRequestDescriptor.java new file mode 100644 index 0000000..20cb9af --- /dev/null +++ b/src/com/android/messaging/datamodel/media/ImageRequestDescriptor.java @@ -0,0 +1,113 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel.media; + +import android.content.Context; + +import com.android.messaging.util.Assert; + +/** + * The base ImageRequest descriptor that describes the requirement of the requested image + * resource, including the desired size. It holds request info that will be consumed by + * ImageRequest instances. Subclasses of ImageRequest are expected to take + * more descriptions such as content URI or file path. + */ +public abstract class ImageRequestDescriptor extends MediaRequestDescriptor<ImageResource> { + /** Desired size for the image (if known). This is used for bitmap downsampling */ + public final int desiredWidth; + public final int desiredHeight; + + /** Source size of the image (if known). This is used so that we don't have to manually decode + * the metrics from the image resource */ + public final int sourceWidth; + public final int sourceHeight; + + /** + * A static image resource is required, even if the image format supports animation (like Gif). + */ + public final boolean isStatic; + + /** + * The loaded image will be cropped to circular shape. + */ + public final boolean cropToCircle; + + /** + * The loaded image will be cropped to circular shape with the background color. + */ + public final int circleBackgroundColor; + + /** + * The loaded image will be cropped to circular shape with a stroke color. + */ + public final int circleStrokeColor; + + protected static final char KEY_PART_DELIMITER = '|'; + + /** + * Creates a new image request with unspecified width and height. In this case, the full + * bitmap is loaded and decoded, so unless you are sure that the image will be of + * reasonable size, you should consider limiting at least one of the two dimensions + * (for example, limiting the image width to the width of the ImageView container). + */ + public ImageRequestDescriptor() { + this(ImageRequest.UNSPECIFIED_SIZE, ImageRequest.UNSPECIFIED_SIZE, + ImageRequest.UNSPECIFIED_SIZE, ImageRequest.UNSPECIFIED_SIZE, false, false, 0, 0); + } + + public ImageRequestDescriptor(final int desiredWidth, final int desiredHeight) { + this(desiredWidth, desiredHeight, + ImageRequest.UNSPECIFIED_SIZE, ImageRequest.UNSPECIFIED_SIZE, false, false, 0, 0); + } + + public ImageRequestDescriptor(final int desiredWidth, + final int desiredHeight, final int sourceWidth, final int sourceHeight, + final boolean isStatic, final boolean cropToCircle, final int circleBackgroundColor, + int circleStrokeColor) { + Assert.isTrue(desiredWidth == ImageRequest.UNSPECIFIED_SIZE || desiredWidth > 0); + Assert.isTrue(desiredHeight == ImageRequest.UNSPECIFIED_SIZE || desiredHeight > 0); + Assert.isTrue(sourceWidth == ImageRequest.UNSPECIFIED_SIZE || sourceWidth > 0); + Assert.isTrue(sourceHeight == ImageRequest.UNSPECIFIED_SIZE || sourceHeight > 0); + this.desiredWidth = desiredWidth; + this.desiredHeight = desiredHeight; + this.sourceWidth = sourceWidth; + this.sourceHeight = sourceHeight; + this.isStatic = isStatic; + this.cropToCircle = cropToCircle; + this.circleBackgroundColor = circleBackgroundColor; + this.circleStrokeColor = circleStrokeColor; + } + + public String getKey() { + return new StringBuilder() + .append(desiredWidth).append(KEY_PART_DELIMITER) + .append(desiredHeight).append(KEY_PART_DELIMITER) + .append(String.valueOf(cropToCircle)).append(KEY_PART_DELIMITER) + .append(String.valueOf(circleBackgroundColor)).append(KEY_PART_DELIMITER) + .append(String.valueOf(isStatic)).toString(); + } + + public boolean isStatic() { + return isStatic; + } + + @Override + public abstract MediaRequest<ImageResource> buildSyncMediaRequest(Context context); + + // Called once source dimensions finally determined upon loading the image + public void updateSourceDimensions(final int sourceWidth, final int sourceHeight) { + } +}
\ No newline at end of file diff --git a/src/com/android/messaging/datamodel/media/ImageResource.java b/src/com/android/messaging/datamodel/media/ImageResource.java new file mode 100644 index 0000000..75d817d --- /dev/null +++ b/src/com/android/messaging/datamodel/media/ImageResource.java @@ -0,0 +1,63 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel.media; + +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.drawable.Drawable; + +/** + * Base class for holding some form of image resource. The subclass gets to define the specific + * type of data format it's holding, whether it be Bitmap objects or compressed byte arrays. + */ +public abstract class ImageResource extends RefCountedMediaResource { + protected final int mOrientation; + + public ImageResource(final String key, int orientation) { + super(key); + mOrientation = orientation; + } + + /** + * Gets the contained image in drawable format. + */ + public abstract Drawable getDrawable(Resources resources); + + /** + * Gets the contained image in bitmap format. + */ + public abstract Bitmap getBitmap(); + + /** + * Gets the contained image in byte array format. + */ + public abstract byte[] getBytes(); + + /** + * Attempt to reuse the bitmap in the image resource and re-purpose it for something else. + * After this, the image resource will relinquish ownership on the bitmap resource so that + * it doesn't try to recycle it when getting closed. + */ + public abstract Bitmap reuseBitmap(); + public abstract boolean supportsBitmapReuse(); + + /** + * Gets the orientation of the image as one of the ExifInterface.ORIENTATION_* constants + */ + public int getOrientation() { + return mOrientation; + } +} diff --git a/src/com/android/messaging/datamodel/media/MediaBytes.java b/src/com/android/messaging/datamodel/media/MediaBytes.java new file mode 100644 index 0000000..823bf27 --- /dev/null +++ b/src/com/android/messaging/datamodel/media/MediaBytes.java @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel.media; + + +/** + * Container class for handing around media information used by the MediaResourceManager. + */ +public class MediaBytes extends RefCountedMediaResource { + private final byte[] mBytes; + + public MediaBytes(final String key, final byte[] bytes) { + super(key); + mBytes = bytes; + } + + public byte[] getMediaBytes() { + return mBytes; + } + + @Override + public int getMediaSize() { + return mBytes.length; + } + + @Override + protected void close() { + } +} diff --git a/src/com/android/messaging/datamodel/media/MediaCache.java b/src/com/android/messaging/datamodel/media/MediaCache.java new file mode 100644 index 0000000..510da2d --- /dev/null +++ b/src/com/android/messaging/datamodel/media/MediaCache.java @@ -0,0 +1,113 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel.media; + +import android.util.LruCache; + +import com.android.messaging.util.LogUtil; + +/** + * A modified LruCache that is able to hold RefCountedMediaResource instances. It releases + * ref on the entries as they are evicted from the cache, and it uses the media resource + * size in kilobytes, instead of the entry count, as the size of the cache. + * + * This class is used by the MediaResourceManager class to maintain a number of caches for + * holding different types of {@link RefCountedMediaResource} + */ +public class MediaCache<T extends RefCountedMediaResource> extends LruCache<String, T> { + private static final String TAG = LogUtil.BUGLE_IMAGE_TAG; + + // Default memory cache size in kilobytes + protected static final int DEFAULT_MEDIA_RESOURCE_CACHE_SIZE_IN_KILOBYTES = 1024 * 5; // 5MB + + // Unique identifier for the cache. + private final int mId; + // Descriptive name given to the cache for debugging purposes. + private final String mName; + + // Convenience constructor that uses the default cache size. + public MediaCache(final int id, final String name) { + this(DEFAULT_MEDIA_RESOURCE_CACHE_SIZE_IN_KILOBYTES, id, name); + } + + public MediaCache(final int maxSize, final int id, final String name) { + super(maxSize); + mId = id; + mName = name; + } + + public void destroy() { + evictAll(); + } + + public String getName() { + return mName; + } + + public int getId() { + return mId; + } + + /** + * Gets a media resource from this cache. Must use this method to get resource instead of get() + * to ensure addRef() on the resource. + */ + public synchronized T fetchResourceFromCache(final String key) { + final T ret = get(key); + if (ret != null) { + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "cache hit in mediaCache @ " + getName() + + ", total cache hit = " + hitCount() + + ", total cache miss = " + missCount()); + } + ret.addRef(); + } else if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "cache miss in mediaCache @ " + getName() + + ", total cache hit = " + hitCount() + + ", total cache miss = " + missCount()); + } + return ret; + } + + /** + * Add a media resource to this cache. Must use this method to add resource instead of put() + * to ensure addRef() on the resource. + */ + public synchronized T addResourceToCache(final String key, final T mediaResource) { + mediaResource.addRef(); + return put(key, mediaResource); + } + + /** + * Notify the removed entry that is no longer being cached + */ + @Override + protected synchronized void entryRemoved(final boolean evicted, final String key, + final T oldValue, final T newValue) { + oldValue.release(); + } + + /** + * Measure item size in kilobytes rather than units which is more practical + * for a media resource cache + */ + @Override + protected int sizeOf(final String key, final T value) { + final int mediaSizeInKilobytes = value.getMediaSize() / 1024; + // Never zero-count any resource, count as at least 1KB. + return mediaSizeInKilobytes == 0 ? 1 : mediaSizeInKilobytes; + } +}
\ No newline at end of file diff --git a/src/com/android/messaging/datamodel/media/MediaCacheManager.java b/src/com/android/messaging/datamodel/media/MediaCacheManager.java new file mode 100644 index 0000000..6e029f2 --- /dev/null +++ b/src/com/android/messaging/datamodel/media/MediaCacheManager.java @@ -0,0 +1,69 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel.media; + +import android.util.SparseArray; + +import com.android.messaging.Factory; +import com.android.messaging.datamodel.MemoryCacheManager; +import com.android.messaging.datamodel.MemoryCacheManager.MemoryCache; +import com.android.messaging.datamodel.media.PoolableImageCache.ReusableImageResourcePool; + +/** + * Manages a set of media caches by id. + */ +public abstract class MediaCacheManager implements MemoryCache { + public static MediaCacheManager get() { + return Factory.get().getMediaCacheManager(); + } + + protected final SparseArray<MediaCache<?>> mCaches; + + public MediaCacheManager() { + mCaches = new SparseArray<MediaCache<?>>(); + MemoryCacheManager.get().registerMemoryCache(this); + } + + @Override + public void reclaim() { + final int count = mCaches.size(); + for (int i = 0; i < count; i++) { + mCaches.valueAt(i).destroy(); + } + mCaches.clear(); + } + + public synchronized MediaCache<?> getOrCreateMediaCacheById(final int id) { + MediaCache<?> cache = mCaches.get(id); + if (cache == null) { + cache = createMediaCacheById(id); + if (cache != null) { + mCaches.put(id, cache); + } + } + return cache; + } + + public ReusableImageResourcePool getOrCreateBitmapPoolForCache(final int cacheId) { + final MediaCache<?> cache = getOrCreateMediaCacheById(cacheId); + if (cache != null && cache instanceof PoolableImageCache) { + return ((PoolableImageCache) cache).asReusableBitmapPool(); + } + return null; + } + + protected abstract MediaCache<?> createMediaCacheById(final int id); +}
\ No newline at end of file diff --git a/src/com/android/messaging/datamodel/media/MediaRequest.java b/src/com/android/messaging/datamodel/media/MediaRequest.java new file mode 100644 index 0000000..703671b --- /dev/null +++ b/src/com/android/messaging/datamodel/media/MediaRequest.java @@ -0,0 +1,70 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel.media; + +import java.util.List; + +/** + * Keeps track of a media loading request. MediaResourceManager uses this interface to load, encode, + * decode, and cache different types of media resource. + * + * This interface defines a media request class that's threading-model-blind. Wrapper classes + * (such as {@link AsyncMediaRequestWrapper} wraps around any base media request to offer async + * extensions). + */ +public interface MediaRequest<T extends RefCountedMediaResource> { + public static final int REQUEST_ENCODE_MEDIA = 1; + public static final int REQUEST_DECODE_MEDIA = 2; + public static final int REQUEST_LOAD_MEDIA = 3; + + /** + * Returns a unique key used for storing and looking up the MediaRequest. + */ + String getKey(); + + /** + * This method performs the heavy-lifting work of synchronously loading the media bytes for + * this MediaRequest on a single threaded executor. + * @param chainedTask subsequent tasks to be performed after this request is complete. For + * example, an image request may need to compress the image resource before putting it in the + * cache + */ + T loadMediaBlocking(List<MediaRequest<T>> chainedTask) throws Exception; + + /** + * Returns the media cache where this MediaRequest wants to store the loaded + * media resource. + */ + MediaCache<T> getMediaCache(); + + /** + * Returns the id of the cache where this MediaRequest wants to store the loaded + * media resource. + */ + int getCacheId(); + + /** + * Returns the request type of this media request, i.e. one of {@link #REQUEST_ENCODE_MEDIA}, + * {@link #REQUEST_DECODE_MEDIA}, or {@link #REQUEST_LOAD_MEDIA}. The default is + * {@link #REQUEST_LOAD_MEDIA} + */ + int getRequestType(); + + /** + * Returns the descriptor defining the request. + */ + MediaRequestDescriptor<T> getDescriptor(); +}
\ No newline at end of file diff --git a/src/com/android/messaging/datamodel/media/MediaRequestDescriptor.java b/src/com/android/messaging/datamodel/media/MediaRequestDescriptor.java new file mode 100644 index 0000000..216b2a3 --- /dev/null +++ b/src/com/android/messaging/datamodel/media/MediaRequestDescriptor.java @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel.media; + +import android.content.Context; + +import com.android.messaging.datamodel.media.MediaResourceManager.MediaResourceLoadListener; + +/** + * The base data holder/builder class for constructing async/sync MediaRequest objects during + * runtime. + */ +public abstract class MediaRequestDescriptor<T extends RefCountedMediaResource> { + public abstract MediaRequest<T> buildSyncMediaRequest(Context context); + + /** + * Builds an async media request to be used with + * {@link MediaResourceManager#requestMediaResourceAsync(MediaRequest)} + */ + public BindableMediaRequest<T> buildAsyncMediaRequest(final Context context, + final MediaResourceLoadListener<T> listener) { + final MediaRequest<T> syncRequest = buildSyncMediaRequest(context); + return AsyncMediaRequestWrapper.createWith(syncRequest, listener); + } +} diff --git a/src/com/android/messaging/datamodel/media/MediaResourceManager.java b/src/com/android/messaging/datamodel/media/MediaResourceManager.java new file mode 100644 index 0000000..13f7291 --- /dev/null +++ b/src/com/android/messaging/datamodel/media/MediaResourceManager.java @@ -0,0 +1,325 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel.media; + +import android.os.AsyncTask; + +import com.android.messaging.Factory; +import com.android.messaging.util.Assert; +import com.android.messaging.util.Assert.RunsOnAnyThread; +import com.android.messaging.util.LogUtil; +import com.google.common.annotations.VisibleForTesting; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadFactory; + +/** + * <p>Loads and maintains a set of in-memory LRU caches for different types of media resources. + * Right now we don't utilize any disk cache as all media urls are expected to be resolved to + * local content.<p/> + * + * <p>The MediaResourceManager takes media loading requests through one of two ways:</p> + * + * <ol> + * <li>{@link #requestMediaResourceAsync(MediaRequest)} that takes a MediaRequest, which may be a + * regular request if the caller doesn't want to listen for events (fire-and-forget), + * or an async request wrapper if event callback is needed.</li> + * <li>{@link #requestMediaResourceSync(MediaRequest)} which takes a MediaRequest and synchronously + * returns the loaded result, or null if failed.</li> + * </ol> + * + * <p>For each media loading task, MediaResourceManager starts an AsyncTask that runs on a + * dedicated thread, which calls MediaRequest.loadMediaBlocking() to perform the actual media + * loading work. As the media resources are loaded, MediaResourceManager notifies the callers + * (which must implement the MediaResourceLoadListener interface) via onMediaResourceLoaded() + * callback. Meanwhile, MediaResourceManager also pushes the loaded resource onto its dedicated + * cache.</p> + * + * <p>The media resource caches ({@link MediaCache}) are maintained as a set of LRU caches. They are + * created on demand by the incoming MediaRequest's getCacheId() method. The implementations of + * MediaRequest (such as {@link ImageRequest}) get to determine the desired cache id. For Bugle, + * the list of available caches are in {@link BugleMediaCacheManager}</p> + * + * <p>Optionally, media loading can support on-demand media encoding and decoding. + * All {@link MediaRequest}'s can opt to chain additional {@link MediaRequest}'s to be executed + * after the completion of the main media loading task, by adding new tasks to the chained + * task list in {@link MediaRequest#loadMediaBlocking(List)}. One possible type of chained task is + * media encoding task. Loaded media will be encoded on a dedicated single threaded executor + * *after* the UI is notified of the loaded media. In this case, the encoded media resource will + * be eventually pushed to the cache, which will later be decoded before posting to the UI thread + * on cache hit.</p> + * + * <p><b>To add support for a new type of media resource,</b></p> + * + * <ol> + * <li>Create a new subclass of {@link RefCountedMediaResource} for the new resource type (example: + * {@link ImageResource} class).</li> + * + * <li>Implement the {@link MediaRequest} interface (example: {@link ImageRequest}). Perform the + * media loading work in loadMediaBlocking() and return a cache id in getCacheId().</li> + * + * <li>For the UI component that requests the media resource, let it implement + * {@link MediaResourceLoadListener} interface to listen for resource load callback. Let the + * UI component call MediaResourceManager.requestMediaResourceAsync() to request a media source. + * (example: {@link com.android.messaging.ui.ContactIconView}</li> + * </ol> + */ +public class MediaResourceManager { + private static final String TAG = LogUtil.BUGLE_TAG; + + public static MediaResourceManager get() { + return Factory.get().getMediaResourceManager(); + } + + /** + * Listener for asynchronous callback from media loading events. + */ + public interface MediaResourceLoadListener<T extends RefCountedMediaResource> { + void onMediaResourceLoaded(MediaRequest<T> request, T resource, boolean cached); + void onMediaResourceLoadError(MediaRequest<T> request, Exception exception); + } + + // We use a fixed thread pool for handling media loading tasks. Using a cached thread pool + // allows for unlimited thread creation which can lead to OOMs so we limit the threads here. + private static final Executor MEDIA_LOADING_EXECUTOR = Executors.newFixedThreadPool(10); + + // A dedicated single thread executor for performing background task after loading the resource + // on the media loading executor. This includes work such as encoding loaded media to be cached. + // These tasks are run on a single worker thread with low priority so as not to contend with the + // media loading tasks. + private static final Executor MEDIA_BACKGROUND_EXECUTOR = Executors.newSingleThreadExecutor( + new ThreadFactory() { + @Override + public Thread newThread(final Runnable runnable) { + final Thread encodingThread = new Thread(runnable); + encodingThread.setPriority(Thread.MIN_PRIORITY); + return encodingThread; + } + }); + + /** + * Requests a media resource asynchronously. Upon completion of the media loading task, + * the listener will be notified of success/failure iff it's still bound. A refcount on the + * resource is held and guaranteed for the caller for the duration of the + * {@link MediaResourceLoadListener#onMediaResourceLoaded( + * MediaRequest, RefCountedMediaResource, boolean)} callback. + * @param mediaRequest the media request. May be either an + * {@link AsyncMediaRequestWrapper} for listening for event callbacks, or a regular media + * request for fire-and-forget type of behavior. + */ + public <T extends RefCountedMediaResource> void requestMediaResourceAsync( + final MediaRequest<T> mediaRequest) { + scheduleAsyncMediaRequest(mediaRequest, MEDIA_LOADING_EXECUTOR); + } + + /** + * Requests a media resource synchronously. + * @return the loaded resource with a refcount reserved for the caller. The caller must call + * release() on the resource once it's done using it (like with Cursors). + */ + public <T extends RefCountedMediaResource> T requestMediaResourceSync( + final MediaRequest<T> mediaRequest) { + Assert.isNotMainThread(); + // Block and load media. + MediaLoadingResult<T> loadResult = null; + try { + loadResult = processMediaRequestInternal(mediaRequest); + // The loaded resource should have at least one refcount by now reserved for the caller. + Assert.isTrue(loadResult.loadedResource.getRefCount() > 0); + return loadResult.loadedResource; + } catch (final Exception e) { + LogUtil.e(LogUtil.BUGLE_TAG, "Synchronous media loading failed, key=" + + mediaRequest.getKey(), e); + return null; + } finally { + if (loadResult != null) { + // Schedule the background requests chained to the main request. + loadResult.scheduleChainedRequests(); + } + } + } + + @SuppressWarnings("unchecked") + private <T extends RefCountedMediaResource> MediaLoadingResult<T> processMediaRequestInternal( + final MediaRequest<T> mediaRequest) + throws Exception { + final List<MediaRequest<T>> chainedRequests = new ArrayList<>(); + T loadedResource = null; + // Try fetching from cache first. + final T cachedResource = loadMediaFromCache(mediaRequest); + if (cachedResource != null) { + if (cachedResource.isEncoded()) { + // The resource is encoded, issue a decoding request. + final MediaRequest<T> decodeRequest = (MediaRequest<T>) cachedResource + .getMediaDecodingRequest(mediaRequest); + Assert.notNull(decodeRequest); + cachedResource.release(); + loadedResource = loadMediaFromRequest(decodeRequest, chainedRequests); + } else { + // The resource is ready-to-use. + loadedResource = cachedResource; + } + } else { + // Actually load the media after cache miss. + loadedResource = loadMediaFromRequest(mediaRequest, chainedRequests); + } + return new MediaLoadingResult<>(loadedResource, cachedResource != null /* fromCache */, + chainedRequests); + } + + private <T extends RefCountedMediaResource> T loadMediaFromCache( + final MediaRequest<T> mediaRequest) { + if (mediaRequest.getRequestType() != MediaRequest.REQUEST_LOAD_MEDIA) { + // Only look up in the cache if we are loading media. + return null; + } + final MediaCache<T> mediaCache = mediaRequest.getMediaCache(); + if (mediaCache != null) { + final T mediaResource = mediaCache.fetchResourceFromCache(mediaRequest.getKey()); + if (mediaResource != null) { + return mediaResource; + } + } + return null; + } + + private <T extends RefCountedMediaResource> T loadMediaFromRequest( + final MediaRequest<T> mediaRequest, final List<MediaRequest<T>> chainedRequests) + throws Exception { + final T resource = mediaRequest.loadMediaBlocking(chainedRequests); + // mediaRequest.loadMediaBlocking() should never return null without + // throwing an exception. + Assert.notNull(resource); + // It's possible for the media to be evicted right after it's added to + // the cache (possibly because it's by itself too big for the cache). + // It's also possible that, after added to the cache, something else comes + // to the cache and evicts this media resource. To prevent this from + // recycling the underlying resource objects, make sure to add ref before + // adding to cache so that the caller is guaranteed a ref on the resource. + resource.addRef(); + // Don't cache the media request if it is defined as non-cacheable. + if (resource.isCacheable()) { + addResourceToMemoryCache(mediaRequest, resource); + } + return resource; + } + + /** + * Schedule an async media request on the given <code>executor</code>. + * @param mediaRequest the media request to be processed asynchronously. May be either an + * {@link AsyncMediaRequestWrapper} for listening for event callbacks, or a regular media + * request for fire-and-forget type of behavior. + */ + private <T extends RefCountedMediaResource> void scheduleAsyncMediaRequest( + final MediaRequest<T> mediaRequest, final Executor executor) { + final BindableMediaRequest<T> bindableRequest = + (mediaRequest instanceof BindableMediaRequest<?>) ? + (BindableMediaRequest<T>) mediaRequest : null; + if (bindableRequest != null && !bindableRequest.isBound()) { + return; // Request is obsolete + } + // We don't use SafeAsyncTask here since it enforces the shared thread pool executor + // whereas we want a dedicated thread pool executor. + AsyncTask<Void, Void, MediaLoadingResult<T>> mediaLoadingTask = + new AsyncTask<Void, Void, MediaLoadingResult<T>>() { + private Exception mException; + + @Override + protected MediaLoadingResult<T> doInBackground(Void... params) { + // Double check the request is still valid by the time we start processing it + if (bindableRequest != null && !bindableRequest.isBound()) { + return null; // Request is obsolete + } + try { + return processMediaRequestInternal(mediaRequest); + } catch (Exception e) { + mException = e; + return null; + } + } + + @Override + protected void onPostExecute(final MediaLoadingResult<T> result) { + if (result != null) { + Assert.isNull(mException); + Assert.isTrue(result.loadedResource.getRefCount() > 0); + try { + if (bindableRequest != null) { + bindableRequest.onMediaResourceLoaded( + bindableRequest, result.loadedResource, result.fromCache); + } + } finally { + result.loadedResource.release(); + result.scheduleChainedRequests(); + } + } else if (mException != null) { + LogUtil.e(LogUtil.BUGLE_TAG, "Asynchronous media loading failed, key=" + + mediaRequest.getKey(), mException); + if (bindableRequest != null) { + bindableRequest.onMediaResourceLoadError(bindableRequest, mException); + } + } else { + Assert.isTrue(bindableRequest == null || !bindableRequest.isBound()); + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "media request not processed, no longer bound; key=" + + LogUtil.sanitizePII(mediaRequest.getKey()) /* key with phone# */); + } + } + } + }; + mediaLoadingTask.executeOnExecutor(executor, (Void) null); + } + + @VisibleForTesting + @RunsOnAnyThread + <T extends RefCountedMediaResource> void addResourceToMemoryCache( + final MediaRequest<T> mediaRequest, final T mediaResource) { + Assert.isTrue(mediaResource != null); + final MediaCache<T> mediaCache = mediaRequest.getMediaCache(); + if (mediaCache != null) { + mediaCache.addResourceToCache(mediaRequest.getKey(), mediaResource); + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "added media resource to " + mediaCache.getName() + ". key=" + + LogUtil.sanitizePII(mediaRequest.getKey()) /* key can contain phone# */); + } + } + } + + private class MediaLoadingResult<T extends RefCountedMediaResource> { + public final T loadedResource; + public final boolean fromCache; + private final List<MediaRequest<T>> mChainedRequests; + + MediaLoadingResult(final T loadedResource, final boolean fromCache, + final List<MediaRequest<T>> chainedRequests) { + this.loadedResource = loadedResource; + this.fromCache = fromCache; + mChainedRequests = chainedRequests; + } + + /** + * Asynchronously schedule a list of chained requests on the background thread. + */ + public void scheduleChainedRequests() { + for (final MediaRequest<T> mediaRequest : mChainedRequests) { + scheduleAsyncMediaRequest(mediaRequest, MEDIA_BACKGROUND_EXECUTOR); + } + } + } +} diff --git a/src/com/android/messaging/datamodel/media/MessagePartImageRequestDescriptor.java b/src/com/android/messaging/datamodel/media/MessagePartImageRequestDescriptor.java new file mode 100644 index 0000000..1871e66 --- /dev/null +++ b/src/com/android/messaging/datamodel/media/MessagePartImageRequestDescriptor.java @@ -0,0 +1,64 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.datamodel.media; + +import android.net.Uri; + +import com.android.messaging.datamodel.action.UpdateMessagePartSizeAction; +import com.android.messaging.datamodel.data.MessagePartData; +import com.android.messaging.util.ImageUtils; + +/** + * Image descriptor attached to a message part. + * Once image size is determined during loading this descriptor will update the db if necessary. + */ +public class MessagePartImageRequestDescriptor extends UriImageRequestDescriptor { + private final String mMessagePartId; + + /** + * Creates a new image request for a message part. + */ + public MessagePartImageRequestDescriptor(final MessagePartData messagePart, + final int desiredWidth, final int desiredHeight, boolean isStatic) { + // Pull image parameters out of the MessagePart record + this(messagePart.getPartId(), messagePart.getContentUri(), desiredWidth, desiredHeight, + messagePart.getWidth(), messagePart.getHeight(), isStatic); + } + + protected MessagePartImageRequestDescriptor(final String messagePartId, final Uri contentUri, + final int desiredWidth, final int desiredHeight, final int sourceWidth, + final int sourceHeight, boolean isStatic) { + super(contentUri, desiredWidth, desiredHeight, sourceWidth, sourceHeight, + true /* allowCompression */, isStatic, false /* cropToCircle */, + ImageUtils.DEFAULT_CIRCLE_BACKGROUND_COLOR /* circleBackgroundColor */, + ImageUtils.DEFAULT_CIRCLE_STROKE_COLOR /* circleStrokeColor */); + mMessagePartId = messagePartId; + } + + @Override + public void updateSourceDimensions(final int updatedWidth, final int updatedHeight) { + // If the dimensions of the image do not match then queue a DB update with new size. + // Don't update if we don't have a part id, which happens if this part is loaded as + // draft through actions such as share intent/message forwarding. + if (mMessagePartId != null && + updatedWidth != MessagePartData.UNSPECIFIED_SIZE && + updatedHeight != MessagePartData.UNSPECIFIED_SIZE && + updatedWidth != sourceWidth && updatedHeight != sourceHeight) { + UpdateMessagePartSizeAction.updateSize(mMessagePartId, updatedWidth, updatedHeight); + } + } +} diff --git a/src/com/android/messaging/datamodel/media/MessagePartVideoThumbnailRequestDescriptor.java b/src/com/android/messaging/datamodel/media/MessagePartVideoThumbnailRequestDescriptor.java new file mode 100644 index 0000000..ff11e92 --- /dev/null +++ b/src/com/android/messaging/datamodel/media/MessagePartVideoThumbnailRequestDescriptor.java @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.datamodel.media; + +import android.content.Context; +import android.net.Uri; + +import com.android.messaging.datamodel.data.MessagePartData; + +public class MessagePartVideoThumbnailRequestDescriptor extends MessagePartImageRequestDescriptor { + public MessagePartVideoThumbnailRequestDescriptor(MessagePartData messagePart) { + super(messagePart, ImageRequest.UNSPECIFIED_SIZE, ImageRequest.UNSPECIFIED_SIZE, false); + } + + public MessagePartVideoThumbnailRequestDescriptor(Uri uri) { + super(null, uri, ImageRequest.UNSPECIFIED_SIZE, ImageRequest.UNSPECIFIED_SIZE, + ImageRequest.UNSPECIFIED_SIZE, ImageRequest.UNSPECIFIED_SIZE, false); + } + + @Override + public MediaRequest<ImageResource> buildSyncMediaRequest(final Context context) { + return new VideoThumbnailRequest(context, this); + } +} diff --git a/src/com/android/messaging/datamodel/media/NetworkUriImageRequest.java b/src/com/android/messaging/datamodel/media/NetworkUriImageRequest.java new file mode 100644 index 0000000..642e947 --- /dev/null +++ b/src/com/android/messaging/datamodel/media/NetworkUriImageRequest.java @@ -0,0 +1,120 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel.media; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; + +import com.android.messaging.Factory; +import com.android.messaging.util.Assert; +import com.android.messaging.util.ContentType; +import com.android.messaging.util.LogUtil; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.MalformedURLException; +import java.net.URL; + +/** + * Serves network content URI based image requests. + */ +public class NetworkUriImageRequest<D extends UriImageRequestDescriptor> extends + ImageRequest<D> { + + public NetworkUriImageRequest(Context context, D descriptor) { + super(context, descriptor); + mOrientation = android.media.ExifInterface.ORIENTATION_UNDEFINED; + } + + @Override + protected InputStream getInputStreamForResource() throws FileNotFoundException { + Assert.isNotMainThread(); + // Since we need to have an open urlConnection to get the stream, but we don't want to keep + // that connection open. There is no good way to perform this method. + return null; + } + + @Override + protected boolean isGif() throws FileNotFoundException { + Assert.isNotMainThread(); + + HttpURLConnection connection = null; + try { + final URL url = new URL(mDescriptor.uri.toString()); + connection = (HttpURLConnection) url.openConnection(); + connection.connect(); + if (connection.getResponseCode() == HttpURLConnection.HTTP_OK) { + return ContentType.IMAGE_GIF.equalsIgnoreCase(connection.getContentType()); + } + } catch (MalformedURLException e) { + LogUtil.e(LogUtil.BUGLE_TAG, + "MalformedUrl for image with url: " + + mDescriptor.uri.toString(), e); + } catch (IOException e) { + LogUtil.e(LogUtil.BUGLE_TAG, + "IOException trying to get inputStream for image with url: " + + mDescriptor.uri.toString(), e); + } finally { + if (connection != null) { + connection.disconnect(); + } + } + return false; + } + + @SuppressWarnings("deprecation") + @Override + public Bitmap loadBitmapInternal() throws IOException { + Assert.isNotMainThread(); + + InputStream inputStream = null; + Bitmap bitmap = null; + HttpURLConnection connection = null; + try { + final URL url = new URL(mDescriptor.uri.toString()); + connection = (HttpURLConnection) url.openConnection(); + connection.setDoInput(true); + connection.connect(); + if (connection.getResponseCode() == HttpURLConnection.HTTP_OK) { + bitmap = BitmapFactory.decodeStream(connection.getInputStream()); + } + } catch (MalformedURLException e) { + LogUtil.e(LogUtil.BUGLE_TAG, + "MalformedUrl for image with url: " + + mDescriptor.uri.toString(), e); + } catch (final OutOfMemoryError e) { + LogUtil.e(LogUtil.BUGLE_TAG, + "OutOfMemoryError for image with url: " + + mDescriptor.uri.toString(), e); + Factory.get().reclaimMemory(); + } catch (IOException e) { + LogUtil.e(LogUtil.BUGLE_TAG, + "IOException trying to get inputStream for image with url: " + + mDescriptor.uri.toString(), e); + } finally { + if (inputStream != null) { + inputStream.close(); + } + if (connection != null) { + connection.disconnect(); + } + } + return bitmap; + } +} diff --git a/src/com/android/messaging/datamodel/media/PoolableImageCache.java b/src/com/android/messaging/datamodel/media/PoolableImageCache.java new file mode 100644 index 0000000..df814ba --- /dev/null +++ b/src/com/android/messaging/datamodel/media/PoolableImageCache.java @@ -0,0 +1,419 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel.media; + +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Color; +import android.os.SystemClock; +import android.support.annotation.NonNull; +import android.util.SparseArray; + +import com.android.messaging.Factory; +import com.android.messaging.util.Assert; +import com.android.messaging.util.LogUtil; + +import java.io.IOException; +import java.io.InputStream; +import java.util.LinkedList; + +/** + * A media cache that holds image resources, which doubles as a bitmap pool that allows the + * consumer to optionally decode image resources using unused bitmaps stored in the cache. + */ +public class PoolableImageCache extends MediaCache<ImageResource> { + private static final int MIN_TIME_IN_POOL = 5000; + + /** Encapsulates bitmap pool representation of the image cache */ + private final ReusableImageResourcePool mReusablePoolAccessor = new ReusableImageResourcePool(); + + public PoolableImageCache(final int id, final String name) { + this(DEFAULT_MEDIA_RESOURCE_CACHE_SIZE_IN_KILOBYTES, id, name); + } + + public PoolableImageCache(final int maxSize, final int id, final String name) { + super(maxSize, id, name); + } + + /** + * Creates a new BitmapFactory.Options for using the self-contained bitmap pool. + */ + public static BitmapFactory.Options getBitmapOptionsForPool(final boolean scaled, + final int inputDensity, final int targetDensity) { + final BitmapFactory.Options options = new BitmapFactory.Options(); + options.inScaled = scaled; + options.inDensity = inputDensity; + options.inTargetDensity = targetDensity; + options.inSampleSize = 1; + options.inJustDecodeBounds = false; + options.inMutable = true; + return options; + } + + @Override + public synchronized ImageResource addResourceToCache(final String key, + final ImageResource imageResource) { + mReusablePoolAccessor.onResourceEnterCache(imageResource); + return super.addResourceToCache(key, imageResource); + } + + @Override + protected synchronized void entryRemoved(final boolean evicted, final String key, + final ImageResource oldValue, final ImageResource newValue) { + mReusablePoolAccessor.onResourceLeaveCache(oldValue); + super.entryRemoved(evicted, key, oldValue, newValue); + } + + /** + * Returns a representation of the image cache as a reusable bitmap pool. + */ + public ReusableImageResourcePool asReusableBitmapPool() { + return mReusablePoolAccessor; + } + + /** + * A bitmap pool representation built on top of the image cache. It treats the image resources + * stored in the image cache as a self-contained bitmap pool and is able to create or + * reclaim bitmap resource as needed. + */ + public class ReusableImageResourcePool { + private static final int MAX_SUPPORTED_IMAGE_DIMENSION = 0xFFFF; + private static final int INVALID_POOL_KEY = 0; + + /** + * Number of reuse failures to skip before reporting. + * For debugging purposes, change to a lower number for more frequent reporting. + */ + private static final int FAILED_REPORTING_FREQUENCY = 100; + + /** + * Count of reuse failures which have occurred. + */ + private volatile int mFailedBitmapReuseCount = 0; + + /** + * Count of reuse successes which have occurred. + */ + private volatile int mSucceededBitmapReuseCount = 0; + + /** + * A sparse array from bitmap size to a list of image cache entries that match the + * given size. This map is used to quickly retrieve a usable bitmap to be reused by an + * incoming ImageRequest. We need to ensure that this sparse array always contains only + * elements currently in the image cache with no other consumer. + */ + private final SparseArray<LinkedList<ImageResource>> mImageListSparseArray; + + public ReusableImageResourcePool() { + mImageListSparseArray = new SparseArray<LinkedList<ImageResource>>(); + } + + /** + * Load an input stream into a bitmap. Uses a bitmap from the pool if possible to reduce + * memory turnover. + * @param inputStream InputStream load. Cannot be null. + * @param optionsTmp Should be the same options returned from getBitmapOptionsForPool(). + * Cannot be null. + * @param width The width of the bitmap. + * @param height The height of the bitmap. + * @return The decoded Bitmap with the resource drawn in it. + * @throws IOException + */ + public Bitmap decodeSampledBitmapFromInputStream(@NonNull final InputStream inputStream, + @NonNull final BitmapFactory.Options optionsTmp, + final int width, final int height) throws IOException { + if (width <= 0 || height <= 0) { + // This is an invalid / corrupted image of zero size. + LogUtil.w(LogUtil.BUGLE_IMAGE_TAG, "PoolableImageCache: Decoding bitmap with " + + "invalid size"); + throw new IOException("Invalid size / corrupted image"); + } + Assert.notNull(inputStream); + assignPoolBitmap(optionsTmp, width, height); + Bitmap b = null; + try { + b = BitmapFactory.decodeStream(inputStream, null, optionsTmp); + mSucceededBitmapReuseCount++; + } catch (final IllegalArgumentException e) { + // BitmapFactory couldn't decode the file, try again without an inputBufferBitmap. + if (optionsTmp.inBitmap != null) { + optionsTmp.inBitmap.recycle(); + optionsTmp.inBitmap = null; + b = BitmapFactory.decodeStream(inputStream, null, optionsTmp); + onFailedToReuse(); + } + } catch (final OutOfMemoryError e) { + LogUtil.w(LogUtil.BUGLE_IMAGE_TAG, "Oom decoding inputStream"); + Factory.get().reclaimMemory(); + } + return b; + } + + /** + * Turn encoded bytes into a bitmap. Uses a bitmap from the pool if possible to reduce + * memory turnover. + * @param bytes Encoded bytes to draw on the bitmap. Cannot be null. + * @param optionsTmp The bitmap will set here and the input should be generated from + * getBitmapOptionsForPool(). Cannot be null. + * @param width The width of the bitmap. + * @param height The height of the bitmap. + * @return A Bitmap with the encoded bytes drawn in it. + * @throws IOException + */ + public Bitmap decodeByteArray(@NonNull final byte[] bytes, + @NonNull final BitmapFactory.Options optionsTmp, final int width, + final int height) throws OutOfMemoryError, IOException { + if (width <= 0 || height <= 0) { + // This is an invalid / corrupted image of zero size. + LogUtil.w(LogUtil.BUGLE_IMAGE_TAG, "PoolableImageCache: Decoding bitmap with " + + "invalid size"); + throw new IOException("Invalid size / corrupted image"); + } + Assert.notNull(bytes); + Assert.notNull(optionsTmp); + assignPoolBitmap(optionsTmp, width, height); + Bitmap b = null; + try { + b = BitmapFactory.decodeByteArray(bytes, 0, bytes.length, optionsTmp); + mSucceededBitmapReuseCount++; + } catch (final IllegalArgumentException e) { + // BitmapFactory couldn't decode the file, try again without an inputBufferBitmap. + // (i.e. without the bitmap from the pool) + if (optionsTmp.inBitmap != null) { + optionsTmp.inBitmap.recycle(); + optionsTmp.inBitmap = null; + b = BitmapFactory.decodeByteArray(bytes, 0, bytes.length, optionsTmp); + onFailedToReuse(); + } + } catch (final OutOfMemoryError e) { + LogUtil.w(LogUtil.BUGLE_IMAGE_TAG, "Oom decoding inputStream"); + Factory.get().reclaimMemory(); + } + return b; + } + + /** + * Called when a new image resource is added to the cache. We add the resource to the + * pool so it's properly keyed into the pool structure. + */ + void onResourceEnterCache(final ImageResource imageResource) { + if (getPoolKey(imageResource) != INVALID_POOL_KEY) { + addResourceToPool(imageResource); + } + } + + /** + * Called when an image resource is evicted from the cache. Bitmap pool's entries are + * strictly tied to their presence in the image cache. Once an image is evicted from the + * cache, it should be removed from the pool. + */ + void onResourceLeaveCache(final ImageResource imageResource) { + if (getPoolKey(imageResource) != INVALID_POOL_KEY) { + removeResourceFromPool(imageResource); + } + } + + private void addResourceToPool(final ImageResource imageResource) { + synchronized (PoolableImageCache.this) { + final int poolKey = getPoolKey(imageResource); + Assert.isTrue(poolKey != INVALID_POOL_KEY); + LinkedList<ImageResource> imageList = mImageListSparseArray.get(poolKey); + if (imageList == null) { + imageList = new LinkedList<ImageResource>(); + mImageListSparseArray.put(poolKey, imageList); + } + imageList.addLast(imageResource); + } + } + + private void removeResourceFromPool(final ImageResource imageResource) { + synchronized (PoolableImageCache.this) { + final int poolKey = getPoolKey(imageResource); + Assert.isTrue(poolKey != INVALID_POOL_KEY); + final LinkedList<ImageResource> imageList = mImageListSparseArray.get(poolKey); + if (imageList != null) { + imageList.remove(imageResource); + } + } + } + + /** + * Try to get a reusable bitmap from the pool with the given width and height. As a + * result of this call, the caller will assume ownership of the returned bitmap. + */ + private Bitmap getReusableBitmapFromPool(final int width, final int height) { + synchronized (PoolableImageCache.this) { + final int poolKey = getPoolKey(width, height); + if (poolKey != INVALID_POOL_KEY) { + final LinkedList<ImageResource> images = mImageListSparseArray.get(poolKey); + if (images != null && images.size() > 0) { + // Try to reuse the first available bitmap from the pool list. We start from + // the least recently added cache entry of the given size. + ImageResource imageToUse = null; + for (int i = 0; i < images.size(); i++) { + final ImageResource image = images.get(i); + if (image.getRefCount() == 1) { + image.acquireLock(); + if (image.getRefCount() == 1) { + // The image is only used by the cache, so it's reusable. + imageToUse = images.remove(i); + break; + } else { + // Logically, this shouldn't happen, because as soon as the + // cache is the only user of this resource, it will not be + // used by anyone else until the next cache access, but we + // currently hold on to the cache lock. But technically + // future changes may violate this assumption, so warn about + // this. + LogUtil.w(LogUtil.BUGLE_IMAGE_TAG, "Image refCount changed " + + "from 1 in getReusableBitmapFromPool()"); + image.releaseLock(); + } + } + } + + if (imageToUse == null) { + return null; + } + + try { + imageToUse.assertLockHeldByCurrentThread(); + + // Only reuse the bitmap if the last time we use was greater than 5s. + // This allows the cache a chance to reuse instead of always taking the + // oldest. + final long timeSinceLastRef = SystemClock.elapsedRealtime() - + imageToUse.getLastRefAddTimestamp(); + if (timeSinceLastRef < MIN_TIME_IN_POOL) { + if (LogUtil.isLoggable(LogUtil.BUGLE_IMAGE_TAG, LogUtil.VERBOSE)) { + LogUtil.v(LogUtil.BUGLE_IMAGE_TAG, "Not reusing reusing " + + "first available bitmap from the pool because it " + + "has not been in the pool long enough. " + + "timeSinceLastRef=" + timeSinceLastRef); + } + // Put back the image and return no reuseable bitmap. + images.addLast(imageToUse); + return null; + } + + // Add a temp ref on the image resource so it won't be GC'd after + // being removed from the cache. + imageToUse.addRef(); + + // Remove the image resource from the image cache. + final ImageResource removed = remove(imageToUse.getKey()); + Assert.isTrue(removed == imageToUse); + + // Try to reuse the bitmap from the image resource. This will transfer + // ownership of the bitmap object to the caller of this method. + final Bitmap reusableBitmap = imageToUse.reuseBitmap(); + + imageToUse.release(); + return reusableBitmap; + } finally { + // We are either done with the reuse operation, or decided not to use + // the image. Either way, release the lock. + imageToUse.releaseLock(); + } + } + } + } + return null; + } + + /** + * Try to locate and return a reusable bitmap from the pool, or create a new bitmap. + * @param width desired bitmap width + * @param height desired bitmap height + * @return the created or reused mutable bitmap that has its background cleared to + * {@value Color#TRANSPARENT} + */ + public Bitmap createOrReuseBitmap(final int width, final int height) { + return createOrReuseBitmap(width, height, Color.TRANSPARENT); + } + + /** + * Try to locate and return a reusable bitmap from the pool, or create a new bitmap. + * @param width desired bitmap width + * @param height desired bitmap height + * @param backgroundColor the background color for the returned bitmap + * @return the created or reused mutable bitmap with the requested background color + */ + public Bitmap createOrReuseBitmap(final int width, final int height, + final int backgroundColor) { + Bitmap retBitmap = null; + try { + final Bitmap poolBitmap = getReusableBitmapFromPool(width, height); + retBitmap = (poolBitmap != null) ? poolBitmap : + Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); + retBitmap.eraseColor(backgroundColor); + } catch (final OutOfMemoryError e) { + LogUtil.w(LogUtil.BUGLE_IMAGE_TAG, "PoolableImageCache:try to createOrReuseBitmap"); + Factory.get().reclaimMemory(); + } + return retBitmap; + } + + private void assignPoolBitmap(final BitmapFactory.Options optionsTmp, final int width, + final int height) { + if (optionsTmp.inJustDecodeBounds) { + return; + } + optionsTmp.inBitmap = getReusableBitmapFromPool(width, height); + } + + /** + * @return The pool key for the provided image dimensions or 0 if either width or height is + * greater than the max supported image dimension. + */ + private int getPoolKey(final int width, final int height) { + if (width > MAX_SUPPORTED_IMAGE_DIMENSION || height > MAX_SUPPORTED_IMAGE_DIMENSION) { + return INVALID_POOL_KEY; + } + return (width << 16) | height; + } + + /** + * @return the pool key for a given image resource. + */ + private int getPoolKey(final ImageResource imageResource) { + if (imageResource.supportsBitmapReuse()) { + final Bitmap bitmap = imageResource.getBitmap(); + if (bitmap != null && bitmap.isMutable()) { + final int width = bitmap.getWidth(); + final int height = bitmap.getHeight(); + if (width > 0 && height > 0) { + return getPoolKey(width, height); + } + } + } + return INVALID_POOL_KEY; + } + + /** + * Called when bitmap reuse fails. Conditionally report the failure with statistics. + */ + private void onFailedToReuse() { + mFailedBitmapReuseCount++; + if (mFailedBitmapReuseCount % FAILED_REPORTING_FREQUENCY == 0) { + LogUtil.w(LogUtil.BUGLE_IMAGE_TAG, + "Pooled bitmap consistently not being reused. Failure count = " + + mFailedBitmapReuseCount + ", success count = " + + mSucceededBitmapReuseCount); + } + } + } +} diff --git a/src/com/android/messaging/datamodel/media/RefCountedMediaResource.java b/src/com/android/messaging/datamodel/media/RefCountedMediaResource.java new file mode 100644 index 0000000..c21f477 --- /dev/null +++ b/src/com/android/messaging/datamodel/media/RefCountedMediaResource.java @@ -0,0 +1,164 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel.media; + +import android.os.SystemClock; + +import com.android.messaging.util.Assert; +import com.android.messaging.util.LogUtil; +import com.google.common.base.Throwables; + +import java.util.ArrayList; +import java.util.concurrent.locks.ReentrantLock; + +/** + * A ref-counted class that holds loaded media resource, be it bitmaps or media bytes. + * Subclasses must implement the close() method to release any resources (such as bitmaps) + * when it's no longer used. + * + * Instances of the subclasses are: + * 1. Loaded by their corresponding MediaRequest classes. + * 2. Maintained by MediaResourceManager in its MediaCache pool. + * 3. Used by the UI (such as ContactIconViews) to present the content. + * + * Note: all synchronized methods in this class (e.g. addRef()) should not attempt to make outgoing + * calls that could potentially acquire media cache locks due to the potential deadlock this can + * cause. To synchronize read/write access to shared resource, {@link #acquireLock()} and + * {@link #releaseLock()} must be used, instead of using synchronized keyword. + */ +public abstract class RefCountedMediaResource { + private final String mKey; + private int mRef = 0; + private long mLastRefAddTimestamp; + + // Set DEBUG to true to enable detailed stack trace for each addRef() and release() operation + // to find out where each ref change happens. + private static final boolean DEBUG = false; + private static final String TAG = "bugle_media_ref_history"; + private final ArrayList<String> mRefHistory = new ArrayList<String>(); + + // A lock that guards access to shared members in this class (and all its subclasses). + private final ReentrantLock mLock = new ReentrantLock(); + + public RefCountedMediaResource(final String key) { + mKey = key; + } + + public String getKey() { + return mKey; + } + + public void addRef() { + acquireLock(); + try { + if (DEBUG) { + mRefHistory.add("Added ref current ref = " + mRef); + mRefHistory.add(Throwables.getStackTraceAsString(new Exception())); + } + + mRef++; + mLastRefAddTimestamp = SystemClock.elapsedRealtime(); + } finally { + releaseLock(); + } + } + + public void release() { + acquireLock(); + try { + if (DEBUG) { + mRefHistory.add("Released ref current ref = " + mRef); + mRefHistory.add(Throwables.getStackTraceAsString(new Exception())); + } + + mRef--; + if (mRef == 0) { + close(); + } else if (mRef < 0) { + if (DEBUG) { + LogUtil.i(TAG, "Unwinding ref count history for RefCountedMediaResource " + + this); + for (final String ref : mRefHistory) { + LogUtil.i(TAG, ref); + } + } + Assert.fail("RefCountedMediaResource has unbalanced ref. Refcount=" + mRef); + } + } finally { + releaseLock(); + } + } + + public int getRefCount() { + acquireLock(); + try { + return mRef; + } finally { + releaseLock(); + } + } + + public long getLastRefAddTimestamp() { + acquireLock(); + try { + return mLastRefAddTimestamp; + } finally { + releaseLock(); + } + } + + public void assertSingularRefCount() { + acquireLock(); + try { + Assert.equals(1, mRef); + } finally { + releaseLock(); + } + } + + void acquireLock() { + mLock.lock(); + } + + void releaseLock() { + mLock.unlock(); + } + + void assertLockHeldByCurrentThread() { + Assert.isTrue(mLock.isHeldByCurrentThread()); + } + + boolean isEncoded() { + return false; + } + + boolean isCacheable() { + return true; + } + + MediaRequest<? extends RefCountedMediaResource> getMediaDecodingRequest( + final MediaRequest<? extends RefCountedMediaResource> originalRequest) { + return null; + } + + MediaRequest<? extends RefCountedMediaResource> getMediaEncodingRequest( + final MediaRequest<? extends RefCountedMediaResource> originalRequest) { + return null; + } + + public abstract int getMediaSize(); + protected abstract void close(); +}
\ No newline at end of file diff --git a/src/com/android/messaging/datamodel/media/SimSelectorAvatarRequest.java b/src/com/android/messaging/datamodel/media/SimSelectorAvatarRequest.java new file mode 100644 index 0000000..e4f0334 --- /dev/null +++ b/src/com/android/messaging/datamodel/media/SimSelectorAvatarRequest.java @@ -0,0 +1,117 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel.media; + +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.PorterDuff; +import android.graphics.PorterDuffColorFilter; +import android.graphics.Rect; +import android.graphics.Typeface; +import android.graphics.drawable.BitmapDrawable; +import android.media.ExifInterface; +import android.text.TextUtils; + +import com.android.messaging.R; +import com.android.messaging.util.Assert; +import com.android.messaging.util.AvatarUriUtil; + +import java.io.IOException; +import java.util.List; + +public class SimSelectorAvatarRequest extends AvatarRequest { + private static Bitmap sRegularSimIcon; + + public SimSelectorAvatarRequest(final Context context, + final AvatarRequestDescriptor descriptor) { + super(context, descriptor); + } + + /** + * {@inheritDoc} + */ + @Override + protected ImageResource loadMediaInternal(List<MediaRequest<ImageResource>> chainedTasks) + throws IOException { + Assert.isNotMainThread(); + final String avatarType = AvatarUriUtil.getAvatarType(mDescriptor.uri); + if (AvatarUriUtil.TYPE_SIM_SELECTOR_URI.equals(avatarType)){ + final int width = mDescriptor.desiredWidth; + final int height = mDescriptor.desiredHeight; + final String identifier = AvatarUriUtil.getIdentifier(mDescriptor.uri); + final boolean simSelected = AvatarUriUtil.getSimSelected(mDescriptor.uri); + final int simColor = AvatarUriUtil.getSimColor(mDescriptor.uri); + final boolean incoming = AvatarUriUtil.getSimIncoming(mDescriptor.uri); + return renderSimAvatarInternal(identifier, width, height, simColor, simSelected, + incoming); + } + return super.loadMediaInternal(chainedTasks); + } + + private ImageResource renderSimAvatarInternal(final String identifier, final int width, + final int height, final int subColor, final boolean selected, final boolean incoming) { + final Resources resources = mContext.getResources(); + final float halfWidth = width / 2; + final float halfHeight = height / 2; + final int minOfWidthAndHeight = Math.min(width, height); + final int backgroundColor = selected ? subColor : Color.WHITE; + final int textColor = selected ? subColor : Color.WHITE; + final int simColor = selected ? Color.WHITE : subColor; + final Bitmap bitmap = getBitmapPool().createOrReuseBitmap(width, height, backgroundColor); + final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG); + final Canvas canvas = new Canvas(bitmap); + + if (sRegularSimIcon == null) { + final BitmapDrawable regularSim = (BitmapDrawable) mContext.getResources() + .getDrawable(R.drawable.ic_sim_card_send); + sRegularSimIcon = regularSim.getBitmap(); + } + + paint.setColorFilter(new PorterDuffColorFilter(simColor, PorterDuff.Mode.SRC_ATOP)); + paint.setAlpha(0xff); + canvas.drawBitmap(sRegularSimIcon, halfWidth - sRegularSimIcon.getWidth() / 2, + halfHeight - sRegularSimIcon.getHeight() / 2, paint); + paint.setColorFilter(null); + paint.setAlpha(0xff); + + if (!TextUtils.isEmpty(identifier)) { + paint.setTypeface(Typeface.create("sans-serif", Typeface.NORMAL)); + paint.setColor(textColor); + final float letterToTileRatio = + resources.getFraction(R.dimen.sim_identifier_to_tile_ratio, 1, 1); + paint.setTextSize(letterToTileRatio * minOfWidthAndHeight); + + final String firstCharString = identifier.substring(0, 1).toUpperCase(); + final Rect textBound = new Rect(); + paint.getTextBounds(firstCharString, 0, 1, textBound); + + final float xOffset = halfWidth - textBound.centerX(); + final float yOffset = halfHeight - textBound.centerY(); + canvas.drawText(firstCharString, xOffset, yOffset, paint); + } + + return new DecodedImageResource(getKey(), bitmap, ExifInterface.ORIENTATION_NORMAL); + } + + @Override + public int getCacheId() { + return BugleMediaCacheManager.AVATAR_IMAGE_CACHE; + } +} diff --git a/src/com/android/messaging/datamodel/media/UriImageRequest.java b/src/com/android/messaging/datamodel/media/UriImageRequest.java new file mode 100644 index 0000000..b4934ca --- /dev/null +++ b/src/com/android/messaging/datamodel/media/UriImageRequest.java @@ -0,0 +1,58 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel.media; + +import android.content.Context; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.util.List; + +/** + * Serves local content URI based image requests. + */ +public class UriImageRequest<D extends UriImageRequestDescriptor> extends ImageRequest<D> { + public UriImageRequest(final Context context, final D descriptor) { + super(context, descriptor); + } + + @Override + protected InputStream getInputStreamForResource() throws FileNotFoundException { + return mContext.getContentResolver().openInputStream(mDescriptor.uri); + } + + @Override + protected ImageResource loadMediaInternal(List<MediaRequest<ImageResource>> chainedTasks) + throws IOException { + final ImageResource resource = super.loadMediaInternal(chainedTasks); + // Check if the caller asked for compression. If so, chain an encoding task if possible. + if (mDescriptor.allowCompression && chainedTasks != null) { + @SuppressWarnings("unchecked") + final MediaRequest<ImageResource> chainedTask = (MediaRequest<ImageResource>) + resource.getMediaEncodingRequest(this); + if (chainedTask != null) { + chainedTasks.add(chainedTask); + // Don't cache decoded image resource since we'll perform compression and cache + // the compressed resource. + if (resource instanceof DecodedImageResource) { + ((DecodedImageResource) resource).setCacheable(false); + } + } + } + return resource; + } +} diff --git a/src/com/android/messaging/datamodel/media/UriImageRequestDescriptor.java b/src/com/android/messaging/datamodel/media/UriImageRequestDescriptor.java new file mode 100644 index 0000000..c5685d1 --- /dev/null +++ b/src/com/android/messaging/datamodel/media/UriImageRequestDescriptor.java @@ -0,0 +1,95 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel.media; + +import android.content.Context; +import android.net.Uri; + +import com.android.messaging.util.UriUtil; + +public class UriImageRequestDescriptor extends ImageRequestDescriptor { + public final Uri uri; + public final boolean allowCompression; + + public UriImageRequestDescriptor(final Uri uri) { + this(uri, UriImageRequest.UNSPECIFIED_SIZE, UriImageRequest.UNSPECIFIED_SIZE, false, false, + false, 0, 0); + } + + public UriImageRequestDescriptor(final Uri uri, final int desiredWidth, final int desiredHeight) + { + this(uri, desiredWidth, desiredHeight, false, false, false, 0, 0); + } + + public UriImageRequestDescriptor(final Uri uri, final int desiredWidth, final int desiredHeight, + final boolean cropToCircle, final int circleBackgroundColor, int circleStrokeColor) + { + this(uri, desiredWidth, desiredHeight, false, + false, cropToCircle, circleBackgroundColor, circleStrokeColor); + } + + public UriImageRequestDescriptor(final Uri uri, final int desiredWidth, + final int desiredHeight, final boolean allowCompression, boolean isStatic, + boolean cropToCircle, int circleBackgroundColor, int circleStrokeColor) { + this(uri, desiredWidth, desiredHeight, UriImageRequest.UNSPECIFIED_SIZE, + UriImageRequest.UNSPECIFIED_SIZE, allowCompression, isStatic, cropToCircle, + circleBackgroundColor, circleStrokeColor); + } + + /** + * Creates a new Uri-based image request. + * @param uri the content Uri. Currently Bugle only supports local resources Uri (i.e. it has + * to begin with content: or android.resource: + * @param circleStrokeColor + */ + public UriImageRequestDescriptor(final Uri uri, final int desiredWidth, + final int desiredHeight, final int sourceWidth, final int sourceHeight, + final boolean allowCompression, final boolean isStatic, final boolean cropToCircle, + final int circleBackgroundColor, int circleStrokeColor) { + super(desiredWidth, desiredHeight, sourceWidth, sourceHeight, isStatic, + cropToCircle, circleBackgroundColor, circleStrokeColor); + this.uri = uri; + this.allowCompression = allowCompression; + } + + @Override + public String getKey() { + if (uri != null) { + final String key = super.getKey(); + if (key != null) { + return new StringBuilder() + .append(uri).append(KEY_PART_DELIMITER) + .append(String.valueOf(allowCompression)).append(KEY_PART_DELIMITER) + .append(key).toString(); + } + } + return null; + } + + @Override + public MediaRequest<ImageResource> buildSyncMediaRequest(final Context context) { + if (uri == null || UriUtil.isLocalUri(uri)) { + return new UriImageRequest<UriImageRequestDescriptor>(context, this); + } else { + return new NetworkUriImageRequest<UriImageRequestDescriptor>(context, this); + } + } + + /** ID of the resource in MediaStore or null if this resource didn't come from MediaStore */ + public Long getMediaStoreId() { + return null; + } +}
\ No newline at end of file diff --git a/src/com/android/messaging/datamodel/media/VCardRequest.java b/src/com/android/messaging/datamodel/media/VCardRequest.java new file mode 100644 index 0000000..d6e992c --- /dev/null +++ b/src/com/android/messaging/datamodel/media/VCardRequest.java @@ -0,0 +1,328 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel.media; + +import android.content.ContentResolver; +import android.content.Context; +import android.net.Uri; + +import com.android.messaging.util.Assert; +import com.android.messaging.util.Assert.DoesNotRunOnMainThread; +import com.android.messaging.util.AvatarUriUtil; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.PhoneUtils; +import com.android.messaging.util.UriUtil; +import com.android.vcard.VCardConfig; +import com.android.vcard.VCardEntry; +import com.android.vcard.VCardEntryCounter; +import com.android.vcard.VCardInterpreter; +import com.android.vcard.VCardParser; +import com.android.vcard.VCardParser_V21; +import com.android.vcard.VCardParser_V30; +import com.android.vcard.VCardSourceDetector; +import com.android.vcard.exception.VCardException; +import com.android.vcard.exception.VCardNestedException; +import com.android.vcard.exception.VCardNotSupportedException; +import com.android.vcard.exception.VCardVersionException; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +/** + * Requests and parses VCard data. In Bugle, we need to display VCard details in the conversation + * view such as avatar icon and name, which can be expensive if we parse VCard every time. + * Therefore, we'd like to load the vcard once and cache it in our media cache using the + * MediaResourceManager component. To load the VCard, we use framework's VCard support to + * interpret the VCard content, which gives us information such as phone and email list, which + * we'll put in VCardResource object to be cached. + * + * Some particular attention is needed for the avatar icon. If the VCard contains avatar icon, + * it's in byte array form that can't easily be cached/persisted. Therefore, we persist the + * image bytes to the scratch directory and generate a content Uri for it, so that ContactIconView + * may use this Uri to display and cache the image if needed. + */ +public class VCardRequest implements MediaRequest<VCardResource> { + private final Context mContext; + private final VCardRequestDescriptor mDescriptor; + private final List<VCardResourceEntry> mLoadedVCards; + private VCardResource mLoadedResource; + private static final int VCARD_LOADING_TIMEOUT_MILLIS = 10000; // 10s + private static final String DEFAULT_VCARD_TYPE = "default"; + + VCardRequest(final Context context, final VCardRequestDescriptor descriptor) { + mDescriptor = descriptor; + mContext = context; + mLoadedVCards = new ArrayList<VCardResourceEntry>(); + } + + @Override + public String getKey() { + return mDescriptor.vCardUri.toString(); + } + + @Override + @DoesNotRunOnMainThread + public VCardResource loadMediaBlocking(List<MediaRequest<VCardResource>> chainedTask) + throws Exception { + Assert.isNotMainThread(); + Assert.isTrue(mLoadedResource == null); + Assert.equals(0, mLoadedVCards.size()); + + // The VCard library doesn't support synchronously loading the media resource. Therefore, + // We have to burn the thread waiting for the result to come back. + final CountDownLatch signal = new CountDownLatch(1); + if (!parseVCard(mDescriptor.vCardUri, signal)) { + // Directly fail without actually going through the interpreter, return immediately. + throw new VCardException("Invalid vcard"); + } + + signal.await(VCARD_LOADING_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS); + if (mLoadedResource == null) { + // Maybe null if failed or timeout. + throw new VCardException("Failure or timeout loading vcard"); + } + return mLoadedResource; + } + + @Override + public int getCacheId() { + return BugleMediaCacheManager.VCARD_CACHE; + } + + @SuppressWarnings("unchecked") + @Override + public MediaCache<VCardResource> getMediaCache() { + return (MediaCache<VCardResource>) MediaCacheManager.get().getOrCreateMediaCacheById( + getCacheId()); + } + + @DoesNotRunOnMainThread + private boolean parseVCard(final Uri targetUri, final CountDownLatch signal) { + Assert.isNotMainThread(); + final VCardEntryCounter counter = new VCardEntryCounter(); + final VCardSourceDetector detector = new VCardSourceDetector(); + boolean result; + try { + // We don't know which type should be used to parse the Uri. + // It is possible to misinterpret the vCard, but we expect the parser + // lets VCardSourceDetector detect the type before the misinterpretation. + result = readOneVCardFile(targetUri, VCardConfig.VCARD_TYPE_UNKNOWN, + detector, true, null); + } catch (final VCardNestedException e) { + try { + final int estimatedVCardType = detector.getEstimatedType(); + // Assume that VCardSourceDetector was able to detect the source. + // Try again with the detector. + result = readOneVCardFile(targetUri, estimatedVCardType, + counter, false, null); + } catch (final VCardNestedException e2) { + result = false; + LogUtil.e(LogUtil.BUGLE_TAG, "Must not reach here. " + e2); + } + } + + if (!result) { + // Load failure. + return false; + } + + return doActuallyReadOneVCard(targetUri, true, detector, null, signal); + } + + @DoesNotRunOnMainThread + private boolean doActuallyReadOneVCard(final Uri uri, final boolean showEntryParseProgress, + final VCardSourceDetector detector, final List<String> errorFileNameList, + final CountDownLatch signal) { + Assert.isNotMainThread(); + int vcardType = detector.getEstimatedType(); + if (vcardType == VCardConfig.VCARD_TYPE_UNKNOWN) { + vcardType = VCardConfig.getVCardTypeFromString(DEFAULT_VCARD_TYPE); + } + final CustomVCardEntryConstructor builder = + new CustomVCardEntryConstructor(vcardType, null); + builder.addEntryHandler(new ContactVCardEntryHandler(signal)); + + try { + if (!readOneVCardFile(uri, vcardType, builder, false, null)) { + return false; + } + } catch (final VCardNestedException e) { + LogUtil.e(LogUtil.BUGLE_TAG, "Must not reach here. " + e); + return false; + } + return true; + } + + @DoesNotRunOnMainThread + private boolean readOneVCardFile(final Uri uri, final int vcardType, + final VCardInterpreter interpreter, + final boolean throwNestedException, final List<String> errorFileNameList) + throws VCardNestedException { + Assert.isNotMainThread(); + final ContentResolver resolver = mContext.getContentResolver(); + VCardParser vCardParser; + InputStream is; + try { + is = resolver.openInputStream(uri); + vCardParser = new VCardParser_V21(vcardType); + vCardParser.addInterpreter(interpreter); + + try { + vCardParser.parse(is); + } catch (final VCardVersionException e1) { + try { + is.close(); + } catch (final IOException e) { + // Do nothing. + } + if (interpreter instanceof CustomVCardEntryConstructor) { + // Let the object clean up internal temporal objects, + ((CustomVCardEntryConstructor) interpreter).clear(); + } + + is = resolver.openInputStream(uri); + + try { + vCardParser = new VCardParser_V30(vcardType); + vCardParser.addInterpreter(interpreter); + vCardParser.parse(is); + } catch (final VCardVersionException e2) { + throw new VCardException("vCard with unspported version."); + } + } finally { + if (is != null) { + try { + is.close(); + } catch (final IOException e) { + // Do nothing. + } + } + } + } catch (final IOException e) { + LogUtil.e(LogUtil.BUGLE_TAG, "IOException was emitted: " + e.getMessage()); + + if (errorFileNameList != null) { + errorFileNameList.add(uri.toString()); + } + return false; + } catch (final VCardNotSupportedException e) { + if ((e instanceof VCardNestedException) && throwNestedException) { + throw (VCardNestedException) e; + } + if (errorFileNameList != null) { + errorFileNameList.add(uri.toString()); + } + return false; + } catch (final VCardException e) { + if (errorFileNameList != null) { + errorFileNameList.add(uri.toString()); + } + return false; + } + return true; + } + + class ContactVCardEntryHandler implements CustomVCardEntryConstructor.EntryHandler { + final CountDownLatch mSignal; + + public ContactVCardEntryHandler(final CountDownLatch signal) { + mSignal = signal; + } + + @Override + public void onStart() { + } + + @Override + @DoesNotRunOnMainThread + public void onEntryCreated(final CustomVCardEntry entry) { + Assert.isNotMainThread(); + final String displayName = entry.getDisplayName(); + final List<VCardEntry.PhotoData> photos = entry.getPhotoList(); + Uri avatarUri = null; + if (photos != null && photos.size() > 0) { + // The photo data is in bytes form, so we need to persist it in our temp directory + // so that ContactIconView can load it and display it later + // (and cache it, of course). + for (final VCardEntry.PhotoData photo : photos) { + final byte[] photoBytes = photo.getBytes(); + if (photoBytes != null) { + final InputStream inputStream = new ByteArrayInputStream(photoBytes); + try { + avatarUri = UriUtil.persistContentToScratchSpace(inputStream); + if (avatarUri != null) { + // Just load the first avatar and be done. Want more? wait for V2. + break; + } + } finally { + try { + inputStream.close(); + } catch (final IOException e) { + // Do nothing. + } + } + } + } + } + + // Fall back to generated avatar. + if (avatarUri == null) { + String destination = null; + final List<VCardEntry.PhoneData> phones = entry.getPhoneList(); + if (phones != null && phones.size() > 0) { + destination = PhoneUtils.getDefault().getCanonicalBySystemLocale( + phones.get(0).getNumber()); + } + + if (destination == null) { + final List<VCardEntry.EmailData> emails = entry.getEmailList(); + if (emails != null && emails.size() > 0) { + destination = emails.get(0).getAddress(); + } + } + avatarUri = AvatarUriUtil.createAvatarUri(null, displayName, destination, null); + } + + // Add the loaded vcard to the list. + mLoadedVCards.add(new VCardResourceEntry(entry, avatarUri)); + } + + @Override + public void onEnd() { + // Finished loading all vCard entries, signal the loading thread to proceed with the + // result. + if (mLoadedVCards.size() > 0) { + mLoadedResource = new VCardResource(getKey(), mLoadedVCards); + } + mSignal.countDown(); + } + } + + @Override + public int getRequestType() { + return MediaRequest.REQUEST_LOAD_MEDIA; + } + + @Override + public MediaRequestDescriptor<VCardResource> getDescriptor() { + return mDescriptor; + } +} diff --git a/src/com/android/messaging/datamodel/media/VCardRequestDescriptor.java b/src/com/android/messaging/datamodel/media/VCardRequestDescriptor.java new file mode 100644 index 0000000..4084851 --- /dev/null +++ b/src/com/android/messaging/datamodel/media/VCardRequestDescriptor.java @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel.media; + +import android.content.Context; +import android.net.Uri; + +import com.android.messaging.util.Assert; + +public class VCardRequestDescriptor extends MediaRequestDescriptor<VCardResource> { + public final Uri vCardUri; + + public VCardRequestDescriptor(final Uri vCardUri) { + Assert.notNull(vCardUri); + this.vCardUri = vCardUri; + } + + @Override + public MediaRequest<VCardResource> buildSyncMediaRequest(Context context) { + return new VCardRequest(context, this); + } +} diff --git a/src/com/android/messaging/datamodel/media/VCardResource.java b/src/com/android/messaging/datamodel/media/VCardResource.java new file mode 100644 index 0000000..edf5e88 --- /dev/null +++ b/src/com/android/messaging/datamodel/media/VCardResource.java @@ -0,0 +1,52 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel.media; + +import java.util.List; + +/** + * Holds cached information of VCard contact info. + * The temporarily persisted avatar icon Uri is tied to the VCardResource. As a result, whenever + * the VCardResource is no longer used (i.e. close() is called), we need to asynchronously + * delete the avatar image from temp storage since no one will have reference to the avatar Uri + * again. The next time the same VCard is displayed, since the old resource has been evicted from + * the memory cache, we'll load and persist the avatar icon again. + */ +public class VCardResource extends RefCountedMediaResource { + private final List<VCardResourceEntry> mVCards; + + public VCardResource(final String key, final List<VCardResourceEntry> vcards) { + super(key); + mVCards = vcards; + } + + public List<VCardResourceEntry> getVCards() { + return mVCards; + } + + @Override + public int getMediaSize() { + // Instead of track VCards by size in kilobytes, we track them by count. + return 0; + } + + @Override + protected void close() { + for (final VCardResourceEntry vcard : mVCards) { + vcard.close(); + } + } +} diff --git a/src/com/android/messaging/datamodel/media/VCardResourceEntry.java b/src/com/android/messaging/datamodel/media/VCardResourceEntry.java new file mode 100644 index 0000000..f76b796 --- /dev/null +++ b/src/com/android/messaging/datamodel/media/VCardResourceEntry.java @@ -0,0 +1,389 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel.media; + +import android.content.Intent; +import android.content.res.Resources; +import android.content.res.Resources.NotFoundException; +import android.net.Uri; +import android.provider.ContactsContract.CommonDataKinds.Im; +import android.provider.ContactsContract.CommonDataKinds.Organization; +import android.provider.ContactsContract.CommonDataKinds.Phone; +import android.support.v4.util.ArrayMap; +import android.text.TextUtils; + +import com.android.messaging.Factory; +import com.android.messaging.R; +import com.android.messaging.datamodel.MediaScratchFileProvider; +import com.android.messaging.datamodel.data.PersonItemData; +import com.android.messaging.util.ContactUtil; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.SafeAsyncTask; +import com.android.vcard.VCardEntry; +import com.android.vcard.VCardEntry.EmailData; +import com.android.vcard.VCardEntry.ImData; +import com.android.vcard.VCardEntry.NoteData; +import com.android.vcard.VCardEntry.OrganizationData; +import com.android.vcard.VCardEntry.PhoneData; +import com.android.vcard.VCardEntry.PostalData; +import com.android.vcard.VCardEntry.WebsiteData; +import com.android.vcard.VCardProperty; + +import java.io.UnsupportedEncodingException; +import java.net.URLEncoder; +import java.util.ArrayList; +import java.util.List; + +/** + * Holds one entry item (i.e. a single contact) within a VCard resource. It is able to take + * a VCardEntry and extract relevant information from it. + */ +public class VCardResourceEntry { + public static final String PROPERTY_KIND = "KIND"; + + public static final String KIND_LOCATION = "location"; + + private final List<VCardResourceEntry.VCardResourceEntryDestinationItem> mContactInfo; + private final Uri mAvatarUri; + private final String mDisplayName; + private final CustomVCardEntry mVCard; + + public VCardResourceEntry(final CustomVCardEntry vcard, final Uri avatarUri) { + mContactInfo = getContactInfoFromVCardEntry(vcard); + mDisplayName = getDisplayNameFromVCardEntry(vcard); + mAvatarUri = avatarUri; + mVCard = vcard; + } + + void close() { + // If the avatar image was temporarily saved in the scratch folder, remove that. + if (MediaScratchFileProvider.isMediaScratchSpaceUri(mAvatarUri)) { + SafeAsyncTask.executeOnThreadPool(new Runnable() { + @Override + public void run() { + Factory.get().getApplicationContext().getContentResolver().delete( + mAvatarUri, null, null); + } + }); + } + } + + public String getKind() { + VCardProperty kindProperty = mVCard.getProperty(PROPERTY_KIND); + return kindProperty == null ? null : kindProperty.getRawValue(); + } + + public Uri getAvatarUri() { + return mAvatarUri; + } + + public String getDisplayName() { + return mDisplayName; + } + + public String getDisplayAddress() { + List<PostalData> postalList = mVCard.getPostalList(); + if (postalList == null || postalList.size() < 1) { + return null; + } + + return formatAddress(postalList.get(0)); + } + + public String getNotes() { + List<NoteData> notes = mVCard.getNotes(); + if (notes == null || notes.size() == 0) { + return null; + } + StringBuilder noteBuilder = new StringBuilder(); + for (NoteData note : notes) { + noteBuilder.append(note.getNote()); + } + return noteBuilder.toString(); + } + + /** + * Returns a UI-facing representation that can be bound and consumed by the UI layer to display + * this VCard resource entry. + */ + public PersonItemData getDisplayItem() { + return new PersonItemData() { + @Override + public Uri getAvatarUri() { + return VCardResourceEntry.this.getAvatarUri(); + } + + @Override + public String getDisplayName() { + return VCardResourceEntry.this.getDisplayName(); + } + + @Override + public String getDetails() { + return null; + } + + @Override + public Intent getClickIntent() { + return null; + } + + @Override + public long getContactId() { + return ContactUtil.INVALID_CONTACT_ID; + } + + @Override + public String getLookupKey() { + return null; + } + + @Override + public String getNormalizedDestination() { + return null; + } + }; + } + + public List<VCardResourceEntry.VCardResourceEntryDestinationItem> getContactInfo() { + return mContactInfo; + } + + private static List<VCardResourceEntryDestinationItem> getContactInfoFromVCardEntry( + final VCardEntry vcard) { + final Resources resources = Factory.get().getApplicationContext().getResources(); + final List<VCardResourceEntry.VCardResourceEntryDestinationItem> retList = + new ArrayList<VCardResourceEntry.VCardResourceEntryDestinationItem>(); + if (vcard.getPhoneList() != null) { + for (final PhoneData phone : vcard.getPhoneList()) { + final Intent intent = new Intent(Intent.ACTION_DIAL); + intent.setData(Uri.parse("tel:" + phone.getNumber())); + retList.add(new VCardResourceEntryDestinationItem(phone.getNumber(), + Phone.getTypeLabel(resources, phone.getType(), phone.getLabel()).toString(), + intent)); + } + } + + if (vcard.getEmailList() != null) { + for (final EmailData email : vcard.getEmailList()) { + final Intent intent = new Intent(Intent.ACTION_SENDTO); + intent.setData(Uri.parse("mailto:")); + intent.putExtra(Intent.EXTRA_EMAIL, new String[] { email.getAddress() }); + retList.add(new VCardResourceEntryDestinationItem(email.getAddress(), + Phone.getTypeLabel(resources, email.getType(), + email.getLabel()).toString(), intent)); + } + } + + if (vcard.getPostalList() != null) { + for (final PostalData postalData : vcard.getPostalList()) { + String type; + try { + type = resources. + getStringArray(android.R.array.postalAddressTypes) + [postalData.getType() - 1]; + } catch (final NotFoundException ex) { + type = resources.getStringArray(android.R.array.postalAddressTypes)[2]; + } catch (final Exception e) { + LogUtil.e(LogUtil.BUGLE_TAG, "createContactItem postal Exception:" + e); + type = resources.getStringArray(android.R.array.postalAddressTypes)[2]; + } + Intent intent = new Intent(Intent.ACTION_VIEW); + final String address = formatAddress(postalData); + try { + intent.setData(Uri.parse("geo:0,0?q=" + URLEncoder.encode(address, "UTF-8"))); + } catch (UnsupportedEncodingException e) { + intent = null; + } + + retList.add(new VCardResourceEntryDestinationItem(address, type, intent)); + } + } + + if (vcard.getImList() != null) { + for (final ImData imData : vcard.getImList()) { + String type = null; + try { + type = resources. + getString(Im.getProtocolLabelResource(imData.getProtocol())); + } catch (final NotFoundException ex) { + // Do nothing since this implies an empty label. + } + retList.add(new VCardResourceEntryDestinationItem(imData.getAddress(), type, null)); + } + } + + if (vcard.getOrganizationList() != null) { + for (final OrganizationData organtization : vcard.getOrganizationList()) { + String type = null; + try { + type = resources.getString(Organization.getTypeLabelResource( + organtization.getType())); + } catch (final NotFoundException ex) { + //set other kind as "other" + type = resources.getStringArray(android.R.array.organizationTypes)[1]; + } catch (final Exception e) { + LogUtil.e(LogUtil.BUGLE_TAG, "createContactItem org Exception:" + e); + type = resources.getStringArray(android.R.array.organizationTypes)[1]; + } + retList.add(new VCardResourceEntryDestinationItem( + organtization.getOrganizationName(), type, null)); + } + } + + if (vcard.getWebsiteList() != null) { + for (final WebsiteData web : vcard.getWebsiteList()) { + if (web != null && TextUtils.isGraphic(web.getWebsite())){ + String website = web.getWebsite(); + if (!website.startsWith("http://") && !website.startsWith("https://")) { + // Prefix required for parsing to end up with a scheme and result in + // navigation + website = "http://" + website; + } + final Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(website)); + retList.add(new VCardResourceEntryDestinationItem(web.getWebsite(), null, + intent)); + } + } + } + + if (vcard.getBirthday() != null) { + final String birthday = vcard.getBirthday(); + if (TextUtils.isGraphic(birthday)){ + retList.add(new VCardResourceEntryDestinationItem(birthday, + resources.getString(R.string.vcard_detail_birthday_label), null)); + } + } + + if (vcard.getNotes() != null) { + for (final NoteData note : vcard.getNotes()) { + final ArrayMap<String, String> curChildMap = new ArrayMap<String, String>(); + if (TextUtils.isGraphic(note.getNote())){ + retList.add(new VCardResourceEntryDestinationItem(note.getNote(), + resources.getString(R.string.vcard_detail_notes_label), null)); + } + } + } + return retList; + } + + private static String formatAddress(final PostalData postalData) { + final StringBuilder sb = new StringBuilder(); + final String poBox = postalData.getPobox(); + if (!TextUtils.isEmpty(poBox)) { + sb.append(poBox); + sb.append(" "); + } + final String extendedAddress = postalData.getExtendedAddress(); + if (!TextUtils.isEmpty(extendedAddress)) { + sb.append(extendedAddress); + sb.append(" "); + } + final String street = postalData.getStreet(); + if (!TextUtils.isEmpty(street)) { + sb.append(street); + sb.append(" "); + } + final String localty = postalData.getLocalty(); + if (!TextUtils.isEmpty(localty)) { + sb.append(localty); + sb.append(" "); + } + final String region = postalData.getRegion(); + if (!TextUtils.isEmpty(region)) { + sb.append(region); + sb.append(" "); + } + final String postalCode = postalData.getPostalCode(); + if (!TextUtils.isEmpty(postalCode)) { + sb.append(postalCode); + sb.append(" "); + } + final String country = postalData.getCountry(); + if (!TextUtils.isEmpty(country)) { + sb.append(country); + } + return sb.toString(); + } + + private static String getDisplayNameFromVCardEntry(final VCardEntry vcard) { + String name = vcard.getDisplayName(); + if (name == null) { + vcard.consolidateFields(); + name = vcard.getDisplayName(); + } + return name; + } + + /** + * Represents one entry line (e.g. phone number and phone label) for a single contact. Each + * VCardResourceEntry may hold one or more VCardResourceEntryDestinationItem's. + */ + public static class VCardResourceEntryDestinationItem { + private final String mDisplayDestination; + private final String mDestinationType; + private final Intent mClickIntent; + public VCardResourceEntryDestinationItem(final String displayDestination, + final String destinationType, final Intent clickIntent) { + mDisplayDestination = displayDestination; + mDestinationType = destinationType; + mClickIntent = clickIntent; + } + + /** + * Returns a UI-facing representation that can be bound and consumed by the UI layer to + * display this VCard resource destination entry. + */ + public PersonItemData getDisplayItem() { + return new PersonItemData() { + @Override + public Uri getAvatarUri() { + return null; + } + + @Override + public String getDisplayName() { + return mDisplayDestination; + } + + @Override + public String getDetails() { + return mDestinationType; + } + + @Override + public Intent getClickIntent() { + return mClickIntent; + } + + @Override + public long getContactId() { + return ContactUtil.INVALID_CONTACT_ID; + } + + @Override + public String getLookupKey() { + return null; + } + + @Override + public String getNormalizedDestination() { + return null; + } + }; + } + } +} diff --git a/src/com/android/messaging/datamodel/media/VideoThumbnailRequest.java b/src/com/android/messaging/datamodel/media/VideoThumbnailRequest.java new file mode 100644 index 0000000..f17591c --- /dev/null +++ b/src/com/android/messaging/datamodel/media/VideoThumbnailRequest.java @@ -0,0 +1,78 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.datamodel.media; + +import android.content.ContentResolver; +import android.content.Context; +import android.graphics.Bitmap; +import android.provider.MediaStore.Video.Thumbnails; + +import com.android.messaging.Factory; +import com.android.messaging.util.MediaMetadataRetrieverWrapper; +import com.android.messaging.util.OsUtil; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; + +/** + * Class to request a video thumbnail. + * Users of this class as responsible for checking {@link #shouldShowIncomingVideoThumbnails} + */ +public class VideoThumbnailRequest extends ImageRequest<UriImageRequestDescriptor> { + + public VideoThumbnailRequest(final Context context, + final UriImageRequestDescriptor descriptor) { + super(context, descriptor); + } + + public static boolean shouldShowIncomingVideoThumbnails() { + return OsUtil.isAtLeastM(); + } + + @Override + protected InputStream getInputStreamForResource() throws FileNotFoundException { + return null; + } + + @Override + protected boolean hasBitmapObject() { + return true; + } + + @Override + protected Bitmap getBitmapForResource() throws IOException { + final Long mediaId = mDescriptor.getMediaStoreId(); + Bitmap bitmap = null; + if (mediaId != null) { + final ContentResolver cr = Factory.get().getApplicationContext().getContentResolver(); + bitmap = Thumbnails.getThumbnail(cr, mediaId, Thumbnails.MICRO_KIND, null); + } else { + final MediaMetadataRetrieverWrapper retriever = new MediaMetadataRetrieverWrapper(); + try { + retriever.setDataSource(mDescriptor.uri); + bitmap = retriever.getFrameAtTime(); + } finally { + retriever.release(); + } + } + if (bitmap != null) { + mDescriptor.updateSourceDimensions(bitmap.getWidth(), bitmap.getHeight()); + } + return bitmap; + } +} diff --git a/src/com/android/messaging/datamodel/media/VideoThumbnailRequestDescriptor.java b/src/com/android/messaging/datamodel/media/VideoThumbnailRequestDescriptor.java new file mode 100644 index 0000000..907bb8f --- /dev/null +++ b/src/com/android/messaging/datamodel/media/VideoThumbnailRequestDescriptor.java @@ -0,0 +1,44 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.datamodel.media; + +import android.content.Context; + +import com.android.messaging.util.ImageUtils; +import com.android.messaging.util.UriUtil; + +public class VideoThumbnailRequestDescriptor extends UriImageRequestDescriptor { + protected final long mMediaId; + public VideoThumbnailRequestDescriptor(final long id, String path, int desiredWidth, + int desiredHeight, int sourceWidth, int sourceHeight) { + super(UriUtil.getUriForResourceFile(path), desiredWidth, desiredHeight, sourceWidth, + sourceHeight, false /* canCompress */, false /* isStatic */, + false /* cropToCircle */, + ImageUtils.DEFAULT_CIRCLE_BACKGROUND_COLOR /* circleBackgroundColor */, + ImageUtils.DEFAULT_CIRCLE_STROKE_COLOR /* circleStrokeColor */); + mMediaId = id; + } + + @Override + public MediaRequest<ImageResource> buildSyncMediaRequest(Context context) { + return new VideoThumbnailRequest(context, this); + } + + @Override + public Long getMediaStoreId() { + return mMediaId; + } +} |