diff options
Diffstat (limited to 'src/com/android/messaging/ui')
154 files changed, 33536 insertions, 0 deletions
diff --git a/src/com/android/messaging/ui/AsyncImageView.java b/src/com/android/messaging/ui/AsyncImageView.java new file mode 100644 index 0000000..9aaf0b1 --- /dev/null +++ b/src/com/android/messaging/ui/AsyncImageView.java @@ -0,0 +1,457 @@ +/* + * 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.ui; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Path; +import android.graphics.RectF; +import android.graphics.drawable.ColorDrawable; +import android.graphics.drawable.Drawable; +import android.support.annotation.Nullable; +import android.support.rastermill.FrameSequenceDrawable; +import android.text.TextUtils; +import android.util.AttributeSet; +import android.widget.ImageView; + +import com.android.messaging.R; +import com.android.messaging.datamodel.binding.Binding; +import com.android.messaging.datamodel.binding.BindingBase; +import com.android.messaging.datamodel.media.BindableMediaRequest; +import com.android.messaging.datamodel.media.GifImageResource; +import com.android.messaging.datamodel.media.ImageRequest; +import com.android.messaging.datamodel.media.ImageRequestDescriptor; +import com.android.messaging.datamodel.media.ImageResource; +import com.android.messaging.datamodel.media.MediaRequest; +import com.android.messaging.datamodel.media.MediaResourceManager; +import com.android.messaging.datamodel.media.MediaResourceManager.MediaResourceLoadListener; +import com.android.messaging.util.Assert; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.ThreadUtil; +import com.android.messaging.util.UiUtils; +import com.google.common.annotations.VisibleForTesting; + +import java.util.HashSet; + +/** + * An ImageView used to asynchronously request an image from MediaResourceManager and render it. + */ +public class AsyncImageView extends ImageView implements MediaResourceLoadListener<ImageResource> { + private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG; + // 100ms delay before disposing the image in case the AsyncImageView is re-added to the UI + private static final int DISPOSE_IMAGE_DELAY = 100; + + // AsyncImageView has a 1-1 binding relationship with an ImageRequest instance that requests + // the image from the MediaResourceManager. Since the request is done asynchronously, we + // want to make sure the image view is always bound to the latest image request that it + // issues, so that when the image is loaded, the ImageRequest (which extends BindableData) + // will be able to figure out whether the binding is still valid and whether the loaded image + // should be delivered to the AsyncImageView via onMediaResourceLoaded() callback. + @VisibleForTesting + public final Binding<BindableMediaRequest<ImageResource>> mImageRequestBinding; + + /** True if we want the image to fade in when it loads */ + private boolean mFadeIn; + + /** True if we want the image to reveal (scale) when it loads. When set to true, this + * will take precedence over {@link #mFadeIn} */ + private final boolean mReveal; + + // The corner radius for drawing rounded corners around bitmap. The default value is zero + // (no rounded corners) + private final int mCornerRadius; + private final Path mRoundedCornerClipPath; + private int mClipPathWidth; + private int mClipPathHeight; + + // A placeholder drawable that takes the spot of the image when it's loading. The default + // setting is null (no placeholder). + private final Drawable mPlaceholderDrawable; + protected ImageResource mImageResource; + private final Runnable mDisposeRunnable = new Runnable() { + @Override + public void run() { + if (mImageRequestBinding.isBound()) { + mDetachedRequestDescriptor = (ImageRequestDescriptor) + mImageRequestBinding.getData().getDescriptor(); + } + unbindView(); + releaseImageResource(); + } + }; + + private AsyncImageViewDelayLoader mDelayLoader; + private ImageRequestDescriptor mDetachedRequestDescriptor; + + public AsyncImageView(final Context context, final AttributeSet attrs) { + super(context, attrs); + mImageRequestBinding = BindingBase.createBinding(this); + final TypedArray attr = context.obtainStyledAttributes(attrs, R.styleable.AsyncImageView, + 0, 0); + mFadeIn = attr.getBoolean(R.styleable.AsyncImageView_fadeIn, true); + mReveal = attr.getBoolean(R.styleable.AsyncImageView_reveal, false); + mPlaceholderDrawable = attr.getDrawable(R.styleable.AsyncImageView_placeholderDrawable); + mCornerRadius = attr.getDimensionPixelSize(R.styleable.AsyncImageView_cornerRadius, 0); + mRoundedCornerClipPath = new Path(); + + attr.recycle(); + } + + /** + * The main entrypoint for AsyncImageView to load image resource given an ImageRequestDescriptor + * @param descriptor the request descriptor, or null if no image should be displayed + */ + public void setImageResourceId(@Nullable final ImageRequestDescriptor descriptor) { + final String requestKey = (descriptor == null) ? null : descriptor.getKey(); + if (mImageRequestBinding.isBound()) { + if (TextUtils.equals(mImageRequestBinding.getData().getKey(), requestKey)) { + // Don't re-request the bitmap if the new request is for the same resource. + return; + } + unbindView(); + } + setImage(null); + resetTransientViewStates(); + if (!TextUtils.isEmpty(requestKey)) { + maybeSetupPlaceholderDrawable(descriptor); + final BindableMediaRequest<ImageResource> imageRequest = + descriptor.buildAsyncMediaRequest(getContext(), this); + requestImage(imageRequest); + } + } + + /** + * Sets a delay loader that centrally manages image request delay loading logic. + */ + public void setDelayLoader(final AsyncImageViewDelayLoader delayLoader) { + Assert.isTrue(mDelayLoader == null); + mDelayLoader = delayLoader; + } + + /** + * Called by the delay loader when we can resume image loading. + */ + public void resumeLoading() { + Assert.notNull(mDelayLoader); + Assert.isTrue(mImageRequestBinding.isBound()); + MediaResourceManager.get().requestMediaResourceAsync(mImageRequestBinding.getData()); + } + + /** + * Setup the placeholder drawable if: + * 1. There's an image to be loaded AND + * 2. We are given a placeholder drawable AND + * 3. The descriptor provided us with source width and height. + */ + private void maybeSetupPlaceholderDrawable(final ImageRequestDescriptor descriptor) { + if (!TextUtils.isEmpty(descriptor.getKey()) && mPlaceholderDrawable != null) { + if (descriptor.sourceWidth != ImageRequest.UNSPECIFIED_SIZE && + descriptor.sourceHeight != ImageRequest.UNSPECIFIED_SIZE) { + // Set a transparent inset drawable to the foreground so it will mimick the final + // size of the image, and use the background to show the actual placeholder + // drawable. + setImageDrawable(PlaceholderInsetDrawable.fromDrawable( + new ColorDrawable(Color.TRANSPARENT), + descriptor.sourceWidth, descriptor.sourceHeight)); + } + setBackground(mPlaceholderDrawable); + } + } + + protected void setImage(final ImageResource resource) { + setImage(resource, false /* isCached */); + } + + protected void setImage(final ImageResource resource, final boolean isCached) { + // Switch reference to the new ImageResource. Make sure we release the current + // resource and addRef() on the new resource so that the underlying bitmaps don't + // get leaked or get recycled by the bitmap cache. + releaseImageResource(); + // Ensure that any pending dispose runnables get removed. + ThreadUtil.getMainThreadHandler().removeCallbacks(mDisposeRunnable); + // The drawable may require work to get if its a static object so try to only make this call + // once. + final Drawable drawable = (resource != null) ? resource.getDrawable(getResources()) : null; + if (drawable != null) { + mImageResource = resource; + mImageResource.addRef(); + setImageDrawable(drawable); + if (drawable instanceof FrameSequenceDrawable) { + ((FrameSequenceDrawable) drawable).start(); + } + + if (getVisibility() == VISIBLE) { + if (mReveal) { + setVisibility(INVISIBLE); + UiUtils.revealOrHideViewWithAnimation(this, VISIBLE, null); + } else if (mFadeIn && !isCached) { + // Hide initially to avoid flash. + setAlpha(0F); + animate().alpha(1F).start(); + } + } + + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + if (mImageResource instanceof GifImageResource) { + LogUtil.v(TAG, "setImage size unknown -- it's a GIF"); + } else { + LogUtil.v(TAG, "setImage size: " + mImageResource.getMediaSize() + + " width: " + mImageResource.getBitmap().getWidth() + + " heigh: " + mImageResource.getBitmap().getHeight()); + } + } + } + invalidate(); + } + + private void requestImage(final BindableMediaRequest<ImageResource> request) { + mImageRequestBinding.bind(request); + if (mDelayLoader == null || !mDelayLoader.isDelayLoadingImage()) { + MediaResourceManager.get().requestMediaResourceAsync(request); + } else { + mDelayLoader.registerView(this); + } + } + + @Override + public void onMediaResourceLoaded(final MediaRequest<ImageResource> request, + final ImageResource resource, final boolean isCached) { + if (mImageResource != resource) { + setImage(resource, isCached); + } + } + + @Override + public void onMediaResourceLoadError( + final MediaRequest<ImageResource> request, final Exception exception) { + // Media load failed, unbind and reset bitmap to default. + unbindView(); + setImage(null); + } + + private void releaseImageResource() { + final Drawable drawable = getDrawable(); + if (drawable instanceof FrameSequenceDrawable) { + ((FrameSequenceDrawable) drawable).stop(); + ((FrameSequenceDrawable) drawable).destroy(); + } + if (mImageResource != null) { + mImageResource.release(); + mImageResource = null; + } + setImageDrawable(null); + setBackground(null); + } + + /** + * Resets transient view states (eg. alpha, animations) before rebinding/reusing the view. + */ + private void resetTransientViewStates() { + clearAnimation(); + setAlpha(1F); + } + + @Override + protected void onAttachedToWindow() { + super.onAttachedToWindow(); + // If it was recently removed, then cancel disposing, we're still using it. + ThreadUtil.getMainThreadHandler().removeCallbacks(mDisposeRunnable); + + // When the image view gets detached and immediately re-attached, any fade-in animation + // will be terminated, leaving the view in a semi-transparent state. Make sure we restore + // alpha when the view is re-attached. + if (mFadeIn) { + setAlpha(1F); + } + + // Check whether we are in a simple reuse scenario: detached from window, and reattached + // later without rebinding. This may be done by containers such as the RecyclerView to + // reuse the views. In this case, we would like to rebind the original image request. + if (!mImageRequestBinding.isBound() && mDetachedRequestDescriptor != null) { + setImageResourceId(mDetachedRequestDescriptor); + } + mDetachedRequestDescriptor = null; + } + + @Override + protected void onDetachedFromWindow() { + super.onDetachedFromWindow(); + // Dispose the bitmap, but if an AysncImageView is removed from the window, then quickly + // re-added, we shouldn't dispose, so wait a short time before disposing + ThreadUtil.getMainThreadHandler().postDelayed(mDisposeRunnable, DISPOSE_IMAGE_DELAY); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + + // The base implementation does not honor the minimum sizes. We try to to honor it here. + + final int measuredWidth = getMeasuredWidth(); + final int measuredHeight = getMeasuredHeight(); + if (measuredWidth >= getMinimumWidth() || measuredHeight >= getMinimumHeight()) { + // We are ok if either of the minimum sizes is honored. Note that satisfying both the + // sizes may not be possible, depending on the aspect ratio of the image and whether + // a maximum size has been specified. This implementation only tries to handle the case + // where both the minimum sizes are not being satisfied. + return; + } + + if (!getAdjustViewBounds()) { + // The base implementation is reasonable in this case. If the view bounds cannot be + // changed, it is not possible to satisfy the minimum sizes anyway. + return; + } + + final int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec); + final int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec); + if (widthSpecMode == MeasureSpec.EXACTLY && heightSpecMode == MeasureSpec.EXACTLY) { + // The base implementation is reasonable in this case. + return; + } + + int width = measuredWidth; + int height = measuredHeight; + // Get the minimum sizes that will honor other constraints as well. + final int minimumWidth = resolveSize( + getMinimumWidth(), getMaxWidth(), widthMeasureSpec); + final int minimumHeight = resolveSize( + getMinimumHeight(), getMaxHeight(), heightMeasureSpec); + final float aspectRatio = measuredWidth / (float) measuredHeight; + if (aspectRatio == 0) { + // If the image is (close to) infinitely high, there is not much we can do. + return; + } + + if (width < minimumWidth) { + height = resolveSize((int) (minimumWidth / aspectRatio), + getMaxHeight(), heightMeasureSpec); + width = (int) (height * aspectRatio); + } + + if (height < minimumHeight) { + width = resolveSize((int) (minimumHeight * aspectRatio), + getMaxWidth(), widthMeasureSpec); + height = (int) (width / aspectRatio); + } + + setMeasuredDimension(width, height); + } + + private static int resolveSize(int desiredSize, int maxSize, int measureSpec) { + final int specMode = MeasureSpec.getMode(measureSpec); + final int specSize = MeasureSpec.getSize(measureSpec); + switch(specMode) { + case MeasureSpec.UNSPECIFIED: + return Math.min(desiredSize, maxSize); + + case MeasureSpec.AT_MOST: + return Math.min(Math.min(desiredSize, specSize), maxSize); + + default: + Assert.fail("Unreachable"); + return specSize; + } + } + + @Override + protected void onDraw(final Canvas canvas) { + if (mCornerRadius > 0) { + final int currentWidth = this.getWidth(); + final int currentHeight = this.getHeight(); + if (mClipPathWidth != currentWidth || mClipPathHeight != currentHeight) { + final RectF rect = new RectF(0, 0, currentWidth, currentHeight); + mRoundedCornerClipPath.reset(); + mRoundedCornerClipPath.addRoundRect(rect, mCornerRadius, mCornerRadius, + Path.Direction.CW); + mClipPathWidth = currentWidth; + mClipPathHeight = currentHeight; + } + + final int saveCount = canvas.getSaveCount(); + canvas.save(); + canvas.clipPath(mRoundedCornerClipPath); + super.onDraw(canvas); + canvas.restoreToCount(saveCount); + } else { + super.onDraw(canvas); + } + } + + private void unbindView() { + if (mImageRequestBinding.isBound()) { + mImageRequestBinding.unbind(); + if (mDelayLoader != null) { + mDelayLoader.unregisterView(this); + } + } + } + + /** + * As a performance optimization, the consumer of the AsyncImageView may opt to delay loading + * the image when it's busy doing other things (such as when a list view is scrolling). In + * order to do this, the consumer can create a new AsyncImageViewDelayLoader instance to be + * shared among all relevant AsyncImageViews (through setDelayLoader() method), and call + * onStartDelayLoading() and onStopDelayLoading() to start and stop delay loading, respectively. + */ + public static class AsyncImageViewDelayLoader { + private boolean mShouldDelayLoad; + private final HashSet<AsyncImageView> mAttachedViews; + + public AsyncImageViewDelayLoader() { + mAttachedViews = new HashSet<AsyncImageView>(); + } + + private void registerView(final AsyncImageView view) { + mAttachedViews.add(view); + } + + private void unregisterView(final AsyncImageView view) { + mAttachedViews.remove(view); + } + + public boolean isDelayLoadingImage() { + return mShouldDelayLoad; + } + + /** + * Called by the consumer of this view to delay loading images + */ + public void onDelayLoading() { + // Don't need to explicitly tell the AsyncImageView to stop loading since + // ImageRequests are not cancellable. + mShouldDelayLoad = true; + } + + /** + * Called by the consumer of this view to resume loading images + */ + public void onResumeLoading() { + if (mShouldDelayLoad) { + mShouldDelayLoad = false; + + // Notify all attached views to resume loading. + for (final AsyncImageView view : mAttachedViews) { + view.resumeLoading(); + } + mAttachedViews.clear(); + } + } + } +} diff --git a/src/com/android/messaging/ui/AttachmentPreview.java b/src/com/android/messaging/ui/AttachmentPreview.java new file mode 100644 index 0000000..7eea14b --- /dev/null +++ b/src/com/android/messaging/ui/AttachmentPreview.java @@ -0,0 +1,331 @@ +/* + * 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.ui; + +import android.animation.Animator; +import android.animation.ObjectAnimator; +import android.content.Context; +import android.graphics.Rect; +import android.os.Handler; +import android.os.Looper; +import android.util.AttributeSet; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.FrameLayout; +import android.widget.ImageButton; +import android.widget.ScrollView; + +import com.android.messaging.R; +import com.android.messaging.annotation.VisibleForAnimation; +import com.android.messaging.datamodel.data.DraftMessageData; +import com.android.messaging.datamodel.data.MediaPickerMessagePartData; +import com.android.messaging.datamodel.data.MessagePartData; +import com.android.messaging.datamodel.data.PendingAttachmentData; +import com.android.messaging.ui.MultiAttachmentLayout.OnAttachmentClickListener; +import com.android.messaging.ui.animation.PopupTransitionAnimation; +import com.android.messaging.ui.conversation.ComposeMessageView; +import com.android.messaging.ui.conversation.ConversationFragment; +import com.android.messaging.util.Assert; +import com.android.messaging.util.ThreadUtil; +import com.android.messaging.util.UiUtils; + +import java.util.ArrayList; +import java.util.List; + +public class AttachmentPreview extends ScrollView implements OnAttachmentClickListener { + private FrameLayout mAttachmentView; + private ComposeMessageView mComposeMessageView; + private ImageButton mCloseButton; + private int mAnimatedHeight = -1; + private Animator mCloseGapAnimator; + private boolean mPendingFirstUpdate; + private Handler mHandler; + private Runnable mHideRunnable; + private boolean mPendingHideCanceled; + + private static final int CLOSE_BUTTON_REVEAL_STAGGER_MILLIS = 300; + + public AttachmentPreview(final Context context, final AttributeSet attrs) { + super(context, attrs); + mHandler = new Handler(Looper.getMainLooper()); + } + + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + mCloseButton = (ImageButton) findViewById(R.id.close_button); + mCloseButton.setOnClickListener(new OnClickListener() { + @Override + public void onClick(final View view) { + mComposeMessageView.clearAttachments(); + } + }); + + mAttachmentView = (FrameLayout) findViewById(R.id.attachment_view); + + // The attachment preview is a scroll view so that it can show the bottom portion of the + // attachment whenever the space is tight (e.g. when in landscape mode). Per design + // request we'd like to make the attachment view always scrolled to the bottom. + addOnLayoutChangeListener(new OnLayoutChangeListener() { + @Override + public void onLayoutChange(final View v, final int left, final int top, final int right, + final int bottom, final int oldLeft, final int oldTop, final int oldRight, + final int oldBottom) { + post(new Runnable() { + @Override + public void run() { + final int childCount = getChildCount(); + if (childCount > 0) { + final View lastChild = getChildAt(childCount - 1); + scrollTo(getScrollX(), lastChild.getBottom() - getHeight()); + } + } + }); + } + }); + mPendingFirstUpdate = true; + } + + public void setComposeMessageView(final ComposeMessageView composeMessageView) { + mComposeMessageView = composeMessageView; + } + + @Override + protected void onMeasure(final int widthMeasureSpec, final int heightMeasureSpec) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + if (mAnimatedHeight >= 0) { + setMeasuredDimension(getMeasuredWidth(), mAnimatedHeight); + } + } + + private void cancelPendingHide() { + mPendingHideCanceled = true; + } + + public void hideAttachmentPreview() { + if (getVisibility() != GONE) { + UiUtils.revealOrHideViewWithAnimation(mCloseButton, GONE, + null /* onFinishRunnable */); + startCloseGapAnimationOnAttachmentClear(); + + if (mAttachmentView.getChildCount() > 0) { + mPendingHideCanceled = false; + final View viewToHide = mAttachmentView.getChildCount() > 1 ? + mAttachmentView : mAttachmentView.getChildAt(0); + UiUtils.revealOrHideViewWithAnimation(viewToHide, INVISIBLE, + new Runnable() { + @Override + public void run() { + // Only hide if we are didn't get overruled by showing + if (!mPendingHideCanceled) { + mAttachmentView.removeAllViews(); + setVisibility(GONE); + } + } + }); + } else { + mAttachmentView.removeAllViews(); + setVisibility(GONE); + } + } + } + + // returns true if we have attachments + public boolean onAttachmentsChanged(final DraftMessageData draftMessageData) { + final boolean isFirstUpdate = mPendingFirstUpdate; + final List<MessagePartData> attachments = draftMessageData.getReadOnlyAttachments(); + final List<PendingAttachmentData> pendingAttachments = + draftMessageData.getReadOnlyPendingAttachments(); + + // Any change in attachments would invalidate the animated height animation. + cancelCloseGapAnimation(); + mPendingFirstUpdate = false; + + final int combinedAttachmentCount = attachments.size() + pendingAttachments.size(); + mCloseButton.setContentDescription(getResources() + .getQuantityString(R.plurals.attachment_preview_close_content_description, + combinedAttachmentCount)); + if (combinedAttachmentCount == 0) { + mHideRunnable = new Runnable() { + @Override + public void run() { + mHideRunnable = null; + // Only start the hiding if there are still no attachments + if (attachments.size() + pendingAttachments.size() == 0) { + hideAttachmentPreview(); + } + } + }; + if (draftMessageData.isSending()) { + // Wait to hide until the message is ready to start animating + // We'll execute immediately when the animation triggers + mHandler.postDelayed(mHideRunnable, + ConversationFragment.MESSAGE_ANIMATION_MAX_WAIT); + } else { + // Run immediately when clearing attachments + mHideRunnable.run(); + } + return false; + } + + cancelPendingHide(); // We're showing + if (getVisibility() != VISIBLE) { + setVisibility(VISIBLE); + mAttachmentView.setVisibility(VISIBLE); + + // Don't animate in the close button if this is the first update after view creation. + // This is the initial draft load from database for pre-existing drafts. + if (!isFirstUpdate) { + // Reveal the close button after the view animates in. + mCloseButton.setVisibility(INVISIBLE); + ThreadUtil.getMainThreadHandler().postDelayed(new Runnable() { + @Override + public void run() { + UiUtils.revealOrHideViewWithAnimation(mCloseButton, VISIBLE, + null /* onFinishRunnable */); + } + }, UiUtils.MEDIAPICKER_TRANSITION_DURATION + CLOSE_BUTTON_REVEAL_STAGGER_MILLIS); + } + } + + // Merge the pending attachment list with real attachment. Design would prefer these be + // in LIFO order user can see added images past the 5th one but we also want them to be in + // order and we want it to be WYSIWYG. + final List<MessagePartData> combinedAttachments = new ArrayList<>(); + combinedAttachments.addAll(attachments); + combinedAttachments.addAll(pendingAttachments); + + final LayoutInflater layoutInflater = LayoutInflater.from(getContext()); + if (combinedAttachmentCount > 1) { + MultiAttachmentLayout multiAttachmentLayout = null; + Rect transitionRect = null; + if (mAttachmentView.getChildCount() > 0) { + final View firstChild = mAttachmentView.getChildAt(0); + if (firstChild instanceof MultiAttachmentLayout) { + Assert.equals(1, mAttachmentView.getChildCount()); + multiAttachmentLayout = (MultiAttachmentLayout) firstChild; + multiAttachmentLayout.bindAttachments(combinedAttachments, + null /* transitionRect */, combinedAttachmentCount); + } else { + transitionRect = new Rect(firstChild.getLeft(), firstChild.getTop(), + firstChild.getRight(), firstChild.getBottom()); + } + } + if (multiAttachmentLayout == null) { + multiAttachmentLayout = AttachmentPreviewFactory.createMultiplePreview( + getContext(), this); + multiAttachmentLayout.bindAttachments(combinedAttachments, transitionRect, + combinedAttachmentCount); + mAttachmentView.removeAllViews(); + mAttachmentView.addView(multiAttachmentLayout); + } + } else { + final MessagePartData attachment = combinedAttachments.get(0); + boolean shouldAnimate = true; + if (mAttachmentView.getChildCount() > 0) { + // If we are going from N->1 attachments, try to use the current bounds + // bounds as the starting rect. + shouldAnimate = false; + final View firstChild = mAttachmentView.getChildAt(0); + if (firstChild instanceof MultiAttachmentLayout && + attachment instanceof MediaPickerMessagePartData) { + final View leftoverView = ((MultiAttachmentLayout) firstChild) + .findViewForAttachment(attachment); + if (leftoverView != null) { + final Rect currentRect = UiUtils.getMeasuredBoundsOnScreen(leftoverView); + if (!currentRect.isEmpty() && + attachment instanceof MediaPickerMessagePartData) { + ((MediaPickerMessagePartData) attachment).setStartRect(currentRect); + shouldAnimate = true; + } + } + } + } + mAttachmentView.removeAllViews(); + final View attachmentView = AttachmentPreviewFactory.createAttachmentPreview( + layoutInflater, attachment, mAttachmentView, + AttachmentPreviewFactory.TYPE_SINGLE, true /* startImageRequest */, this); + if (attachmentView != null) { + mAttachmentView.addView(attachmentView); + if (shouldAnimate) { + tryAnimateViewIn(attachment, attachmentView); + } + } + } + return true; + } + + public void onMessageAnimationStart() { + if (mHideRunnable == null) { + return; + } + + // Run the hide animation at the same time as the message animation + mHandler.removeCallbacks(mHideRunnable); + setVisibility(View.INVISIBLE); + mHideRunnable.run(); + } + + static void tryAnimateViewIn(final MessagePartData attachmentData, final View view) { + if (attachmentData instanceof MediaPickerMessagePartData) { + final Rect startRect = ((MediaPickerMessagePartData) attachmentData).getStartRect(); + new PopupTransitionAnimation(startRect, view).startAfterLayoutComplete(); + } + } + + @VisibleForAnimation + public void setAnimatedHeight(final int animatedHeight) { + if (mAnimatedHeight != animatedHeight) { + mAnimatedHeight = animatedHeight; + requestLayout(); + } + } + + /** + * Kicks off an animation to animate the layout change for closing the gap between the + * message list and the compose message box when the attachments are cleared. + */ + private void startCloseGapAnimationOnAttachmentClear() { + // Cancel existing animation. + cancelCloseGapAnimation(); + mCloseGapAnimator = ObjectAnimator.ofInt(this, "animatedHeight", getHeight(), 0); + mCloseGapAnimator.start(); + } + + private void cancelCloseGapAnimation() { + if (mCloseGapAnimator != null) { + mCloseGapAnimator.cancel(); + mCloseGapAnimator = null; + } + mAnimatedHeight = -1; + } + + @Override + public boolean onAttachmentClick(final MessagePartData attachment, + final Rect viewBoundsOnScreen, final boolean longPress) { + if (longPress) { + mComposeMessageView.onAttachmentPreviewLongClicked(); + return true; + } + + if (!(attachment instanceof PendingAttachmentData) && attachment.isImage()) { + mComposeMessageView.displayPhoto(attachment.getContentUri(), viewBoundsOnScreen); + return true; + } + return false; + } +} diff --git a/src/com/android/messaging/ui/AttachmentPreviewFactory.java b/src/com/android/messaging/ui/AttachmentPreviewFactory.java new file mode 100644 index 0000000..ed5d4d7 --- /dev/null +++ b/src/com/android/messaging/ui/AttachmentPreviewFactory.java @@ -0,0 +1,299 @@ +/* + * 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.ui; + +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Rect; +import android.net.Uri; +import android.support.annotation.Nullable; +import android.text.TextUtils; +import android.view.LayoutInflater; +import android.view.View; +import android.view.View.OnClickListener; +import android.view.View.OnLongClickListener; +import android.view.ViewGroup; +import android.widget.FrameLayout.LayoutParams; +import android.widget.ImageView; +import android.widget.TextView; + +import com.android.messaging.R; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.data.MessagePartData; +import com.android.messaging.datamodel.data.PendingAttachmentData; +import com.android.messaging.datamodel.data.PersonItemData; +import com.android.messaging.datamodel.data.VCardContactItemData; +import com.android.messaging.datamodel.media.FileImageRequestDescriptor; +import com.android.messaging.datamodel.media.ImageRequest; +import com.android.messaging.datamodel.media.ImageRequestDescriptor; +import com.android.messaging.datamodel.media.UriImageRequestDescriptor; +import com.android.messaging.ui.MultiAttachmentLayout.OnAttachmentClickListener; +import com.android.messaging.ui.PersonItemView.PersonItemViewListener; +import com.android.messaging.util.Assert; +import com.android.messaging.util.ContentType; +import com.android.messaging.util.ImageUtils; +import com.android.messaging.util.UiUtils; +import com.android.messaging.util.UriUtil; + +/** + * A view factory that creates previews for single/multiple attachments. + */ +public class AttachmentPreviewFactory { + /** Standalone attachment preview */ + public static final int TYPE_SINGLE = 1; + + /** Attachment preview displayed in a multi-attachment layout */ + public static final int TYPE_MULTIPLE = 2; + + /** Attachment preview displayed in the attachment chooser grid view */ + public static final int TYPE_CHOOSER_GRID = 3; + + public static View createAttachmentPreview(final LayoutInflater layoutInflater, + final MessagePartData attachmentData, final ViewGroup parent, + final int viewType, final boolean startImageRequest, + @Nullable final OnAttachmentClickListener clickListener) { + final String contentType = attachmentData.getContentType(); + View attachmentView = null; + if (attachmentData instanceof PendingAttachmentData) { + attachmentView = createPendingAttachmentPreview(layoutInflater, parent, + (PendingAttachmentData) attachmentData); + } else if (ContentType.isImageType(contentType)) { + attachmentView = createImagePreview(layoutInflater, attachmentData, parent, viewType, + startImageRequest); + } else if (ContentType.isAudioType(contentType)) { + attachmentView = createAudioPreview(layoutInflater, attachmentData, parent, viewType); + } else if (ContentType.isVideoType(contentType)) { + attachmentView = createVideoPreview(layoutInflater, attachmentData, parent, viewType); + } else if (ContentType.isVCardType(contentType)) { + attachmentView = createVCardPreview(layoutInflater, attachmentData, parent, viewType); + } else { + Assert.fail("unsupported attachment type: " + contentType); + return null; + } + + // Some views have a caption, set the text/visibility if one exists + final TextView captionView = (TextView) attachmentView.findViewById(R.id.caption); + if (captionView != null) { + final String caption = attachmentData.getText(); + captionView.setVisibility(TextUtils.isEmpty(caption) ? View.GONE : View.VISIBLE); + captionView.setText(caption); + } + + if (attachmentView != null && clickListener != null) { + attachmentView.setOnClickListener(new OnClickListener() { + @Override + public void onClick(final View view) { + final Rect bounds = UiUtils.getMeasuredBoundsOnScreen(view); + clickListener.onAttachmentClick(attachmentData, bounds, + false /* longPress */); + } + }); + attachmentView.setOnLongClickListener(new OnLongClickListener() { + @Override + public boolean onLongClick(final View view) { + final Rect bounds = UiUtils.getMeasuredBoundsOnScreen(view); + return clickListener.onAttachmentClick(attachmentData, bounds, + true /* longPress */); + } + }); + } + return attachmentView; + } + + public static MultiAttachmentLayout createMultiplePreview(final Context context, + final OnAttachmentClickListener listener) { + final MultiAttachmentLayout multiAttachmentLayout = + new MultiAttachmentLayout(context, null); + final ViewGroup.LayoutParams layoutParams = new ViewGroup.LayoutParams( + LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT); + multiAttachmentLayout.setLayoutParams(layoutParams); + multiAttachmentLayout.setOnAttachmentClickListener(listener); + return multiAttachmentLayout; + } + + public static ImageRequestDescriptor getImageRequestDescriptorForAttachment( + final MessagePartData attachmentData, final int desiredWidth, final int desiredHeight) { + final Uri uri = attachmentData.getContentUri(); + final String contentType = attachmentData.getContentType(); + if (ContentType.isImageType(contentType)) { + final String filePath = UriUtil.getFilePathFromUri(uri); + if (filePath != null) { + return new FileImageRequestDescriptor(filePath, desiredWidth, desiredHeight, + attachmentData.getWidth(), attachmentData.getHeight(), + false /* canUseThumbnail */, true /* allowCompression */, + false /* isStatic */); + } else { + return new UriImageRequestDescriptor(uri, desiredWidth, desiredHeight, + attachmentData.getWidth(), attachmentData.getHeight(), + true /* allowCompression */, false /* isStatic */, false /*cropToCircle*/, + ImageUtils.DEFAULT_CIRCLE_BACKGROUND_COLOR /* circleBackgroundColor */, + ImageUtils.DEFAULT_CIRCLE_STROKE_COLOR /* circleStrokeColor */); + } + } + return null; + } + + private static View createImagePreview(final LayoutInflater layoutInflater, + final MessagePartData attachmentData, final ViewGroup parent, + final int viewType, final boolean startImageRequest) { + int layoutId = R.layout.attachment_single_image; + switch (viewType) { + case AttachmentPreviewFactory.TYPE_SINGLE: + layoutId = R.layout.attachment_single_image; + break; + case AttachmentPreviewFactory.TYPE_MULTIPLE: + layoutId = R.layout.attachment_multiple_image; + break; + case AttachmentPreviewFactory.TYPE_CHOOSER_GRID: + layoutId = R.layout.attachment_chooser_image; + break; + default: + Assert.fail("unsupported attachment view type!"); + break; + } + final View view = layoutInflater.inflate(layoutId, parent, false /* attachToRoot */); + final AsyncImageView imageView = (AsyncImageView) view.findViewById( + R.id.attachment_image_view); + int maxWidth = imageView.getMaxWidth(); + int maxHeight = imageView.getMaxHeight(); + if (viewType == TYPE_CHOOSER_GRID) { + final Resources resources = layoutInflater.getContext().getResources(); + maxWidth = maxHeight = resources.getDimensionPixelSize( + R.dimen.attachment_grid_image_cell_size); + } + if (maxWidth <= 0 || maxWidth == Integer.MAX_VALUE) { + maxWidth = ImageRequest.UNSPECIFIED_SIZE; + } + if (maxHeight <= 0 || maxHeight == Integer.MAX_VALUE) { + maxHeight = ImageRequest.UNSPECIFIED_SIZE; + } + if (startImageRequest) { + imageView.setImageResourceId(getImageRequestDescriptorForAttachment(attachmentData, + maxWidth, maxHeight)); + } + imageView.setContentDescription( + parent.getResources().getString(R.string.message_image_content_description)); + return view; + } + + private static View createPendingAttachmentPreview(final LayoutInflater layoutInflater, + final ViewGroup parent, final PendingAttachmentData attachmentData) { + final View pendingItemView = layoutInflater.inflate(R.layout.attachment_pending_item, + parent, false); + final ImageView imageView = (ImageView) + pendingItemView.findViewById(R.id.pending_item_view); + final ViewGroup.LayoutParams layoutParams = imageView.getLayoutParams(); + final int defaultSize = layoutInflater.getContext().getResources().getDimensionPixelSize( + R.dimen.pending_attachment_size); + layoutParams.width = attachmentData.getWidth() == MessagePartData.UNSPECIFIED_SIZE ? + defaultSize : attachmentData.getWidth(); + layoutParams.height = attachmentData.getHeight() == MessagePartData.UNSPECIFIED_SIZE ? + defaultSize : attachmentData.getHeight(); + return pendingItemView; + } + + private static View createVCardPreview(final LayoutInflater layoutInflater, + final MessagePartData attachmentData, final ViewGroup parent, + final int viewType) { + int layoutId = R.layout.attachment_single_vcard; + switch (viewType) { + case AttachmentPreviewFactory.TYPE_SINGLE: + layoutId = R.layout.attachment_single_vcard; + break; + case AttachmentPreviewFactory.TYPE_MULTIPLE: + layoutId = R.layout.attachment_multiple_vcard; + break; + case AttachmentPreviewFactory.TYPE_CHOOSER_GRID: + layoutId = R.layout.attachment_chooser_vcard; + break; + default: + Assert.fail("unsupported attachment view type!"); + break; + } + final View view = layoutInflater.inflate(layoutId, parent, false /* attachToRoot */); + final PersonItemView vcardPreview = (PersonItemView) view.findViewById( + R.id.vcard_attachment_view); + vcardPreview.setAvatarOnly(viewType != AttachmentPreviewFactory.TYPE_SINGLE); + vcardPreview.bind(DataModel.get().createVCardContactItemData(layoutInflater.getContext(), + attachmentData)); + vcardPreview.setListener(new PersonItemViewListener() { + @Override + public void onPersonClicked(final PersonItemData data) { + Assert.isTrue(data instanceof VCardContactItemData); + final VCardContactItemData vCardData = (VCardContactItemData) data; + if (vCardData.hasValidVCard()) { + final Uri vCardUri = vCardData.getVCardUri(); + UIIntents.get().launchVCardDetailActivity(vcardPreview.getContext(), vCardUri); + } + } + + @Override + public boolean onPersonLongClicked(final PersonItemData data) { + return false; + } + }); + return view; + } + + private static View createAudioPreview(final LayoutInflater layoutInflater, + final MessagePartData attachmentData, final ViewGroup parent, + final int viewType) { + int layoutId = R.layout.attachment_single_audio; + switch (viewType) { + case AttachmentPreviewFactory.TYPE_SINGLE: + layoutId = R.layout.attachment_single_audio; + break; + case AttachmentPreviewFactory.TYPE_MULTIPLE: + layoutId = R.layout.attachment_multiple_audio; + break; + case AttachmentPreviewFactory.TYPE_CHOOSER_GRID: + layoutId = R.layout.attachment_chooser_audio; + break; + default: + Assert.fail("unsupported attachment view type!"); + break; + } + final View view = layoutInflater.inflate(layoutId, parent, false /* attachToRoot */); + final AudioAttachmentView audioView = (AudioAttachmentView) + view.findViewById(R.id.audio_attachment_view); + audioView.bindMessagePartData(attachmentData, false /* incoming */); + return view; + } + + private static View createVideoPreview(final LayoutInflater layoutInflater, + final MessagePartData attachmentData, final ViewGroup parent, + final int viewType) { + int layoutId = R.layout.attachment_single_video; + switch (viewType) { + case AttachmentPreviewFactory.TYPE_SINGLE: + layoutId = R.layout.attachment_single_video; + break; + case AttachmentPreviewFactory.TYPE_MULTIPLE: + layoutId = R.layout.attachment_multiple_video; + break; + case AttachmentPreviewFactory.TYPE_CHOOSER_GRID: + layoutId = R.layout.attachment_chooser_video; + break; + default: + Assert.fail("unsupported attachment view type!"); + break; + } + final VideoThumbnailView videoThumbnail = (VideoThumbnailView) layoutInflater.inflate( + layoutId, parent, false /* attachToRoot */); + videoThumbnail.setSource(attachmentData, false /* incomingMessage */); + return videoThumbnail; + } +} diff --git a/src/com/android/messaging/ui/AudioAttachmentPlayPauseButton.java b/src/com/android/messaging/ui/AudioAttachmentPlayPauseButton.java new file mode 100644 index 0000000..724c4fe --- /dev/null +++ b/src/com/android/messaging/ui/AudioAttachmentPlayPauseButton.java @@ -0,0 +1,59 @@ +/* + * 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.ui; + +import android.content.Context; +import android.util.AttributeSet; +import android.widget.ImageView; +import android.widget.ViewSwitcher; + +import com.android.messaging.R; + +/** + * Shows a tinted play pause button. + */ +public class AudioAttachmentPlayPauseButton extends ViewSwitcher { + private ImageView mPlayButton; + private ImageView mPauseButton; + + private boolean mShowAsIncoming; + + public AudioAttachmentPlayPauseButton(final Context context, final AttributeSet attrs) { + super(context, attrs); + } + + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + mPlayButton = (ImageView) findViewById(R.id.play_button); + mPauseButton = (ImageView) findViewById(R.id.pause_button); + updateAppearance(); + } + + public void setVisualStyle(final boolean showAsIncoming) { + if (mShowAsIncoming != showAsIncoming) { + mShowAsIncoming = showAsIncoming; + updateAppearance(); + } + } + + private void updateAppearance() { + mPlayButton.setImageDrawable(ConversationDrawables.get() + .getPlayButtonDrawable(mShowAsIncoming)); + mPauseButton.setImageDrawable(ConversationDrawables.get() + .getPauseButtonDrawable(mShowAsIncoming)); + } +} diff --git a/src/com/android/messaging/ui/AudioAttachmentView.java b/src/com/android/messaging/ui/AudioAttachmentView.java new file mode 100644 index 0000000..bb649b0 --- /dev/null +++ b/src/com/android/messaging/ui/AudioAttachmentView.java @@ -0,0 +1,329 @@ +/* + * 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.ui; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Canvas; +import android.graphics.Path; +import android.graphics.RectF; +import android.media.AudioManager; +import android.media.MediaPlayer; +import android.media.MediaPlayer.OnCompletionListener; +import android.media.MediaPlayer.OnErrorListener; +import android.media.MediaPlayer.OnPreparedListener; +import android.net.Uri; +import android.os.SystemClock; +import android.text.TextUtils; +import android.util.AttributeSet; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.ImageView; +import android.widget.LinearLayout; + +import com.android.messaging.Factory; +import com.android.messaging.R; +import com.android.messaging.datamodel.data.MessagePartData; +import com.android.messaging.ui.mediapicker.PausableChronometer; +import com.android.messaging.util.Assert; +import com.android.messaging.util.ContentType; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.UiUtils; + +/** + * A reusable widget that hosts an audio player for audio attachment playback. This widget is used + * by both the media picker and the conversation message view to show audio attachments. + */ +public class AudioAttachmentView extends LinearLayout { + /** The normal layout mode where we have the play button, timer and progress bar */ + private static final int LAYOUT_MODE_NORMAL = 0; + + /** The compact layout mode with only the play button and the timer beneath it. Suitable + * for displaying in limited space such as multi-attachment layout */ + private static final int LAYOUT_MODE_COMPACT = 1; + + /** The sub-compact layout mode with only the play button. */ + private static final int LAYOUT_MODE_SUB_COMPACT = 2; + + private static final int PLAY_BUTTON = 0; + private static final int PAUSE_BUTTON = 1; + + private AudioAttachmentPlayPauseButton mPlayPauseButton; + private PausableChronometer mChronometer; + private AudioPlaybackProgressBar mProgressBar; + private MediaPlayer mMediaPlayer; + + private Uri mDataSourceUri; + + // The corner radius for drawing rounded corners. The default value is zero (no rounded corners) + private final int mCornerRadius; + private final Path mRoundedCornerClipPath; + private int mClipPathWidth; + private int mClipPathHeight; + + // Indicates whether the attachment view is to be styled as a part of an incoming message. + private boolean mShowAsIncoming; + + private boolean mPrepared; + private boolean mPlaybackFinished; + private final int mMode; + + public AudioAttachmentView(final Context context, final AttributeSet attrs) { + super(context, attrs); + final TypedArray typedAttributes = + context.obtainStyledAttributes(attrs, R.styleable.AudioAttachmentView); + mMode = typedAttributes.getInt(R.styleable.AudioAttachmentView_layoutMode, + LAYOUT_MODE_NORMAL); + final LayoutInflater inflater = LayoutInflater.from(getContext()); + inflater.inflate(R.layout.audio_attachment_view, this, true); + typedAttributes.recycle(); + + setWillNotDraw(mMode != LAYOUT_MODE_SUB_COMPACT); + mRoundedCornerClipPath = new Path(); + mCornerRadius = context.getResources().getDimensionPixelSize( + R.dimen.conversation_list_image_preview_corner_radius); + setContentDescription(context.getString(R.string.audio_attachment_content_description)); + } + + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + + mPlayPauseButton = (AudioAttachmentPlayPauseButton) findViewById(R.id.play_pause_button); + mChronometer = (PausableChronometer) findViewById(R.id.timer); + mProgressBar = (AudioPlaybackProgressBar) findViewById(R.id.progress); + mPlayPauseButton.setOnClickListener(new OnClickListener() { + @Override + public void onClick(final View v) { + setupMediaPlayer(); + if (mMediaPlayer != null && mPrepared) { + if (mMediaPlayer.isPlaying()) { + mMediaPlayer.pause(); + mChronometer.pause(); + mProgressBar.pause(); + } else { + playAudio(); + } + } + updatePlayPauseButtonState(); + } + }); + updatePlayPauseButtonState(); + initializeViewsForMode(); + } + + /** + * Bind the audio attachment view with a MessagePartData. + * @param incoming indicates whether the attachment view is to be styled as a part of an + * incoming message. + */ + public void bindMessagePartData(final MessagePartData messagePartData, + final boolean incoming) { + Assert.isTrue(messagePartData == null || + ContentType.isAudioType(messagePartData.getContentType())); + final Uri contentUri = (messagePartData == null) ? null : messagePartData.getContentUri(); + bind(contentUri, incoming); + } + + public void bind(final Uri dataSourceUri, final boolean incoming) { + final String currentUriString = (mDataSourceUri == null) ? "" : mDataSourceUri.toString(); + final String newUriString = (dataSourceUri == null) ? "" : dataSourceUri.toString(); + mShowAsIncoming = incoming; + if (!TextUtils.equals(currentUriString, newUriString)) { + mDataSourceUri = dataSourceUri; + resetToZeroState(); + } + } + + private void playAudio() { + Assert.notNull(mMediaPlayer); + if (mPlaybackFinished) { + mMediaPlayer.seekTo(0); + mChronometer.restart(); + mProgressBar.restart(); + mPlaybackFinished = false; + } else { + mChronometer.resume(); + mProgressBar.resume(); + } + mMediaPlayer.start(); + } + + private void onAudioReplayError(final int what, final int extra, final Exception exception) { + if (exception == null) { + LogUtil.e(LogUtil.BUGLE_TAG, "audio replay failed, what=" + what + + ", extra=" + extra); + } else { + LogUtil.e(LogUtil.BUGLE_TAG, "audio replay failed, exception=" + exception); + } + UiUtils.showToastAtBottom(R.string.audio_recording_replay_failed); + releaseMediaPlayer(); + } + + private void setupMediaPlayer() { + Assert.notNull(mDataSourceUri); + if (mMediaPlayer == null) { + Assert.isTrue(!mPrepared); + mMediaPlayer = new MediaPlayer(); + try { + mMediaPlayer.setAudioStreamType(AudioManager.STREAM_MUSIC); + mMediaPlayer.setDataSource(Factory.get().getApplicationContext(), mDataSourceUri); + mMediaPlayer.setOnCompletionListener(new OnCompletionListener() { + @Override + public void onCompletion(final MediaPlayer mp) { + updatePlayPauseButtonState(); + mChronometer.reset(); + mChronometer.setBase(SystemClock.elapsedRealtime() - + mMediaPlayer.getDuration()); + mProgressBar.reset(); + + mPlaybackFinished = true; + } + }); + + mMediaPlayer.setOnPreparedListener(new OnPreparedListener() { + @Override + public void onPrepared(final MediaPlayer mp) { + // Set base on the chronometer so we can show the full length of the audio. + mChronometer.setBase(SystemClock.elapsedRealtime() - + mMediaPlayer.getDuration()); + mProgressBar.setDuration(mMediaPlayer.getDuration()); + mMediaPlayer.seekTo(0); + mPrepared = true; + } + }); + + mMediaPlayer.setOnErrorListener(new OnErrorListener() { + @Override + public boolean onError(final MediaPlayer mp, final int what, final int extra) { + onAudioReplayError(what, extra, null); + return true; + } + }); + mMediaPlayer.prepareAsync(); + } catch (final Exception exception) { + onAudioReplayError(0, 0, exception); + releaseMediaPlayer(); + } + } + } + + private void releaseMediaPlayer() { + if (mMediaPlayer != null) { + mMediaPlayer.release(); + mMediaPlayer = null; + mPrepared = false; + mPlaybackFinished = false; + } + } + + @Override + protected void onDetachedFromWindow() { + super.onDetachedFromWindow(); + releaseMediaPlayer(); + } + + @Override + protected void onDraw(final Canvas canvas) { + if (mMode != LAYOUT_MODE_SUB_COMPACT) { + return; + } + + final int currentWidth = this.getWidth(); + final int currentHeight = this.getHeight(); + if (mClipPathWidth != currentWidth || mClipPathHeight != currentHeight) { + final RectF rect = new RectF(0, 0, currentWidth, currentHeight); + mRoundedCornerClipPath.reset(); + mRoundedCornerClipPath.addRoundRect(rect, mCornerRadius, mCornerRadius, + Path.Direction.CW); + mClipPathWidth = currentWidth; + mClipPathHeight = currentHeight; + } + + canvas.clipPath(mRoundedCornerClipPath); + super.onDraw(canvas); + } + + private void updatePlayPauseButtonState() { + if (mMediaPlayer == null || !mMediaPlayer.isPlaying()) { + mPlayPauseButton.setDisplayedChild(PLAY_BUTTON); + } else { + mPlayPauseButton.setDisplayedChild(PAUSE_BUTTON); + } + } + + private void resetToZeroState() { + // Release the media player so it may be set up with the new audio source. + releaseMediaPlayer(); + mChronometer.reset(); + mProgressBar.reset(); + updateVisualStyle(); + + if (mDataSourceUri != null) { + // Re-ensure the media player, so we can read the duration of the audio. + setupMediaPlayer(); + } + } + + private void updateVisualStyle() { + if (mMode == LAYOUT_MODE_SUB_COMPACT) { + // Sub-compact mode has static visual appearance already set up during initialization. + return; + } + + if (mShowAsIncoming) { + mChronometer.setTextColor(getResources().getColor(R.color.message_text_color_incoming)); + } else { + mChronometer.setTextColor(getResources().getColor(R.color.message_text_color_outgoing)); + } + mProgressBar.setVisualStyle(mShowAsIncoming); + mPlayPauseButton.setVisualStyle(mShowAsIncoming); + updatePlayPauseButtonState(); + } + + private void initializeViewsForMode() { + switch (mMode) { + case LAYOUT_MODE_NORMAL: + setOrientation(HORIZONTAL); + mProgressBar.setVisibility(VISIBLE); + break; + + case LAYOUT_MODE_COMPACT: + setOrientation(VERTICAL); + mProgressBar.setVisibility(GONE); + ((MarginLayoutParams) mPlayPauseButton.getLayoutParams()).setMargins(0, 0, 0, 0); + ((MarginLayoutParams) mChronometer.getLayoutParams()).setMargins(0, 0, 0, 0); + break; + + case LAYOUT_MODE_SUB_COMPACT: + setOrientation(VERTICAL); + mProgressBar.setVisibility(GONE); + mChronometer.setVisibility(GONE); + ((MarginLayoutParams) mPlayPauseButton.getLayoutParams()).setMargins(0, 0, 0, 0); + final ImageView playButton = (ImageView) findViewById(R.id.play_button); + playButton.setImageDrawable( + getResources().getDrawable(R.drawable.ic_preview_play)); + final ImageView pauseButton = (ImageView) findViewById(R.id.pause_button); + pauseButton.setImageDrawable( + getResources().getDrawable(R.drawable.ic_preview_pause)); + break; + + default: + Assert.fail("Unsupported mode for AudioAttachmentView!"); + break; + } + } +} diff --git a/src/com/android/messaging/ui/AudioPlaybackProgressBar.java b/src/com/android/messaging/ui/AudioPlaybackProgressBar.java new file mode 100644 index 0000000..a5b3a57 --- /dev/null +++ b/src/com/android/messaging/ui/AudioPlaybackProgressBar.java @@ -0,0 +1,121 @@ +/* + * 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.ui; + +import android.animation.ObjectAnimator; +import android.animation.TimeAnimator; +import android.animation.TimeAnimator.TimeListener; +import android.content.Context; +import android.graphics.drawable.ClipDrawable; +import android.graphics.drawable.Drawable; +import android.os.SystemClock; +import android.util.AttributeSet; +import android.view.Gravity; +import android.widget.ProgressBar; + +/** + * Shows a styled progress bar that is synchronized with the playback state of an audio attachment. + */ +public class AudioPlaybackProgressBar extends ProgressBar implements PlaybackStateView { + private long mDurationInMillis; + private final TimeAnimator mUpdateAnimator; + private long mCumulativeTime = 0; + private long mCurrentPlayStartTime = 0; + private boolean mIncoming = false; + + public AudioPlaybackProgressBar(final Context context, final AttributeSet attrs) { + super(context, attrs); + + mUpdateAnimator = new TimeAnimator(); + mUpdateAnimator.setRepeatCount(ObjectAnimator.INFINITE); + mUpdateAnimator.setTimeListener(new TimeListener() { + @Override + public void onTimeUpdate(final TimeAnimator animation, final long totalTime, + final long deltaTime) { + int progress = 0; + if (mDurationInMillis > 0) { + progress = (int) (((mCumulativeTime + SystemClock.elapsedRealtime() - + mCurrentPlayStartTime) * 1.0f / mDurationInMillis) * 100); + progress = Math.max(Math.min(progress, 100), 0); + } + setProgress(progress); + } + }); + updateAppearance(); + } + + /** + * Sets the duration of the audio that's being played, in milliseconds. + */ + public void setDuration(final long durationInMillis) { + mDurationInMillis = durationInMillis; + } + + @Override + public void restart() { + reset(); + resume(); + } + + @Override + public void reset() { + stopUpdateTicks(); + setProgress(0); + mCumulativeTime = 0; + mCurrentPlayStartTime = 0; + } + + @Override + public void resume() { + mCurrentPlayStartTime = SystemClock.elapsedRealtime(); + startUpdateTicks(); + } + + @Override + public void pause() { + mCumulativeTime += SystemClock.elapsedRealtime() - mCurrentPlayStartTime; + stopUpdateTicks(); + } + + private void startUpdateTicks() { + if (!mUpdateAnimator.isStarted()) { + mUpdateAnimator.start(); + } + } + + private void stopUpdateTicks() { + if (mUpdateAnimator.isStarted()) { + mUpdateAnimator.end(); + } + } + + private void updateAppearance() { + final Drawable drawable = + ConversationDrawables.get().getAudioProgressDrawable(mIncoming); + final ClipDrawable clipDrawable = new ClipDrawable(drawable, Gravity.START, + ClipDrawable.HORIZONTAL); + setProgressDrawable(clipDrawable); + setBackground(ConversationDrawables.get() + .getAudioProgressBackgroundDrawable(mIncoming)); + } + + public void setVisualStyle(final boolean incoming) { + if (mIncoming != incoming) { + mIncoming = incoming; + updateAppearance(); + } + } +} diff --git a/src/com/android/messaging/ui/BaseBugleActivity.java b/src/com/android/messaging/ui/BaseBugleActivity.java new file mode 100644 index 0000000..1236282 --- /dev/null +++ b/src/com/android/messaging/ui/BaseBugleActivity.java @@ -0,0 +1,51 @@ +/* + * 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.ui; + +import android.app.Activity; +import android.os.Bundle; + +import com.android.messaging.util.BugleActivityUtil; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.UiUtils; + +/** + * Base class for app activities that would normally derive from Activity. Responsible for + * ensuring app requirements are met during onResume() + */ +public class BaseBugleActivity extends Activity { + @Override + protected void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + if (UiUtils.redirectToPermissionCheckIfNeeded(this)) { + return; + } + } + + @Override + protected void onResume() { + super.onResume(); + LogUtil.v(LogUtil.BUGLE_TAG, this.getLocalClassName() + ".onResume"); + BugleActivityUtil.onActivityResume(this, BaseBugleActivity.this); + } + + @Override + protected void onPause() { + super.onPause(); + LogUtil.v(LogUtil.BUGLE_TAG, this.getLocalClassName() + ".onPause"); + } +} diff --git a/src/com/android/messaging/ui/BaseBugleFragmentActivity.java b/src/com/android/messaging/ui/BaseBugleFragmentActivity.java new file mode 100644 index 0000000..947970f --- /dev/null +++ b/src/com/android/messaging/ui/BaseBugleFragmentActivity.java @@ -0,0 +1,43 @@ +/* + * 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.ui; + +import android.app.Activity; + +import com.android.messaging.util.BugleActivityUtil; +import com.android.messaging.util.LogUtil; + +/** + * Base class for app activities that would normally derive from FragmentActivity. Responsible for + * ensuring app requirements are met during onResume() + */ +public class BaseBugleFragmentActivity extends Activity { + @Override + protected void onResume() { + super.onResume(); + LogUtil.v(LogUtil.BUGLE_TAG, this.getLocalClassName() + ".onResume"); + // Ensure we have a sufficient version of Google Play Services, prompting for upgrade and + // disabling the data updates if we don't have the correct version. + BugleActivityUtil.onActivityResume(this, BaseBugleFragmentActivity.this); + } + + @Override + protected void onPause() { + super.onPause(); + LogUtil.v(LogUtil.BUGLE_TAG, this.getLocalClassName() + ".onPause"); + } +} diff --git a/src/com/android/messaging/ui/BasePagerViewHolder.java b/src/com/android/messaging/ui/BasePagerViewHolder.java new file mode 100644 index 0000000..a82ecce --- /dev/null +++ b/src/com/android/messaging/ui/BasePagerViewHolder.java @@ -0,0 +1,106 @@ +/* + * 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.ui; + +import android.os.Parcelable; +import android.view.View; +import android.view.ViewGroup; + +/** + * The base pager view holder implementation that handles basic view creation/destruction logic, + * as well as logic to save/restore instance state that's persisted not only for activity + * reconstruction (e.g. during a configuration change), but also cases where the individual + * page view gets destroyed and recreated. + * + * To opt into saving/restoring instance state behavior for a particular page view, let the + * PageView implement PersistentInstanceState to save and restore instance states to/from a + * Parcelable. + */ +public abstract class BasePagerViewHolder implements PagerViewHolder { + protected View mView; + protected Parcelable mSavedState; + + /** + * This is called when the entire view pager is being torn down (due to configuration change + * for example) that will be restored later. + */ + @Override + public Parcelable saveState() { + savePendingState(); + return mSavedState; + } + + /** + * This is called when the view pager is being restored. + */ + @Override + public void restoreState(final Parcelable restoredState) { + if (restoredState != null) { + mSavedState = restoredState; + // If the view is already there, let it restore the state. Otherwise, it will be + // restored the next time the view gets created. + restorePendingState(); + } + } + + @Override + public void resetState() { + mSavedState = null; + if (mView != null && (mView instanceof PersistentInstanceState)) { + ((PersistentInstanceState) mView).resetState(); + } + } + + /** + * This is called when the view itself is being torn down. This may happen when the user + * has flipped to another page in the view pager, so we want to save the current state if + * possible. + */ + @Override + public View destroyView() { + savePendingState(); + final View retView = mView; + mView = null; + return retView; + } + + @Override + public View getView(ViewGroup container) { + if (mView == null) { + mView = createView(container); + // When initially created, check if the view has any saved state. If so restore it. + restorePendingState(); + } + return mView; + } + + private void savePendingState() { + if (mView != null && (mView instanceof PersistentInstanceState)) { + mSavedState = ((PersistentInstanceState) mView).saveState(); + } + } + + private void restorePendingState() { + if (mView != null && (mView instanceof PersistentInstanceState) && (mSavedState != null)) { + ((PersistentInstanceState) mView).restoreState(mSavedState); + } + } + + /** + * Create and initialize a new page view. + */ + protected abstract View createView(ViewGroup container); +} diff --git a/src/com/android/messaging/ui/BlockedParticipantListItemView.java b/src/com/android/messaging/ui/BlockedParticipantListItemView.java new file mode 100644 index 0000000..2ccdebf --- /dev/null +++ b/src/com/android/messaging/ui/BlockedParticipantListItemView.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.ui; + +import android.content.Context; +import android.support.v4.text.BidiFormatter; +import android.support.v4.text.TextDirectionHeuristicsCompat; +import android.util.AttributeSet; +import android.view.View; +import android.widget.LinearLayout; +import android.widget.TextView; + +import com.android.messaging.R; +import com.android.messaging.datamodel.data.ParticipantListItemData; + +/** + * View for individual participant in blocked participants list. + * + * Unblocks participant when clicked. + */ +public class BlockedParticipantListItemView extends LinearLayout { + private TextView mNameTextView; + private ContactIconView mContactIconView; + private ParticipantListItemData mData; + + public BlockedParticipantListItemView(final Context context, final AttributeSet attrs) { + super(context, attrs); + } + + @Override + protected void onFinishInflate() { + mNameTextView = (TextView) findViewById(R.id.name); + mContactIconView = (ContactIconView) findViewById(R.id.contact_icon); + setOnClickListener(new OnClickListener() { + @Override + public void onClick(final View v) { + mData.unblock(getContext()); + } + }); + } + + public void bind(final ParticipantListItemData data) { + mData = data; + final BidiFormatter bidiFormatter = BidiFormatter.getInstance(); + mNameTextView.setText(bidiFormatter.unicodeWrap( + data.getDisplayName(), TextDirectionHeuristicsCompat.LTR)); + mContactIconView.setImageResourceUri(data.getAvatarUri(), data.getContactId(), + data.getLookupKey(), data.getNormalizedDestination()); + mNameTextView.setText(data.getDisplayName()); + } +} diff --git a/src/com/android/messaging/ui/BlockedParticipantsActivity.java b/src/com/android/messaging/ui/BlockedParticipantsActivity.java new file mode 100644 index 0000000..b740264 --- /dev/null +++ b/src/com/android/messaging/ui/BlockedParticipantsActivity.java @@ -0,0 +1,57 @@ +/* + * 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.ui; + +import android.app.Fragment; +import android.os.Bundle; +import android.view.MenuItem; + +import com.android.messaging.R; +import com.android.messaging.util.Assert; + +/** + * Show a list of currently blocked participants. + */ +public class BlockedParticipantsActivity extends BugleActionBarActivity { + + @Override + protected void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.blocked_participants_activity); + + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + } + + @Override + public void onAttachFragment(final Fragment fragment) { + Assert.isTrue(fragment instanceof BlockedParticipantsFragment); + } + + @Override + public boolean onOptionsItemSelected(final MenuItem item) { + switch (item.getItemId()) { + case android.R.id.home: + // Treat the home press as back press so that when we go back to + // ConversationActivity, it doesn't lose its original intent (conversation id etc.) + onBackPressed(); + return true; + + default: + return super.onOptionsItemSelected(item); + } + } +} diff --git a/src/com/android/messaging/ui/BlockedParticipantsFragment.java b/src/com/android/messaging/ui/BlockedParticipantsFragment.java new file mode 100644 index 0000000..3ab54ab --- /dev/null +++ b/src/com/android/messaging/ui/BlockedParticipantsFragment.java @@ -0,0 +1,102 @@ +/* + * 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.ui; + +import android.app.Fragment; +import android.content.Context; +import android.content.Intent; +import android.database.Cursor; +import android.os.Bundle; +import android.support.v4.widget.CursorAdapter; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ListView; + +import com.android.messaging.R; +import com.android.messaging.datamodel.binding.Binding; +import com.android.messaging.datamodel.binding.BindingBase; +import com.android.messaging.datamodel.data.BlockedParticipantsData; +import com.android.messaging.datamodel.data.BlockedParticipantsData.BlockedParticipantsDataListener; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.util.Assert; + +/** + * Show a list of currently blocked participants. + */ +public class BlockedParticipantsFragment extends Fragment + implements BlockedParticipantsDataListener { + private ListView mListView; + private BlockedParticipantListAdapter mAdapter; + private final Binding<BlockedParticipantsData> mBinding = + BindingBase.createBinding(this); + + @Override + public void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + } + + @Override + public View onCreateView(final LayoutInflater inflater, final ViewGroup container, + final Bundle savedInstanceState) { + final View view = + inflater.inflate(R.layout.blocked_participants_fragment, container, false); + mListView = (ListView) view.findViewById(android.R.id.list); + mAdapter = new BlockedParticipantListAdapter(getActivity(), null); + mListView.setAdapter(mAdapter); + mBinding.bind(DataModel.get().createBlockedParticipantsData(getActivity(), this)); + mBinding.getData().init(getLoaderManager(), mBinding); + return view; + } + + @Override + public void onActivityResult(final int requestCode, final int resultCode, final Intent data) { + super.onActivityResult(requestCode, resultCode, data); + } + + @Override + public void onDestroy() { + super.onDestroy(); + mBinding.unbind(); + } + + /** + * An adapter to display ParticipantListItemView based on ParticipantData. + */ + private class BlockedParticipantListAdapter extends CursorAdapter { + public BlockedParticipantListAdapter(final Context context, final Cursor cursor) { + super(context, cursor, 0); + } + + @Override + public View newView(Context context, Cursor cursor, ViewGroup parent) { + return LayoutInflater.from(context) + .inflate(R.layout.blocked_participant_list_item_view, parent, false); + } + + @Override + public void bindView(View view, Context context, Cursor cursor) { + Assert.isTrue(view instanceof BlockedParticipantListItemView); + ((BlockedParticipantListItemView) view).bind( + mBinding.getData().createParticipantListItemData(cursor)); + } + } + + @Override + public void onBlockedParticipantsCursorUpdated(Cursor cursor) { + mAdapter.swapCursor(cursor); + } +} diff --git a/src/com/android/messaging/ui/BugleActionBarActivity.java b/src/com/android/messaging/ui/BugleActionBarActivity.java new file mode 100644 index 0000000..80dabb8 --- /dev/null +++ b/src/com/android/messaging/ui/BugleActionBarActivity.java @@ -0,0 +1,356 @@ +/* + * 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.ui; + +import android.graphics.drawable.ColorDrawable; +import android.os.Bundle; +import android.support.v7.app.ActionBar; +import android.support.v7.app.ActionBarActivity; +import android.view.ActionMode; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; + +import com.android.messaging.R; +import com.android.messaging.util.BugleActivityUtil; +import com.android.messaging.util.ImeUtil; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.UiUtils; + +import java.util.HashSet; +import java.util.Set; + +/** + * Base class for app activities that use an action bar. Responsible for logging/telemetry/other + * needs that will be common for all activities. We can break out the common code if/when we need + * a version that doesn't use an actionbar. + */ +public class BugleActionBarActivity extends ActionBarActivity implements ImeUtil.ImeStateHost { + // Tracks the list of observers opting in for IME state change. + private final Set<ImeUtil.ImeStateObserver> mImeStateObservers = new HashSet<>(); + + // Tracks the soft keyboard display state + private boolean mImeOpen; + + // The ActionMode that represents the modal contextual action bar, using our own implementation + // rather than the built in contextual action bar to reduce jank + private CustomActionMode mActionMode; + + // The menu for the action bar + private Menu mActionBarMenu; + + // Used to determine if a onDisplayHeightChanged was due to the IME opening or rotation of the + // device + private int mLastScreenHeight; + + @Override + protected void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + if (UiUtils.redirectToPermissionCheckIfNeeded(this)) { + return; + } + + mLastScreenHeight = getResources().getDisplayMetrics().heightPixels; + if (LogUtil.isLoggable(LogUtil.BUGLE_TAG, LogUtil.VERBOSE)) { + LogUtil.v(LogUtil.BUGLE_TAG, this.getLocalClassName() + ".onCreate"); + } + } + + @Override + protected void onStart() { + super.onStart(); + if (LogUtil.isLoggable(LogUtil.BUGLE_TAG, LogUtil.VERBOSE)) { + LogUtil.v(LogUtil.BUGLE_TAG, this.getLocalClassName() + ".onStart"); + } + } + + @Override + protected void onRestart() { + super.onStop(); + if (LogUtil.isLoggable(LogUtil.BUGLE_TAG, LogUtil.VERBOSE)) { + LogUtil.v(LogUtil.BUGLE_TAG, this.getLocalClassName() + ".onRestart"); + } + } + + @Override + protected void onResume() { + super.onResume(); + if (LogUtil.isLoggable(LogUtil.BUGLE_TAG, LogUtil.VERBOSE)) { + LogUtil.v(LogUtil.BUGLE_TAG, this.getLocalClassName() + ".onResume"); + } + BugleActivityUtil.onActivityResume(this, BugleActionBarActivity.this); + } + + @Override + protected void onPause() { + super.onPause(); + if (LogUtil.isLoggable(LogUtil.BUGLE_TAG, LogUtil.VERBOSE)) { + LogUtil.v(LogUtil.BUGLE_TAG, this.getLocalClassName() + ".onPause"); + } + } + + @Override + protected void onStop() { + super.onStop(); + if (LogUtil.isLoggable(LogUtil.BUGLE_TAG, LogUtil.VERBOSE)) { + LogUtil.v(LogUtil.BUGLE_TAG, this.getLocalClassName() + ".onStop"); + } + } + + private boolean mDestroyed; + + @Override + protected void onDestroy() { + super.onDestroy(); + mDestroyed = true; + } + + public boolean getIsDestroyed() { + return mDestroyed; + } + + @Override + public void onDisplayHeightChanged(final int heightSpecification) { + int screenHeight = getResources().getDisplayMetrics().heightPixels; + + if (screenHeight != mLastScreenHeight) { + // Appears to be an orientation change, don't fire ime updates + mLastScreenHeight = screenHeight; + LogUtil.v(LogUtil.BUGLE_TAG, this.getLocalClassName() + ".onDisplayHeightChanged " + + " screenHeight: " + screenHeight + " lastScreenHeight: " + mLastScreenHeight + + " Skipped, appears to be orientation change."); + return; + } + final ActionBar actionBar = getSupportActionBar(); + if (actionBar != null && actionBar.isShowing()) { + screenHeight -= actionBar.getHeight(); + } + final int height = View.MeasureSpec.getSize(heightSpecification); + + final boolean imeWasOpen = mImeOpen; + mImeOpen = screenHeight - height > 100; + + if (LogUtil.isLoggable(LogUtil.BUGLE_TAG, LogUtil.VERBOSE)) { + LogUtil.v(LogUtil.BUGLE_TAG, this.getLocalClassName() + ".onDisplayHeightChanged " + + "imeWasOpen: " + imeWasOpen + " mImeOpen: " + mImeOpen + " screenHeight: " + + screenHeight + " height: " + height); + } + + if (imeWasOpen != mImeOpen) { + for (final ImeUtil.ImeStateObserver observer : mImeStateObservers) { + observer.onImeStateChanged(mImeOpen); + } + } + } + + @Override + public void registerImeStateObserver(final ImeUtil.ImeStateObserver observer) { + mImeStateObservers.add(observer); + } + + @Override + public void unregisterImeStateObserver(final ImeUtil.ImeStateObserver observer) { + mImeStateObservers.remove(observer); + } + + @Override + public boolean isImeOpen() { + return mImeOpen; + } + + @Override + public boolean onCreateOptionsMenu(final Menu menu) { + mActionBarMenu = menu; + if (mActionMode != null && + mActionMode.getCallback().onCreateActionMode(mActionMode, menu)) { + return true; + } + return false; + } + + @Override + public boolean onPrepareOptionsMenu(final Menu menu) { + mActionBarMenu = menu; + if (mActionMode != null && + mActionMode.getCallback().onPrepareActionMode(mActionMode, menu)) { + return true; + } + return super.onPrepareOptionsMenu(menu); + } + + @Override + public boolean onOptionsItemSelected(final MenuItem menuItem) { + if (mActionMode != null && + mActionMode.getCallback().onActionItemClicked(mActionMode, menuItem)) { + return true; + } + + switch (menuItem.getItemId()) { + case android.R.id.home: + if (mActionMode != null) { + dismissActionMode(); + return true; + } + } + return super.onOptionsItemSelected(menuItem); + } + + @Override + public ActionMode startActionMode(final ActionMode.Callback callback) { + mActionMode = new CustomActionMode(callback); + supportInvalidateOptionsMenu(); + invalidateActionBar(); + return mActionMode; + } + + public void dismissActionMode() { + if (mActionMode != null) { + mActionMode.finish(); + mActionMode = null; + invalidateActionBar(); + } + } + + public ActionMode getActionMode() { + return mActionMode; + } + + protected ActionMode.Callback getActionModeCallback() { + if (mActionMode == null) { + return null; + } + + return mActionMode.getCallback(); + } + + /** + * Receives and handles action bar invalidation request from sub-components of this activity. + * + * <p>Normally actions have sole control over the action bar, but in order to support seamless + * transitions for components such as the full screen media picker, we have to let it take over + * the action bar and then restore its state afterwards</p> + * + * <p>If a fragment does anything that may change the action bar, it should call this method + * and then it is this method's responsibility to figure out which component "controls" the + * action bar and delegate the updating of the action bar to that component</p> + */ + public final void invalidateActionBar() { + if (mActionMode != null) { + mActionMode.updateActionBar(getSupportActionBar()); + } else { + updateActionBar(getSupportActionBar()); + } + } + + protected void updateActionBar(final ActionBar actionBar) { + actionBar.setHomeAsUpIndicator(null); + } + + /** + * Custom ActionMode implementation which allows us to just replace the contents of the main + * action bar rather than overlay over it + */ + private class CustomActionMode extends ActionMode { + private CharSequence mTitle; + private CharSequence mSubtitle; + private View mCustomView; + private final Callback mCallback; + + public CustomActionMode(final Callback callback) { + mCallback = callback; + } + + @Override + public void setTitle(final CharSequence title) { + mTitle = title; + } + + @Override + public void setTitle(final int resId) { + mTitle = getResources().getString(resId); + } + + @Override + public void setSubtitle(final CharSequence subtitle) { + mSubtitle = subtitle; + } + + @Override + public void setSubtitle(final int resId) { + mSubtitle = getResources().getString(resId); + } + + @Override + public void setCustomView(final View view) { + mCustomView = view; + } + + @Override + public void invalidate() { + invalidateActionBar(); + } + + @Override + public void finish() { + mActionMode = null; + mCallback.onDestroyActionMode(this); + supportInvalidateOptionsMenu(); + invalidateActionBar(); + } + + @Override + public Menu getMenu() { + return mActionBarMenu; + } + + @Override + public CharSequence getTitle() { + return mTitle; + } + + @Override + public CharSequence getSubtitle() { + return mSubtitle; + } + + @Override + public View getCustomView() { + return mCustomView; + } + + @Override + public MenuInflater getMenuInflater() { + return BugleActionBarActivity.this.getMenuInflater(); + } + + public Callback getCallback() { + return mCallback; + } + + public void updateActionBar(final ActionBar actionBar) { + actionBar.setDisplayOptions(ActionBar.DISPLAY_HOME_AS_UP); + actionBar.setDisplayShowTitleEnabled(false); + actionBar.setDisplayShowCustomEnabled(false); + mActionMode.getCallback().onPrepareActionMode(mActionMode, mActionBarMenu); + actionBar.setBackgroundDrawable(new ColorDrawable( + getResources().getColor(R.color.contextual_action_bar_background_color))); + actionBar.setHomeAsUpIndicator(R.drawable.ic_cancel_small_dark); + actionBar.show(); + } + } +} diff --git a/src/com/android/messaging/ui/BugleAnimationTags.java b/src/com/android/messaging/ui/BugleAnimationTags.java new file mode 100644 index 0000000..b141f5b --- /dev/null +++ b/src/com/android/messaging/ui/BugleAnimationTags.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.ui; + + +/** + * Defines a list of animation tags used as android:transitionName properties used for L's + * hero transitions. + */ +public class BugleAnimationTags { + /** + * The tag for the FAB in the conversation list activity. + */ + public static final String TAG_FABICON = "bugle:fabicon"; + + /** + * The tag for the content view of a conversation list item view. + */ + public static final String TAG_CLIVCONTENT = "bugle:clivcontent"; + + /** + * The tag for the action bar. + */ + public static final String TAG_ACTIONBAR = "bugle:actionbar"; +} diff --git a/src/com/android/messaging/ui/ClassZeroActivity.java b/src/com/android/messaging/ui/ClassZeroActivity.java new file mode 100644 index 0000000..129ec19 --- /dev/null +++ b/src/com/android/messaging/ui/ClassZeroActivity.java @@ -0,0 +1,205 @@ +/* + * 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.ui; + +import android.app.Activity; +import android.app.AlertDialog; +import android.content.ContentValues; +import android.content.DialogInterface; +import android.content.DialogInterface.OnClickListener; +import android.content.Intent; +import android.os.Bundle; +import android.os.Handler; +import android.os.Message; +import android.os.SystemClock; +import android.provider.Telephony.Sms; +import android.text.TextUtils; +import android.util.Log; +import android.view.Window; + +import com.android.messaging.R; +import com.android.messaging.datamodel.action.ReceiveSmsMessageAction; +import com.android.messaging.util.Assert; + +import java.util.ArrayList; + +/** + * Display a class-zero SMS message to the user. Wait for the user to dismiss + * it. + */ +public class ClassZeroActivity extends Activity { + private static final boolean VERBOSE = false; + private static final String TAG = "display_00"; + private static final int ON_AUTO_SAVE = 1; + + /** Default timer to dismiss the dialog. */ + private static final long DEFAULT_TIMER = 5 * 60 * 1000; + + /** To remember the exact time when the timer should fire. */ + private static final String TIMER_FIRE = "timer_fire"; + + private ContentValues mMessageValues = null; + + /** Is the message read. */ + private boolean mRead = false; + + /** The timer to dismiss the dialog automatically. */ + private long mTimerSet = 0; + private AlertDialog mDialog = null; + + private ArrayList<ContentValues> mMessageQueue = null; + + private final Handler mHandler = new Handler() { + @Override + public void handleMessage(final Message msg) { + // Do not handle an invalid message. + if (msg.what == ON_AUTO_SAVE) { + mRead = false; + mDialog.dismiss(); + saveMessage(); + processNextMessage(); + } + } + }; + + private boolean queueMsgFromIntent(final Intent msgIntent) { + final ContentValues messageValues = + msgIntent.getParcelableExtra(UIIntents.UI_INTENT_EXTRA_MESSAGE_VALUES); + // that takes the format argument is a hidden API right now. + final String message = messageValues.getAsString(Sms.BODY); + if (TextUtils.isEmpty(message)) { + if (mMessageQueue.size() == 0) { + finish(); + } + return false; + } + mMessageQueue.add(messageValues); + return true; + } + + private void processNextMessage() { + if (mMessageQueue.size() > 0) { + mMessageQueue.remove(0); + } + if (mMessageQueue.size() == 0) { + finish(); + } else { + displayZeroMessage(mMessageQueue.get(0)); + } + } + + private void saveMessage() { + mMessageValues.put(Sms.Inbox.READ, mRead ? Integer.valueOf(1) : Integer.valueOf(0)); + final ReceiveSmsMessageAction action = new ReceiveSmsMessageAction(mMessageValues); + action.start(); + } + + @Override + protected void onCreate(final Bundle icicle) { + super.onCreate(icicle); + requestWindowFeature(Window.FEATURE_NO_TITLE); + + if (mMessageQueue == null) { + mMessageQueue = new ArrayList<ContentValues>(); + } + if (!queueMsgFromIntent(getIntent())) { + return; + } + Assert.isTrue(mMessageQueue.size() == 1); + if (mMessageQueue.size() == 1) { + displayZeroMessage(mMessageQueue.get(0)); + } + + if (icicle != null) { + mTimerSet = icicle.getLong(TIMER_FIRE, mTimerSet); + } + } + + private void displayZeroMessage(final ContentValues messageValues) { + /* This'll be used by the save action */ + mMessageValues = messageValues; + final String message = messageValues.getAsString(Sms.BODY);; + + mDialog = new AlertDialog.Builder(this).setMessage(message) + .setPositiveButton(R.string.save, mSaveListener) + .setNegativeButton(android.R.string.cancel, mCancelListener) + .setTitle(R.string.class_0_message_activity) + .setCancelable(false).show(); + final long now = SystemClock.uptimeMillis(); + mTimerSet = now + DEFAULT_TIMER; + } + + @Override + protected void onNewIntent(final Intent msgIntent) { + // Running with another visible message, queue this one + queueMsgFromIntent(msgIntent); + } + + @Override + protected void onStart() { + super.onStart(); + final long now = SystemClock.uptimeMillis(); + if (mTimerSet <= now) { + // Save the message if the timer already expired. + mHandler.sendEmptyMessage(ON_AUTO_SAVE); + } else { + mHandler.sendEmptyMessageAtTime(ON_AUTO_SAVE, mTimerSet); + if (VERBOSE) { + Log.d(TAG, "onRestart time = " + Long.toString(mTimerSet) + " " + + this.toString()); + } + } + } + + @Override + protected void onSaveInstanceState(final Bundle outState) { + super.onSaveInstanceState(outState); + outState.putLong(TIMER_FIRE, mTimerSet); + if (VERBOSE) { + Log.d(TAG, "onSaveInstanceState time = " + Long.toString(mTimerSet) + + " " + this.toString()); + } + } + + @Override + protected void onStop() { + super.onStop(); + mHandler.removeMessages(ON_AUTO_SAVE); + if (VERBOSE) { + Log.d(TAG, "onStop time = " + Long.toString(mTimerSet) + + " " + this.toString()); + } + } + + private final OnClickListener mCancelListener = new OnClickListener() { + @Override + public void onClick(final DialogInterface dialog, final int whichButton) { + dialog.dismiss(); + processNextMessage(); + } + }; + + private final OnClickListener mSaveListener = new OnClickListener() { + @Override + public void onClick(final DialogInterface dialog, final int whichButton) { + mRead = true; + saveMessage(); + dialog.dismiss(); + processNextMessage(); + } + }; +} diff --git a/src/com/android/messaging/ui/CompositeAdapter.java b/src/com/android/messaging/ui/CompositeAdapter.java new file mode 100644 index 0000000..620e511 --- /dev/null +++ b/src/com/android/messaging/ui/CompositeAdapter.java @@ -0,0 +1,288 @@ +/* + * 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.ui; + +import android.content.Context; +import android.database.DataSetObserver; +import android.view.View; +import android.view.ViewGroup; +import android.widget.BaseAdapter; + +/** + * A general purpose adapter that composes one or more other adapters. It + * appends them in the order they are added. + */ +public class CompositeAdapter extends BaseAdapter { + + private static final int INITIAL_CAPACITY = 2; + + public static class Partition { + boolean mShowIfEmpty; + boolean mHasHeader; + BaseAdapter mAdapter; + + public Partition(final boolean showIfEmpty, final boolean hasHeader, + final BaseAdapter adapter) { + this.mShowIfEmpty = showIfEmpty; + this.mHasHeader = hasHeader; + this.mAdapter = adapter; + } + + /** + * True if the directory should be shown even if no contacts are found. + */ + public boolean showIfEmpty() { + return mShowIfEmpty; + } + + public boolean hasHeader() { + return mHasHeader; + } + + public int getCount() { + int count = mAdapter.getCount(); + if (mHasHeader && (count != 0 || mShowIfEmpty)) { + count++; + } + return count; + } + + public BaseAdapter getAdapter() { + return mAdapter; + } + + public View getHeaderView(final View convertView, final ViewGroup parentView) { + return null; + } + + public void close() { + // do nothing in base class. + } + } + + private class Observer extends DataSetObserver { + @Override + public void onChanged() { + CompositeAdapter.this.notifyDataSetChanged(); + } + + @Override + public void onInvalidated() { + CompositeAdapter.this.notifyDataSetInvalidated(); + } + }; + + protected final Context mContext; + private Partition[] mPartitions; + private int mSize = 0; + private int mCount = 0; + private boolean mCacheValid = true; + private final Observer mObserver; + + public CompositeAdapter(final Context context) { + mContext = context; + mObserver = new Observer(); + mPartitions = new Partition[INITIAL_CAPACITY]; + } + + public Context getContext() { + return mContext; + } + + public void addPartition(final Partition partition) { + if (mSize >= mPartitions.length) { + final int newCapacity = mSize + 2; + final Partition[] newAdapters = new Partition[newCapacity]; + System.arraycopy(mPartitions, 0, newAdapters, 0, mSize); + mPartitions = newAdapters; + } + mPartitions[mSize++] = partition; + partition.getAdapter().registerDataSetObserver(mObserver); + invalidate(); + notifyDataSetChanged(); + } + + public void removePartition(final int index) { + final Partition partition = mPartitions[index]; + partition.close(); + System.arraycopy(mPartitions, index + 1, mPartitions, index, + mSize - index - 1); + mSize--; + partition.getAdapter().unregisterDataSetObserver(mObserver); + invalidate(); + notifyDataSetChanged(); + } + + public void clearPartitions() { + for (int i = 0; i < mSize; i++) { + final Partition partition = mPartitions[i]; + partition.close(); + partition.getAdapter().unregisterDataSetObserver(mObserver); + } + invalidate(); + notifyDataSetChanged(); + } + + public Partition getPartition(final int index) { + return mPartitions[index]; + } + + public int getPartitionAtPosition(final int position) { + ensureCacheValid(); + int start = 0; + for (int i = 0; i < mSize; i++) { + final int end = start + mPartitions[i].getCount(); + if (position >= start && position < end) { + int offset = position - start; + if (mPartitions[i].hasHeader() && + (mPartitions[i].getCount() > 0 || mPartitions[i].showIfEmpty())) { + offset--; + } + if (offset == -1) { + return -1; + } + return i; + } + start = end; + } + return mSize - 1; + } + + public int getPartitionCount() { + return mSize; + } + + public void invalidate() { + mCacheValid = false; + } + + private void ensureCacheValid() { + if (mCacheValid) { + return; + } + mCount = 0; + for (int i = 0; i < mSize; i++) { + mCount += mPartitions[i].getCount(); + } + } + + @Override + public int getCount() { + ensureCacheValid(); + return mCount; + } + + public int getCount(final int index) { + ensureCacheValid(); + return mPartitions[index].getCount(); + } + + @Override + public Object getItem(final int position) { + ensureCacheValid(); + int start = 0; + for (int i = 0; i < mSize; i++) { + final int end = start + mPartitions[i].getCount(); + if (position >= start && position < end) { + final int offset = position - start; + final Partition partition = mPartitions[i]; + if (partition.hasHeader() && offset == 0 && + (partition.getCount() > 0 || partition.showIfEmpty())) { + // This is the header + return null; + } + return mPartitions[i].getAdapter().getItem(offset); + } + start = end; + } + + return null; + } + + @Override + public long getItemId(final int position) { + ensureCacheValid(); + int start = 0; + for (int i = 0; i < mSize; i++) { + final int end = start + mPartitions[i].getCount(); + if (position >= start && position < end) { + final int offset = position - start; + final Partition partition = mPartitions[i]; + if (partition.hasHeader() && offset == 0 && + (partition.getCount() > 0 || partition.showIfEmpty())) { + // Header + return 0; + } + return mPartitions[i].getAdapter().getItemId(offset); + } + start = end; + } + + return 0; + } + + @Override + public boolean isEnabled(int position) { + ensureCacheValid(); + int start = 0; + for (int i = 0; i < mSize; i++) { + final int end = start + mPartitions[i].getCount(); + if (position >= start && position < end) { + final int offset = position - start; + final Partition partition = mPartitions[i]; + if (partition.hasHeader() && offset == 0 && + (partition.getCount() > 0 || partition.showIfEmpty())) { + // This is the header + return false; + } + return true; + } + start = end; + } + return true; + } + + @Override + public View getView(final int position, final View convertView, final ViewGroup parentView) { + ensureCacheValid(); + int start = 0; + for (int i = 0; i < mSize; i++) { + final Partition partition = mPartitions[i]; + final int end = start + partition.getCount(); + if (position >= start && position < end) { + int offset = position - start; + View view; + if (partition.hasHeader() && + (partition.getCount() > 0 || partition.showIfEmpty())) { + offset = offset - 1; + } + if (offset == -1) { + view = partition.getHeaderView(convertView, parentView); + } else { + view = partition.getAdapter().getView(offset, convertView, parentView); + } + if (view == null) { + throw new NullPointerException("View should not be null, partition: " + i + + " position: " + offset); + } + return view; + } + start = end; + } + + throw new ArrayIndexOutOfBoundsException(position); + } +} diff --git a/src/com/android/messaging/ui/ContactIconView.java b/src/com/android/messaging/ui/ContactIconView.java new file mode 100644 index 0000000..44983ab --- /dev/null +++ b/src/com/android/messaging/ui/ContactIconView.java @@ -0,0 +1,152 @@ +/* + * 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.ui; + +import android.content.Context; +import android.content.res.Resources; +import android.content.res.TypedArray; +import android.net.Uri; +import android.text.TextUtils; +import android.util.AttributeSet; +import android.view.MotionEvent; +import android.view.View; + +import com.android.messaging.R; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.datamodel.media.AvatarGroupRequestDescriptor; +import com.android.messaging.datamodel.media.AvatarRequestDescriptor; +import com.android.messaging.util.Assert; +import com.android.messaging.util.AvatarUriUtil; +import com.android.messaging.util.ContactUtil; + +/** + * A view used to render contact icons. This class derives from AsyncImageView, so it loads contact + * icons from MediaResourceManager, and it handles more rendering logic than an AsyncImageView + * (draws a circular bitmap). + */ +public class ContactIconView extends AsyncImageView { + private static final int NORMAL_ICON_SIZE_ID = 0; + private static final int LARGE_ICON_SIZE_ID = 1; + private static final int SMALL_ICON_SIZE_ID = 2; + + protected final int mIconSize; + private final int mColorPressedId; + + private long mContactId; + private String mContactLookupKey; + private String mNormalizedDestination; + private Uri mAvatarUri; + private boolean mDisableClickHandler; + + public ContactIconView(final Context context, final AttributeSet attrs) { + super(context, attrs); + + final Resources resources = context.getResources(); + final TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.ContactIconView); + + final int iconSizeId = a.getInt(R.styleable.ContactIconView_iconSize, 0); + switch (iconSizeId) { + case NORMAL_ICON_SIZE_ID: + mIconSize = (int) resources.getDimension( + R.dimen.contact_icon_view_normal_size); + break; + case LARGE_ICON_SIZE_ID: + mIconSize = (int) resources.getDimension( + R.dimen.contact_icon_view_large_size); + break; + case SMALL_ICON_SIZE_ID: + mIconSize = (int) resources.getDimension( + R.dimen.contact_icon_view_small_size); + break; + default: + // For the compiler, something has to be set even with the assert. + mIconSize = 0; + Assert.fail("Unsupported ContactIconView icon size attribute"); + } + mColorPressedId = resources.getColor(R.color.contact_avatar_pressed_color); + + setImage(null); + a.recycle(); + } + + @Override + public boolean onTouchEvent(MotionEvent event) { + if (event.getActionMasked() == MotionEvent.ACTION_DOWN) { + setColorFilter(mColorPressedId); + } else { + clearColorFilter(); + } + return super.onTouchEvent(event); + } + + /** + * Method which allows the automatic hookup of a click handler when the Uri is changed + */ + public void setImageClickHandlerDisabled(final boolean isHandlerDisabled) { + mDisableClickHandler = isHandlerDisabled; + setOnClickListener(null); + setClickable(false); + } + + /** + * A convenience method that sets the URI of the contact icon by creating a new image request. + */ + public void setImageResourceUri(final Uri uri) { + setImageResourceUri(uri, 0, null, null); + } + + public void setImageResourceUri(final Uri uri, final long contactId, + final String contactLookupKey, final String normalizedDestination) { + if (uri == null) { + setImageResourceId(null); + } else { + final String avatarType = AvatarUriUtil.getAvatarType(uri); + if (AvatarUriUtil.TYPE_GROUP_URI.equals(avatarType)) { + setImageResourceId(new AvatarGroupRequestDescriptor(uri, mIconSize, mIconSize)); + } else { + setImageResourceId(new AvatarRequestDescriptor(uri, mIconSize, mIconSize)); + } + } + + mContactId = contactId; + mContactLookupKey = contactLookupKey; + mNormalizedDestination = normalizedDestination; + mAvatarUri = uri; + + maybeInitializeOnClickListener(); + } + + protected void maybeInitializeOnClickListener() { + if ((mContactId > ParticipantData.PARTICIPANT_CONTACT_ID_NOT_RESOLVED + && !TextUtils.isEmpty(mContactLookupKey)) || + !TextUtils.isEmpty(mNormalizedDestination)) { + if (!mDisableClickHandler) { + setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(final View view) { + ContactUtil.showOrAddContact(view, mContactId, mContactLookupKey, + mAvatarUri, mNormalizedDestination); + } + }); + } + } else { + // This should happen when the phone number is not in the user's contacts or it is a + // group conversation, group conversations don't have contact phone numbers. If this + // is the case then absorb the click to prevent propagation. + setOnClickListener(null); + } + } +} diff --git a/src/com/android/messaging/ui/ConversationDrawables.java b/src/com/android/messaging/ui/ConversationDrawables.java new file mode 100644 index 0000000..cf858e2 --- /dev/null +++ b/src/com/android/messaging/ui/ConversationDrawables.java @@ -0,0 +1,177 @@ +/* + * 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.ui; + +import android.content.Context; +import android.content.res.Resources; +import android.graphics.drawable.Drawable; + +import com.android.messaging.Factory; +import com.android.messaging.R; +import com.android.messaging.util.ImageUtils; + +/** + * A singleton cache that holds tinted drawable resources for displaying messages, such as + * message bubbles, audio attachments etc. + */ +public class ConversationDrawables { + private static ConversationDrawables sInstance; + + // Cache the color filtered bubble drawables so that we don't need to create a + // new one for each ConversationMessageView. + private Drawable mIncomingBubbleDrawable; + private Drawable mOutgoingBubbleDrawable; + private Drawable mIncomingErrorBubbleDrawable; + private Drawable mIncomingBubbleNoArrowDrawable; + private Drawable mOutgoingBubbleNoArrowDrawable; + private Drawable mAudioPlayButtonDrawable; + private Drawable mAudioPauseButtonDrawable; + private Drawable mIncomingAudioProgressBackgroundDrawable; + private Drawable mOutgoingAudioProgressBackgroundDrawable; + private Drawable mAudioProgressForegroundDrawable; + private Drawable mFastScrollThumbDrawable; + private Drawable mFastScrollThumbPressedDrawable; + private Drawable mFastScrollPreviewDrawableLeft; + private Drawable mFastScrollPreviewDrawableRight; + private final Context mContext; + private int mOutgoingBubbleColor; + private int mIncomingErrorBubbleColor; + private int mIncomingAudioButtonColor; + private int mSelectedBubbleColor; + private int mThemeColor; + + public static ConversationDrawables get() { + if (sInstance == null) { + sInstance = new ConversationDrawables(Factory.get().getApplicationContext()); + } + return sInstance; + } + + private ConversationDrawables(final Context context) { + mContext = context; + // Pre-create all the drawables. + updateDrawables(); + } + + public int getConversationThemeColor() { + return mThemeColor; + } + + public void updateDrawables() { + final Resources resources = mContext.getResources(); + + mIncomingBubbleDrawable = resources.getDrawable(R.drawable.msg_bubble_incoming); + mIncomingBubbleNoArrowDrawable = + resources.getDrawable(R.drawable.message_bubble_incoming_no_arrow); + mIncomingErrorBubbleDrawable = resources.getDrawable(R.drawable.msg_bubble_error); + mOutgoingBubbleDrawable = resources.getDrawable(R.drawable.msg_bubble_outgoing); + mOutgoingBubbleNoArrowDrawable = + resources.getDrawable(R.drawable.message_bubble_outgoing_no_arrow); + mAudioPlayButtonDrawable = resources.getDrawable(R.drawable.ic_audio_play); + mAudioPauseButtonDrawable = resources.getDrawable(R.drawable.ic_audio_pause); + mIncomingAudioProgressBackgroundDrawable = + resources.getDrawable(R.drawable.audio_progress_bar_background_incoming); + mOutgoingAudioProgressBackgroundDrawable = + resources.getDrawable(R.drawable.audio_progress_bar_background_outgoing); + mAudioProgressForegroundDrawable = + resources.getDrawable(R.drawable.audio_progress_bar_progress); + mFastScrollThumbDrawable = resources.getDrawable(R.drawable.fastscroll_thumb); + mFastScrollThumbPressedDrawable = + resources.getDrawable(R.drawable.fastscroll_thumb_pressed); + mFastScrollPreviewDrawableLeft = + resources.getDrawable(R.drawable.fastscroll_preview_left); + mFastScrollPreviewDrawableRight = + resources.getDrawable(R.drawable.fastscroll_preview_right); + mOutgoingBubbleColor = resources.getColor(R.color.message_bubble_color_outgoing); + mIncomingErrorBubbleColor = + resources.getColor(R.color.message_error_bubble_color_incoming); + mIncomingAudioButtonColor = + resources.getColor(R.color.message_audio_button_color_incoming); + mSelectedBubbleColor = resources.getColor(R.color.message_bubble_color_selected); + mThemeColor = resources.getColor(R.color.primary_color); + } + + public Drawable getBubbleDrawable(final boolean selected, final boolean incoming, + final boolean needArrow, final boolean isError) { + final Drawable protoDrawable; + if (needArrow) { + if (incoming) { + protoDrawable = isError && !selected ? + mIncomingErrorBubbleDrawable : mIncomingBubbleDrawable; + } else { + protoDrawable = mOutgoingBubbleDrawable; + } + } else if (incoming) { + protoDrawable = mIncomingBubbleNoArrowDrawable; + } else { + protoDrawable = mOutgoingBubbleNoArrowDrawable; + } + + int color; + if (selected) { + color = mSelectedBubbleColor; + } else if (incoming) { + if (isError) { + color = mIncomingErrorBubbleColor; + } else { + color = mThemeColor; + } + } else { + color = mOutgoingBubbleColor; + } + + return ImageUtils.getTintedDrawable(mContext, protoDrawable, color); + } + + private int getAudioButtonColor(final boolean incoming) { + return incoming ? mIncomingAudioButtonColor : mThemeColor; + } + + public Drawable getPlayButtonDrawable(final boolean incoming) { + return ImageUtils.getTintedDrawable( + mContext, mAudioPlayButtonDrawable, getAudioButtonColor(incoming)); + } + + public Drawable getPauseButtonDrawable(final boolean incoming) { + return ImageUtils.getTintedDrawable( + mContext, mAudioPauseButtonDrawable, getAudioButtonColor(incoming)); + } + + public Drawable getAudioProgressDrawable(final boolean incoming) { + return ImageUtils.getTintedDrawable( + mContext, mAudioProgressForegroundDrawable, getAudioButtonColor(incoming)); + } + + public Drawable getAudioProgressBackgroundDrawable(final boolean incoming) { + return incoming ? mIncomingAudioProgressBackgroundDrawable : + mOutgoingAudioProgressBackgroundDrawable; + } + + public Drawable getFastScrollThumbDrawable(final boolean pressed) { + if (pressed) { + return ImageUtils.getTintedDrawable(mContext, mFastScrollThumbPressedDrawable, + mThemeColor); + } else { + return mFastScrollThumbDrawable; + } + } + + public Drawable getFastScrollPreviewDrawable(boolean positionRight) { + Drawable protoDrawable = positionRight ? mFastScrollPreviewDrawableRight : + mFastScrollPreviewDrawableLeft; + return ImageUtils.getTintedDrawable(mContext, protoDrawable, mThemeColor); + } +} diff --git a/src/com/android/messaging/ui/CursorRecyclerAdapter.java b/src/com/android/messaging/ui/CursorRecyclerAdapter.java new file mode 100644 index 0000000..f1a7b7d --- /dev/null +++ b/src/com/android/messaging/ui/CursorRecyclerAdapter.java @@ -0,0 +1,333 @@ +/* + * 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.ui; + +import android.content.Context; +import android.database.ContentObserver; +import android.database.Cursor; +import android.database.DataSetObserver; +import android.os.Handler; +import android.support.v7.widget.RecyclerView; +import android.util.Log; +import android.view.ViewGroup; +import android.widget.FilterQueryProvider; + +/** + * Copy of CursorAdapter suited for RecyclerView. + * + * TODO: BUG 16327984. Replace this with a framework supported CursorAdapter for + * RecyclerView when one is available. + */ +public abstract class CursorRecyclerAdapter<VH extends RecyclerView.ViewHolder> + extends RecyclerView.Adapter<VH> { + /** + * This field should be made private, so it is hidden from the SDK. + * {@hide} + */ + protected boolean mDataValid; + /** + * This field should be made private, so it is hidden from the SDK. + * {@hide} + */ + protected boolean mAutoRequery; + /** + * This field should be made private, so it is hidden from the SDK. + * {@hide} + */ + protected Cursor mCursor; + /** + * This field should be made private, so it is hidden from the SDK. + * {@hide} + */ + protected Context mContext; + /** + * This field should be made private, so it is hidden from the SDK. + * {@hide} + */ + protected int mRowIDColumn; + /** + * This field should be made private, so it is hidden from the SDK. + * {@hide} + */ + protected ChangeObserver mChangeObserver; + /** + * This field should be made private, so it is hidden from the SDK. + * {@hide} + */ + protected DataSetObserver mDataSetObserver; + /** + * This field should be made private, so it is hidden from the SDK. + * {@hide} + */ + protected FilterQueryProvider mFilterQueryProvider; + + /** + * If set the adapter will call requery() on the cursor whenever a content change + * notification is delivered. Implies {@link #FLAG_REGISTER_CONTENT_OBSERVER}. + * + * @deprecated This option is discouraged, as it results in Cursor queries + * being performed on the application's UI thread and thus can cause poor + * responsiveness or even Application Not Responding errors. As an alternative, + * use {@link android.app.LoaderManager} with a {@link android.content.CursorLoader}. + */ + @Deprecated + public static final int FLAG_AUTO_REQUERY = 0x01; + + /** + * If set the adapter will register a content observer on the cursor and will call + * {@link #onContentChanged()} when a notification comes in. Be careful when + * using this flag: you will need to unset the current Cursor from the adapter + * to avoid leaks due to its registered observers. This flag is not needed + * when using a CursorAdapter with a + * {@link android.content.CursorLoader}. + */ + public static final int FLAG_REGISTER_CONTENT_OBSERVER = 0x02; + + /** + * Recommended constructor. + * + * @param c The cursor from which to get the data. + * @param context The context + * @param flags Flags used to determine the behavior of the adapter; may + * be any combination of {@link #FLAG_AUTO_REQUERY} and + * {@link #FLAG_REGISTER_CONTENT_OBSERVER}. + */ + public CursorRecyclerAdapter(final Context context, final Cursor c, final int flags) { + init(context, c, flags); + } + + void init(final Context context, final Cursor c, int flags) { + if ((flags & FLAG_AUTO_REQUERY) == FLAG_AUTO_REQUERY) { + flags |= FLAG_REGISTER_CONTENT_OBSERVER; + mAutoRequery = true; + } else { + mAutoRequery = false; + } + final boolean cursorPresent = c != null; + mCursor = c; + mDataValid = cursorPresent; + mContext = context; + mRowIDColumn = cursorPresent ? c.getColumnIndexOrThrow("_id") : -1; + if ((flags & FLAG_REGISTER_CONTENT_OBSERVER) == FLAG_REGISTER_CONTENT_OBSERVER) { + mChangeObserver = new ChangeObserver(); + mDataSetObserver = new MyDataSetObserver(); + } else { + mChangeObserver = null; + mDataSetObserver = null; + } + + if (cursorPresent) { + if (mChangeObserver != null) { + c.registerContentObserver(mChangeObserver); + } + if (mDataSetObserver != null) { + c.registerDataSetObserver(mDataSetObserver); + } + } + } + + /** + * Returns the cursor. + * @return the cursor. + */ + public Cursor getCursor() { + return mCursor; + } + + @Override + public int getItemCount() { + if (mDataValid && mCursor != null) { + return mCursor.getCount(); + } else { + return 0; + } + } + + /** + * @see android.support.v7.widget.RecyclerView.Adapter#getItem(int) + */ + public Object getItem(final int position) { + if (mDataValid && mCursor != null) { + mCursor.moveToPosition(position); + return mCursor; + } else { + return null; + } + } + + /** + * @see android.support.v7.widget.RecyclerView.Adapter#getItemId(int) + */ + @Override + public long getItemId(final int position) { + if (mDataValid && mCursor != null) { + if (mCursor.moveToPosition(position)) { + return mCursor.getLong(mRowIDColumn); + } else { + return 0; + } + } else { + return 0; + } + } + + @Override + public VH onCreateViewHolder(final ViewGroup parent, final int viewType) { + return createViewHolder(mContext, parent, viewType); + } + + @Override + public void onBindViewHolder(final VH holder, final int position) { + if (!mDataValid) { + throw new IllegalStateException("this should only be called when the cursor is valid"); + } + if (!mCursor.moveToPosition(position)) { + throw new IllegalStateException("couldn't move cursor to position " + position); + } + bindViewHolder(holder, mContext, mCursor); + } + /** + * Bind an existing view to the data pointed to by cursor + * @param view Existing view, returned earlier by newView + * @param context Interface to application's global information + * @param cursor The cursor from which to get the data. The cursor is already + * moved to the correct position. + */ + public abstract void bindViewHolder(VH holder, Context context, Cursor cursor); + + /** + * @see android.support.v7.widget.RecyclerView.Adapter#createViewHolder(Context, ViewGroup, int) + */ + public abstract VH createViewHolder(Context context, ViewGroup parent, int viewType); + + /** + * Change the underlying cursor to a new cursor. If there is an existing cursor it will be + * closed. + * + * @param cursor The new cursor to be used + */ + public void changeCursor(final Cursor cursor) { + final Cursor old = swapCursor(cursor); + if (old != null) { + old.close(); + } + } + + /** + * Swap in a new Cursor, returning the old Cursor. Unlike + * {@link #changeCursor(Cursor)}, the returned old Cursor is <em>not</em> + * closed. + * + * @param newCursor The new cursor to be used. + * @return Returns the previously set Cursor, or null if there wasa not one. + * If the given new Cursor is the same instance is the previously set + * Cursor, null is also returned. + */ + public Cursor swapCursor(final Cursor newCursor) { + if (newCursor == mCursor) { + return null; + } + final Cursor oldCursor = mCursor; + if (oldCursor != null) { + if (mChangeObserver != null) { + oldCursor.unregisterContentObserver(mChangeObserver); + } + if (mDataSetObserver != null) { + oldCursor.unregisterDataSetObserver(mDataSetObserver); + } + } + mCursor = newCursor; + if (newCursor != null) { + if (mChangeObserver != null) { + newCursor.registerContentObserver(mChangeObserver); + } + if (mDataSetObserver != null) { + newCursor.registerDataSetObserver(mDataSetObserver); + } + mRowIDColumn = newCursor.getColumnIndexOrThrow("_id"); + mDataValid = true; + // notify the observers about the new cursor + notifyDataSetChanged(); + } else { + mRowIDColumn = -1; + mDataValid = false; + // notify the observers about the lack of a data set + notifyDataSetChanged(); + } + return oldCursor; + } + + /** + * <p>Converts the cursor into a CharSequence. Subclasses should override this + * method to convert their results. The default implementation returns an + * empty String for null values or the default String representation of + * the value.</p> + * + * @param cursor the cursor to convert to a CharSequence + * @return a CharSequence representing the value + */ + public CharSequence convertToString(final Cursor cursor) { + return cursor == null ? "" : cursor.toString(); + } + + /** + * Called when the {@link ContentObserver} on the cursor receives a change notification. + * The default implementation provides the auto-requery logic, but may be overridden by + * sub classes. + * + * @see ContentObserver#onChange(boolean) + */ + protected void onContentChanged() { + if (mAutoRequery && mCursor != null && !mCursor.isClosed()) { + if (false) { + Log.v("Cursor", "Auto requerying " + mCursor + " due to update"); + } + mDataValid = mCursor.requery(); + } + } + + private class ChangeObserver extends ContentObserver { + public ChangeObserver() { + super(new Handler()); + } + + @Override + public boolean deliverSelfNotifications() { + return true; + } + + @Override + public void onChange(final boolean selfChange) { + onContentChanged(); + } + } + + private class MyDataSetObserver extends DataSetObserver { + @Override + public void onChanged() { + mDataValid = true; + notifyDataSetChanged(); + } + + @Override + public void onInvalidated() { + mDataValid = false; + notifyDataSetChanged(); + } + } + +} diff --git a/src/com/android/messaging/ui/CustomHeaderPagerListViewHolder.java b/src/com/android/messaging/ui/CustomHeaderPagerListViewHolder.java new file mode 100644 index 0000000..1268c53 --- /dev/null +++ b/src/com/android/messaging/ui/CustomHeaderPagerListViewHolder.java @@ -0,0 +1,136 @@ +/* + * 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.ui; + +import android.content.Context; +import android.database.Cursor; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.AbsListView; +import android.widget.AbsListView.OnScrollListener; +import android.widget.CursorAdapter; +import android.widget.ListView; + +import com.android.messaging.util.ImeUtil; + +/** + * Produces and holds a list view and its tab header to be displayed in a ViewPager. + */ +public abstract class CustomHeaderPagerListViewHolder extends BasePagerViewHolder + implements CustomHeaderPagerViewHolder { + private final Context mContext; + private final CursorAdapter mListAdapter; + private boolean mListCursorInitialized; + private ListView mListView; + + public CustomHeaderPagerListViewHolder(final Context context, + final CursorAdapter adapter) { + mContext = context; + mListAdapter = adapter; + } + + @Override + protected View createView(ViewGroup container) { + final LayoutInflater inflater = (LayoutInflater) + mContext.getSystemService(Context.LAYOUT_INFLATER_SERVICE); + final View view = inflater.inflate( + getLayoutResId(), + null /* root */, + false /* attachToRoot */); + final ListView listView = (ListView) view.findViewById(getListViewResId()); + listView.setAdapter(mListAdapter); + listView.setOnScrollListener(new OnScrollListener() { + @Override + public void onScrollStateChanged(final AbsListView view, final int scrollState) { + if (scrollState != SCROLL_STATE_IDLE) { + ImeUtil.get().hideImeKeyboard(mContext, view); + } + } + + @Override + public void onScroll(final AbsListView view, final int firstVisibleItem, + final int visibleItemCount, final int totalItemCount) { + } + }); + mListView = listView; + maybeSetEmptyView(); + return view; + } + + public void onContactsCursorUpdated(final Cursor data) { + mListAdapter.swapCursor(data); + if (!mListCursorInitialized) { + // We set the emptyView here instead of in create so that the initial load won't show + // the empty UI - the system handles this and doesn't do what we would like. + mListCursorInitialized = true; + maybeSetEmptyView(); + } + } + + /** + * We don't want to show the empty view hint until BOTH conditions are met: + * 1. The view has been created. + * 2. Cursor data has been loaded once. + * Due to timing when data is loaded, the view may not be ready (and vice versa). So we + * are calling this method from both onContactsCursorUpdated & createView. + */ + private void maybeSetEmptyView() { + if (mView != null && mListCursorInitialized) { + final ListEmptyView emptyView = (ListEmptyView) mView.findViewById(getEmptyViewResId()); + if (emptyView != null) { + emptyView.setTextHint(getEmptyViewTitleResId()); + emptyView.setImageHint(getEmptyViewImageResId()); + final ListView listView = (ListView) mView.findViewById(getListViewResId()); + listView.setEmptyView(emptyView); + } + } + } + + public void invalidateList() { + mListAdapter.notifyDataSetChanged(); + } + + /** + * In order for scene transition to work, we toggle the visibility for each individual list + * view items so that they can be properly tracked by the scene transition manager. + * @param show whether the pending transition is to show or hide the list. + */ + public void toggleVisibilityForPendingTransition(final boolean show, final View epicenterView) { + if (mListView == null) { + return; + } + final int childCount = mListView.getChildCount(); + for (int i = 0; i < childCount; i++) { + final View childView = mListView.getChildAt(i); + if (childView != epicenterView) { + childView.setVisibility(show ? View.VISIBLE : View.INVISIBLE); + } + } + } + + @Override + public CharSequence getPageTitle(Context context) { + return context.getString(getPageTitleResId()); + } + + protected abstract int getLayoutResId(); + protected abstract int getPageTitleResId(); + protected abstract int getEmptyViewResId(); + protected abstract int getEmptyViewTitleResId(); + protected abstract int getEmptyViewImageResId(); + protected abstract int getListViewResId(); +} diff --git a/src/com/android/messaging/ui/CustomHeaderPagerViewHolder.java b/src/com/android/messaging/ui/CustomHeaderPagerViewHolder.java new file mode 100644 index 0000000..43802cd --- /dev/null +++ b/src/com/android/messaging/ui/CustomHeaderPagerViewHolder.java @@ -0,0 +1,26 @@ +/* + * 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.ui; + +import android.content.Context; + +/** + * An extension on the standard PagerViewHolder to return a custom header view to be used by + * CustomHeaderViewPager + */ +public interface CustomHeaderPagerViewHolder extends PagerViewHolder { + CharSequence getPageTitle(Context context); +} diff --git a/src/com/android/messaging/ui/CustomHeaderViewPager.java b/src/com/android/messaging/ui/CustomHeaderViewPager.java new file mode 100644 index 0000000..2e8df42 --- /dev/null +++ b/src/com/android/messaging/ui/CustomHeaderViewPager.java @@ -0,0 +1,91 @@ +/* + * 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.ui; + +import android.content.Context; +import android.support.v4.view.PagerAdapter; +import android.support.v4.view.ViewPager; +import android.support.v4.view.ViewPager.OnPageChangeListener; +import android.util.AttributeSet; +import android.util.TypedValue; +import android.view.LayoutInflater; +import android.widget.LinearLayout; + +import com.android.messaging.R; +import com.android.messaging.util.Assert; + +/** + * A view that contains both a view pager and a tab strip wrapped in a linear layout. + */ +public class CustomHeaderViewPager extends LinearLayout { + public final static int DEFAULT_TAB_STRIP_SIZE = -1; + private final int mDefaultTabStripSize; + private ViewPager mViewPager; + private ViewPagerTabs mTabstrip; + + public CustomHeaderViewPager(final Context context, final AttributeSet attrs) { + super(context, attrs); + + final LayoutInflater inflater = LayoutInflater.from(context); + inflater.inflate(R.layout.custom_header_view_pager, this, true); + setOrientation(LinearLayout.VERTICAL); + + mTabstrip = (ViewPagerTabs) findViewById(R.id.tab_strip); + mViewPager = (ViewPager) findViewById(R.id.pager); + + TypedValue tv = new TypedValue(); + context.getTheme().resolveAttribute(android.R.attr.actionBarSize, tv, true); + mDefaultTabStripSize = context.getResources().getDimensionPixelSize(tv.resourceId); + } + + public void setCurrentItem(final int position) { + mViewPager.setCurrentItem(position); + } + + public void setViewPagerTabHeight(final int tabHeight) { + mTabstrip.getLayoutParams().height = tabHeight == DEFAULT_TAB_STRIP_SIZE ? + mDefaultTabStripSize : tabHeight; + } + + public void setViewHolders(final CustomHeaderPagerViewHolder[] viewHolders) { + Assert.notNull(mViewPager); + final PagerAdapter adapter = new CustomHeaderViewPagerAdapter(viewHolders); + mViewPager.setAdapter(adapter); + mTabstrip.setViewPager(mViewPager); + mViewPager.setOnPageChangeListener(new OnPageChangeListener() { + + @Override + public void onPageScrollStateChanged(int state) { + mTabstrip.onPageScrollStateChanged(state); + } + + @Override + public void onPageScrolled(int position, float positionOffset, + int positionOffsetPixels) { + mTabstrip.onPageScrolled(position, positionOffset, positionOffsetPixels); + } + + @Override + public void onPageSelected(int position) { + mTabstrip.onPageSelected(position); + } + }); + } + + public int getSelectedItemPosition() { + return mTabstrip.getSelectedItemPosition(); + } +} diff --git a/src/com/android/messaging/ui/CustomHeaderViewPagerAdapter.java b/src/com/android/messaging/ui/CustomHeaderViewPagerAdapter.java new file mode 100644 index 0000000..1a5cf6a --- /dev/null +++ b/src/com/android/messaging/ui/CustomHeaderViewPagerAdapter.java @@ -0,0 +1,33 @@ +/* + * 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.ui; + +import com.android.messaging.Factory; + +public class CustomHeaderViewPagerAdapter extends + FixedViewPagerAdapter<CustomHeaderPagerViewHolder> { + + public CustomHeaderViewPagerAdapter(final CustomHeaderPagerViewHolder[] viewHolders) { + super(viewHolders); + } + + @Override + public CharSequence getPageTitle(int position) { + // The tab strip will handle RTL internally so we should use raw position. + return getViewHolder(position, false /* rtlAware */) + .getPageTitle(Factory.get().getApplicationContext()); + } +} diff --git a/src/com/android/messaging/ui/FixedViewPagerAdapter.java b/src/com/android/messaging/ui/FixedViewPagerAdapter.java new file mode 100644 index 0000000..bd73b71 --- /dev/null +++ b/src/com/android/messaging/ui/FixedViewPagerAdapter.java @@ -0,0 +1,132 @@ +/* + * 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.ui; + +import android.os.Bundle; +import android.os.Parcelable; +import android.support.v4.view.PagerAdapter; +import android.view.View; +import android.view.ViewGroup; + +import com.android.messaging.Factory; +import com.android.messaging.util.Assert; +import com.android.messaging.util.UiUtils; +import com.google.common.annotations.VisibleForTesting; + +/** + * A PagerAdapter that provides a fixed number of paged Views provided by a fixed set of + * {@link PagerViewHolder}'s. This allows us to put a fixed number of Views, instead of fragments, + * into a given ViewPager. + */ +public class FixedViewPagerAdapter<T extends PagerViewHolder> extends PagerAdapter { + private final T[] mViewHolders; + + public FixedViewPagerAdapter(final T[] viewHolders) { + Assert.notNull(viewHolders); + mViewHolders = viewHolders; + } + + @Override + public Object instantiateItem(final ViewGroup container, final int position) { + final PagerViewHolder viewHolder = getViewHolder(position); + final View view = viewHolder.getView(container); + if (view == null) { + return null; + } + view.setTag(viewHolder); + container.addView(view); + return viewHolder; + } + + @Override + public void destroyItem(final ViewGroup container, final int position, final Object object) { + final PagerViewHolder viewHolder = getViewHolder(position); + final View destroyedView = viewHolder.destroyView(); + if (destroyedView != null) { + container.removeView(destroyedView); + } + } + + @Override + public int getCount() { + return mViewHolders.length; + } + + @Override + public boolean isViewFromObject(final View view, final Object object) { + return view.getTag() == object; + } + + public T getViewHolder(final int i) { + return getViewHolder(i, true /* rtlAware */); + } + + @VisibleForTesting + public T getViewHolder(final int i, final boolean rtlAware) { + return mViewHolders[rtlAware ? getRtlPosition(i) : i]; + } + + @Override + public Parcelable saveState() { + // The paged views in the view pager gets created and destroyed as the user scrolls through + // them. By default, only the pages to the immediate left and right of the current visible + // page are realized. Moreover, if the activity gets destroyed and recreated, the pages are + // automatically destroyed. Therefore, in order to preserve transient page UI states that + // are not persisted in the DB we'd like to store them in a Bundle when views get + // destroyed. When the views get recreated, we rehydrate them by passing them the saved + // data. When the activity gets destroyed, it invokes saveState() on this adapter to + // add this saved Bundle to the overall saved instance state. + final Bundle savedViewHolderState = new Bundle(Factory.get().getApplicationContext() + .getClassLoader()); + for (int i = 0; i < mViewHolders.length; i++) { + final Parcelable pageState = getViewHolder(i).saveState(); + savedViewHolderState.putParcelable(getInstanceStateKeyForPage(i), pageState); + } + return savedViewHolderState; + } + + @Override + public void restoreState(final Parcelable state, final ClassLoader loader) { + if (state instanceof Bundle) { + final Bundle restoredViewHolderState = (Bundle) state; + ((Bundle) state).setClassLoader(Factory.get().getApplicationContext().getClassLoader()); + for (int i = 0; i < mViewHolders.length; i++) { + final Parcelable pageState = restoredViewHolderState + .getParcelable(getInstanceStateKeyForPage(i)); + getViewHolder(i).restoreState(pageState); + } + } else { + super.restoreState(state, loader); + } + } + + public void resetState() { + for (int i = 0; i < mViewHolders.length; i++) { + getViewHolder(i).resetState(); + } + } + + private String getInstanceStateKeyForPage(final int i) { + return getViewHolder(i).getClass().getCanonicalName() + "_savedstate_" + i; + } + + protected int getRtlPosition(final int position) { + if (UiUtils.isRtlMode()) { + return mViewHolders.length - 1 - position; + } + return position; + } +} diff --git a/src/com/android/messaging/ui/ImeDetectFrameLayout.java b/src/com/android/messaging/ui/ImeDetectFrameLayout.java new file mode 100644 index 0000000..32564ea --- /dev/null +++ b/src/com/android/messaging/ui/ImeDetectFrameLayout.java @@ -0,0 +1,46 @@ +/* + * 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.ui; + +import android.content.Context; +import android.util.AttributeSet; +import android.widget.FrameLayout; + +import com.android.messaging.util.ImeUtil; +import com.android.messaging.util.LogUtil; + +public class ImeDetectFrameLayout extends FrameLayout { + public ImeDetectFrameLayout(Context context, AttributeSet attrs) { + super(context, attrs); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + final int measuredHeight = getMeasuredHeight(); + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + + if (LogUtil.isLoggable(LogUtil.BUGLE_TAG, LogUtil.VERBOSE)) { + LogUtil.v(LogUtil.BUGLE_TAG, "ImeDetectFrameLayout " + + "measuredHeight: " + measuredHeight + " getMeasuredHeight(): " + + getMeasuredHeight()); + } + + if (measuredHeight != getMeasuredHeight() && getContext() instanceof ImeUtil.ImeStateHost) { + ((ImeUtil.ImeStateHost) getContext()).onDisplayHeightChanged(heightMeasureSpec); + } + } +} diff --git a/src/com/android/messaging/ui/LicenseActivity.java b/src/com/android/messaging/ui/LicenseActivity.java new file mode 100644 index 0000000..a28da81 --- /dev/null +++ b/src/com/android/messaging/ui/LicenseActivity.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.ui; + +import android.app.Activity; +import android.os.Bundle; +import android.webkit.WebView; + +import com.android.messaging.R; + +public class LicenseActivity extends Activity { + private final String LICENSE_URL = "file:///android_asset/licenses.html"; + + @Override + public void onCreate(final Bundle bundle) { + super.onCreate(bundle); + setContentView(R.layout.license_activity); + final WebView webView = (WebView) findViewById(R.id.content); + webView.loadUrl(LICENSE_URL); + } +} diff --git a/src/com/android/messaging/ui/LineWrapLayout.java b/src/com/android/messaging/ui/LineWrapLayout.java new file mode 100644 index 0000000..5219811 --- /dev/null +++ b/src/com/android/messaging/ui/LineWrapLayout.java @@ -0,0 +1,232 @@ +/* + * 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.ui; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.Gravity; +import android.view.View; +import android.view.ViewGroup; +import android.widget.FrameLayout; + +import com.android.messaging.util.OsUtil; +import com.android.messaging.util.UiUtils; + +import java.util.ArrayList; + +/** +* A line-wrapping flow layout. Arranges children in horizontal flow, packing as many +* child views as possible on each line. When the current line does not +* have enough horizontal space, the layout continues on the next line. +*/ +public class LineWrapLayout extends ViewGroup { + public LineWrapLayout(Context context) { + this(context, null); + } + + public LineWrapLayout(Context context, AttributeSet attrs) { + super(context, attrs); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + final int startPadding = UiUtils.getPaddingStart(this); + final int endPadding = UiUtils.getPaddingEnd(this); + final int widthMode = MeasureSpec.getMode(widthMeasureSpec); + final int widthSize = MeasureSpec.getSize(widthMeasureSpec) - startPadding - endPadding; + final boolean isFixedSize = (widthMode == MeasureSpec.EXACTLY); + + int height = 0; + + int childCount = getChildCount(); + int childWidthSpec = MeasureSpec.makeMeasureSpec(widthSize, MeasureSpec.AT_MOST); + + int x = startPadding; + int currLineWidth = 0; + int currLineHeight = 0; + int maxLineWidth = 0; + + for (int i = 0; i < childCount; i++) { + View currChild = getChildAt(i); + if (currChild.getVisibility() == GONE) { + continue; + } + LayoutParams layoutParams = (LayoutParams) currChild.getLayoutParams(); + int startMargin = layoutParams.getStartMargin(); + int endMargin = layoutParams.getEndMargin(); + currChild.measure(childWidthSpec, MeasureSpec.UNSPECIFIED); + int childMeasuredWidth = currChild.getMeasuredWidth() + startMargin + endMargin; + int childMeasuredHeight = currChild.getMeasuredHeight() + layoutParams.topMargin + + layoutParams.bottomMargin; + + if ((x + childMeasuredWidth) > widthSize) { + // New line. Update the overall height and reset trackers. + height += currLineHeight; + currLineHeight = 0; + x = startPadding; + currLineWidth = 0; + startMargin = 0; + } + + x += childMeasuredWidth; + currLineWidth += childMeasuredWidth; + currLineHeight = Math.max(currLineHeight, childMeasuredHeight); + maxLineWidth = Math.max(currLineWidth, maxLineWidth); + } + // And account for the height of the last line. + height += currLineHeight; + + int width = isFixedSize ? widthSize : maxLineWidth; + setMeasuredDimension(width + startPadding + endPadding, + height + getPaddingTop() + getPaddingBottom()); + } + + @Override + protected void onLayout(boolean changed, int l, int t, int r, int b) { + final int startPadding = UiUtils.getPaddingStart(this); + final int endPadding = UiUtils.getPaddingEnd(this); + int width = getWidth() - startPadding - endPadding; + int y = getPaddingTop(); + int x = startPadding; + int childCount = getChildCount(); + + int currLineHeight = 0; + + // Do a dry-run first to get the line heights. + final ArrayList<Integer> lineHeights = new ArrayList<Integer>(); + for (int i = 0; i < childCount; i++) { + View currChild = getChildAt(i); + if (currChild.getVisibility() == GONE) { + continue; + } + LayoutParams layoutParams = (LayoutParams) currChild.getLayoutParams(); + int childWidth = currChild.getMeasuredWidth(); + int childHeight = currChild.getMeasuredHeight(); + int startMargin = layoutParams.getStartMargin(); + int endMargin = layoutParams.getEndMargin(); + + if ((x + childWidth + startMargin + endMargin) > width) { + // new line + lineHeights.add(currLineHeight); + currLineHeight = 0; + x = startPadding; + startMargin = 0; + } + currLineHeight = Math.max(currLineHeight, childHeight + layoutParams.topMargin + + layoutParams.bottomMargin); + x += childWidth + startMargin + endMargin; + } + // Add the last line height. + lineHeights.add(currLineHeight); + + // Now perform the actual layout. + x = startPadding; + currLineHeight = 0; + int lineIndex = 0; + for (int i = 0; i < childCount; i++) { + View currChild = getChildAt(i); + if (currChild.getVisibility() == GONE) { + continue; + } + LayoutParams layoutParams = (LayoutParams) currChild.getLayoutParams(); + int childWidth = currChild.getMeasuredWidth(); + int childHeight = currChild.getMeasuredHeight(); + int startMargin = layoutParams.getStartMargin(); + int endMargin = layoutParams.getEndMargin(); + + if ((x + childWidth + startMargin + endMargin) > width) { + // new line + y += currLineHeight; + currLineHeight = 0; + x = startPadding; + startMargin = 0; + lineIndex++; + } + final int startPositionX = x + startMargin; + int startPositionY = y + layoutParams.topMargin; // default to top gravity + final int majorGravity = layoutParams.gravity & Gravity.VERTICAL_GRAVITY_MASK; + if (majorGravity != Gravity.TOP && lineHeights.size() > lineIndex) { + final int lineHeight = lineHeights.get(lineIndex); + switch (majorGravity) { + case Gravity.BOTTOM: + startPositionY = y + lineHeight - childHeight - layoutParams.bottomMargin; + break; + + case Gravity.CENTER_VERTICAL: + startPositionY = y + (lineHeight - childHeight) / 2; + break; + } + } + + if (OsUtil.isAtLeastJB_MR2() && getResources().getConfiguration() + .getLayoutDirection() == View.LAYOUT_DIRECTION_RTL) { + currChild.layout(width - startPositionX - childWidth, startPositionY, + width - startPositionX, startPositionY + childHeight); + } else { + currChild.layout(startPositionX, startPositionY, startPositionX + childWidth, + startPositionY + childHeight); + } + currLineHeight = Math.max(currLineHeight, childHeight + layoutParams.topMargin + + layoutParams.bottomMargin); + x += childWidth + startMargin + endMargin; + } + } + + @Override + protected LayoutParams generateLayoutParams(ViewGroup.LayoutParams p) { + return new LayoutParams(p); + } + + @Override + public LayoutParams generateLayoutParams(AttributeSet attrs) { + return new LayoutParams(getContext(), attrs); + } + + @Override + protected LayoutParams generateDefaultLayoutParams() { + return new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT); + } + + public static final class LayoutParams extends FrameLayout.LayoutParams { + public LayoutParams(Context c, AttributeSet attrs) { + super(c, attrs); + } + + public LayoutParams(int width, int height) { + super(width, height); + } + + public LayoutParams(ViewGroup.LayoutParams source) { + super(source); + } + + public int getStartMargin() { + if (OsUtil.isAtLeastJB_MR2()) { + return getMarginStart(); + } else { + return leftMargin; + } + } + + public int getEndMargin() { + if (OsUtil.isAtLeastJB_MR2()) { + return getMarginEnd(); + } else { + return rightMargin; + } + } + } +} diff --git a/src/com/android/messaging/ui/ListEmptyView.java b/src/com/android/messaging/ui/ListEmptyView.java new file mode 100644 index 0000000..8cf3049 --- /dev/null +++ b/src/com/android/messaging/ui/ListEmptyView.java @@ -0,0 +1,72 @@ +/* + * 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.ui; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.Gravity; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.LinearLayout.LayoutParams; +import android.widget.TextView; + +import com.android.messaging.R; + +/** + * A common reusable view that shows a hint image and text for an empty list view. + */ +public class ListEmptyView extends LinearLayout { + private ImageView mEmptyImageHint; + private TextView mEmptyTextHint; + + public ListEmptyView(final Context context, final AttributeSet attrs) { + super(context, attrs); + } + + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + + mEmptyImageHint = (ImageView) findViewById(R.id.empty_image_hint); + mEmptyTextHint = (TextView) findViewById(R.id.empty_text_hint); + } + + public void setImageHint(final int resId) { + mEmptyImageHint.setImageResource(resId); + } + + public void setTextHint(final int resId) { + mEmptyTextHint.setText(getResources().getText(resId)); + } + + public void setTextHint(final CharSequence hintText) { + mEmptyTextHint.setText(hintText); + } + + public void setIsImageVisible(final boolean isImageVisible) { + mEmptyImageHint.setVisibility(isImageVisible ? VISIBLE : GONE); + } + + public void setIsVerticallyCentered(final boolean isVerticallyCentered) { + int gravity = + isVerticallyCentered ? Gravity.CENTER : Gravity.TOP | Gravity.CENTER_HORIZONTAL; + ((LinearLayout.LayoutParams) mEmptyImageHint.getLayoutParams()).gravity = gravity; + ((LinearLayout.LayoutParams) mEmptyTextHint.getLayoutParams()).gravity = gravity; + getLayoutParams().height = + isVerticallyCentered ? LayoutParams.WRAP_CONTENT : LayoutParams.MATCH_PARENT; + requestLayout(); + } +} diff --git a/src/com/android/messaging/ui/MaxHeightScrollView.java b/src/com/android/messaging/ui/MaxHeightScrollView.java new file mode 100644 index 0000000..a000cd1 --- /dev/null +++ b/src/com/android/messaging/ui/MaxHeightScrollView.java @@ -0,0 +1,50 @@ +/* + * 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.ui; + +import android.content.Context; +import android.content.res.TypedArray; +import android.util.AttributeSet; +import android.widget.ScrollView; + +import com.android.messaging.R; + +/** + * A ScrollView that limits the maximum height that it can take. This is to work around android + * layout's limitation of not having android:maxHeight. + */ +public class MaxHeightScrollView extends ScrollView { + private static final int NO_MAX_HEIGHT = -1; + + private final int mMaxHeight; + + public MaxHeightScrollView(final Context context, final AttributeSet attrs) { + super(context, attrs); + final TypedArray attr = context.obtainStyledAttributes(attrs, + R.styleable.MaxHeightScrollView, 0, 0); + mMaxHeight = attr.getDimensionPixelSize(R.styleable.MaxHeightScrollView_android_maxHeight, + NO_MAX_HEIGHT); + attr.recycle(); + } + + @Override + protected void onMeasure(final int widthMeasureSpec, final int heightMeasureSpec) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + if (mMaxHeight != NO_MAX_HEIGHT) { + setMeasuredDimension(getMeasuredWidth(), Math.min(getMeasuredHeight(), mMaxHeight)); + } + } +}
\ No newline at end of file diff --git a/src/com/android/messaging/ui/MultiAttachmentLayout.java b/src/com/android/messaging/ui/MultiAttachmentLayout.java new file mode 100644 index 0000000..f620245 --- /dev/null +++ b/src/com/android/messaging/ui/MultiAttachmentLayout.java @@ -0,0 +1,424 @@ +/* + * 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.ui; + +import android.content.Context; +import android.graphics.Rect; +import android.util.AttributeSet; +import android.view.LayoutInflater; +import android.view.View; +import android.view.animation.AnimationSet; +import android.view.animation.ScaleAnimation; +import android.view.animation.TranslateAnimation; +import android.widget.FrameLayout; +import android.widget.TextView; + +import com.android.messaging.R; +import com.android.messaging.datamodel.data.MediaPickerMessagePartData; +import com.android.messaging.datamodel.data.MessagePartData; +import com.android.messaging.datamodel.data.PendingAttachmentData; +import com.android.messaging.datamodel.media.ImageRequestDescriptor; +import com.android.messaging.ui.AsyncImageView.AsyncImageViewDelayLoader; +import com.android.messaging.util.AccessibilityUtil; +import com.android.messaging.util.Assert; +import com.android.messaging.util.UiUtils; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Iterator; +import java.util.List; + +/** + * Holds and displays multiple attachments in a 4x2 grid. Each preview image "tile" can take + * one of three sizes - small (1x1), wide (2x1) and large (2x2). We have a number of predefined + * layout settings designed for holding 2, 3, 4+ attachments (these layout settings are + * tweakable by design request to allow for max flexibility). For a visual example, consider the + * following attachment layout: + * + * +---------------+----------------+ + * | | | + * | | B | + * | | | + * | A |-------+--------| + * | | | | + * | | C | D | + * | | | | + * +---------------+-------+--------+ + * + * In the above example, the layout consists of four tiles, A-D. A is a large tile, B is a + * wide tile and C & D are both small tiles. A starts at (0,0) and ends at (1,1), B starts at + * (2,0) and ends at (3,0), and so on. In our layout class we'd have these tiles in the order + * of A-D, so that we make sure the last tile is always the one where we can put the overflow + * indicator (e.g. "+2"). + */ +public class MultiAttachmentLayout extends FrameLayout { + + public interface OnAttachmentClickListener { + boolean onAttachmentClick(MessagePartData attachment, Rect viewBoundsOnScreen, + boolean longPress); + } + + private static final int GRID_WIDTH = 4; // in # of cells + private static final int GRID_HEIGHT = 2; // in # of cells + + /** + * Represents a preview image tile in the layout + */ + private static class Tile { + public final int startX; + public final int startY; + public final int endX; + public final int endY; + + private Tile(final int startX, final int startY, final int endX, final int endY) { + this.startX = startX; + this.startY = startY; + this.endX = endX; + this.endY = endY; + } + + public int getWidthMeasureSpec(final int cellWidth, final int padding) { + return MeasureSpec.makeMeasureSpec((endX - startX + 1) * cellWidth - padding * 2, + MeasureSpec.EXACTLY); + } + + public int getHeightMeasureSpec(final int cellHeight, final int padding) { + return MeasureSpec.makeMeasureSpec((endY - startY + 1) * cellHeight - padding * 2, + MeasureSpec.EXACTLY); + } + + public static Tile large(final int startX, final int startY) { + return new Tile(startX, startY, startX + 1, startY + 1); + } + + public static Tile wide(final int startX, final int startY) { + return new Tile(startX, startY, startX + 1, startY); + } + + public static Tile small(final int startX, final int startY) { + return new Tile(startX, startY, startX, startY); + } + } + + /** + * A layout simply contains a list of tiles, in the order of top-left -> bottom-right. + */ + private static class Layout { + public final List<Tile> tiles; + public Layout(final Tile[] tilesArray) { + tiles = Arrays.asList(tilesArray); + } + } + + /** + * List of predefined layout configurations w.r.t no. of attachments. + */ + private static final Layout[] ATTACHMENT_LAYOUTS_BY_COUNT = { + null, // Doesn't support zero attachments. + null, // Doesn't support one attachment. Single attachment preview is used instead. + new Layout(new Tile[] { Tile.large(0, 0), Tile.large(2, 0) }), // 2 items + new Layout(new Tile[] { Tile.large(0, 0), Tile.wide(2, 0), Tile.wide(2, 1) }), // 3 items + new Layout(new Tile[] { Tile.large(0, 0), Tile.wide(2, 0), Tile.small(2, 1), // 4+ items + Tile.small(3, 1) }), + }; + + /** + * List of predefined RTL layout configurations w.r.t no. of attachments. + */ + private static final Layout[] ATTACHMENT_RTL_LAYOUTS_BY_COUNT = { + null, // Doesn't support zero attachments. + null, // Doesn't support one attachment. Single attachment preview is used instead. + new Layout(new Tile[] { Tile.large(2, 0), Tile.large(0, 0)}), // 2 items + new Layout(new Tile[] { Tile.large(2, 0), Tile.wide(0, 0), Tile.wide(0, 1) }), // 3 items + new Layout(new Tile[] { Tile.large(2, 0), Tile.wide(0, 0), Tile.small(1, 1), // 4+ items + Tile.small(0, 1) }), + }; + + private Layout mCurrentLayout; + private ArrayList<ViewWrapper> mPreviewViews; + private int mPlusNumber; + private TextView mPlusTextView; + private OnAttachmentClickListener mAttachmentClickListener; + private AsyncImageViewDelayLoader mImageViewDelayLoader; + + public MultiAttachmentLayout(final Context context, final AttributeSet attrs) { + super(context, attrs); + mPreviewViews = new ArrayList<ViewWrapper>(); + } + + public void bindAttachments(final Iterable<MessagePartData> attachments, + final Rect transitionRect, final int count) { + final ArrayList<ViewWrapper> previousViews = mPreviewViews; + mPreviewViews = new ArrayList<ViewWrapper>(); + removeView(mPlusTextView); + mPlusTextView = null; + + determineLayout(attachments, count); + buildViews(attachments, previousViews, transitionRect); + + // Remove all previous views that couldn't be recycled. + for (final ViewWrapper viewWrapper : previousViews) { + removeView(viewWrapper.view); + } + requestLayout(); + } + + public OnAttachmentClickListener getOnAttachmentClickListener() { + return mAttachmentClickListener; + } + + public void setOnAttachmentClickListener(final OnAttachmentClickListener listener) { + mAttachmentClickListener = listener; + } + + public void setImageViewDelayLoader(final AsyncImageViewDelayLoader delayLoader) { + mImageViewDelayLoader = delayLoader; + } + + public void setColorFilter(int color) { + for (ViewWrapper viewWrapper : mPreviewViews) { + if (viewWrapper.view instanceof AsyncImageView) { + ((AsyncImageView) viewWrapper.view).setColorFilter(color); + } + } + } + + public void clearColorFilter() { + for (ViewWrapper viewWrapper : mPreviewViews) { + if (viewWrapper.view instanceof AsyncImageView) { + ((AsyncImageView) viewWrapper.view).clearColorFilter(); + } + } + } + + private void determineLayout(final Iterable<MessagePartData> attachments, final int count) { + Assert.isTrue(attachments != null); + final boolean isRtl = AccessibilityUtil.isLayoutRtl(getRootView()); + if (isRtl) { + mCurrentLayout = ATTACHMENT_RTL_LAYOUTS_BY_COUNT[Math.min(count, + ATTACHMENT_RTL_LAYOUTS_BY_COUNT.length - 1)]; + } else { + mCurrentLayout = ATTACHMENT_LAYOUTS_BY_COUNT[Math.min(count, + ATTACHMENT_LAYOUTS_BY_COUNT.length - 1)]; + } + + // We must have a valid layout for the current configuration. + Assert.notNull(mCurrentLayout); + + mPlusNumber = count - mCurrentLayout.tiles.size(); + Assert.isTrue(mPlusNumber >= 0); + } + + private void buildViews(final Iterable<MessagePartData> attachments, + final ArrayList<ViewWrapper> previousViews, final Rect transitionRect) { + final LayoutInflater layoutInflater = LayoutInflater.from(getContext()); + final int count = mCurrentLayout.tiles.size(); + int i = 0; + final Iterator<MessagePartData> iterator = attachments.iterator(); + while (iterator.hasNext() && i < count) { + final MessagePartData attachment = iterator.next(); + ViewWrapper attachmentWrapper = null; + // Try to recycle a previous view first + for (int j = 0; j < previousViews.size(); j++) { + final ViewWrapper previousView = previousViews.get(j); + if (previousView.attachment.equals(attachment) && + !(previousView.attachment instanceof PendingAttachmentData)) { + attachmentWrapper = previousView; + previousViews.remove(j); + break; + } + } + + if (attachmentWrapper == null) { + final View view = AttachmentPreviewFactory.createAttachmentPreview(layoutInflater, + attachment, this, AttachmentPreviewFactory.TYPE_MULTIPLE, + false /* startImageRequest */, mAttachmentClickListener); + + if (view == null) { + // createAttachmentPreview can return null if something goes wrong (e.g. + // attachment has unsupported contentType) + continue; + } + if (view instanceof AsyncImageView && mImageViewDelayLoader != null) { + AsyncImageView asyncImageView = (AsyncImageView) view; + asyncImageView.setDelayLoader(mImageViewDelayLoader); + } + addView(view); + attachmentWrapper = new ViewWrapper(view, attachment); + // Help animate from single to multi by copying over the prev location + if (count == 2 && i == 1 && transitionRect != null) { + attachmentWrapper.prevLeft = transitionRect.left; + attachmentWrapper.prevTop = transitionRect.top; + attachmentWrapper.prevWidth = transitionRect.width(); + attachmentWrapper.prevHeight = transitionRect.height(); + } + } + i++; + Assert.notNull(attachmentWrapper); + mPreviewViews.add(attachmentWrapper); + + // The first view will animate in using PopupTransitionAnimation, but the remaining + // views will slide from their previous position to their new position within the + // layout + if (i == 0) { + AttachmentPreview.tryAnimateViewIn(attachment, attachmentWrapper.view); + } + attachmentWrapper.needsSlideAnimation = i > 0; + } + + // Build the plus text view (e.g. "+2") for when there are more attachments than what + // this layout can display. + if (mPlusNumber > 0) { + mPlusTextView = (TextView) layoutInflater.inflate(R.layout.attachment_more_text_view, + null /* parent */); + mPlusTextView.setText(getResources().getString(R.string.attachment_more_items, + mPlusNumber)); + addView(mPlusTextView); + } + } + + @Override + protected void onMeasure(final int widthMeasureSpec, final int heightMeasureSpec) { + final int maxWidth = getResources().getDimensionPixelSize( + R.dimen.multiple_attachment_preview_width); + final int maxHeight = getResources().getDimensionPixelSize( + R.dimen.multiple_attachment_preview_height); + final int width = Math.min(MeasureSpec.getSize(widthMeasureSpec), maxWidth); + final int height = maxHeight; + final int cellWidth = width / GRID_WIDTH; + final int cellHeight = height / GRID_HEIGHT; + final int count = mPreviewViews.size(); + final int padding = getResources().getDimensionPixelOffset( + R.dimen.multiple_attachment_preview_padding); + for (int i = 0; i < count; i++) { + final View view = mPreviewViews.get(i).view; + final Tile imageTile = mCurrentLayout.tiles.get(i); + view.measure(imageTile.getWidthMeasureSpec(cellWidth, padding), + imageTile.getHeightMeasureSpec(cellHeight, padding)); + + // Now that we know the size, we can request an appropriately-sized image. + if (view instanceof AsyncImageView) { + final ImageRequestDescriptor imageRequest = + AttachmentPreviewFactory.getImageRequestDescriptorForAttachment( + mPreviewViews.get(i).attachment, + view.getMeasuredWidth(), + view.getMeasuredHeight()); + ((AsyncImageView) view).setImageResourceId(imageRequest); + } + + if (i == count - 1 && mPlusTextView != null) { + // The plus text view always covers the last attachment. + mPlusTextView.measure(imageTile.getWidthMeasureSpec(cellWidth, padding), + imageTile.getHeightMeasureSpec(cellHeight, padding)); + } + } + setMeasuredDimension(width, height); + } + + @Override + protected void onLayout(final boolean changed, final int left, final int top, final int right, + final int bottom) { + final int cellWidth = getMeasuredWidth() / GRID_WIDTH; + final int cellHeight = getMeasuredHeight() / GRID_HEIGHT; + final int padding = getResources().getDimensionPixelOffset( + R.dimen.multiple_attachment_preview_padding); + final int count = mPreviewViews.size(); + for (int i = 0; i < count; i++) { + final ViewWrapper viewWrapper = mPreviewViews.get(i); + final View view = viewWrapper.view; + final Tile imageTile = mCurrentLayout.tiles.get(i); + final int tileLeft = imageTile.startX * cellWidth; + final int tileTop = imageTile.startY * cellHeight; + view.layout(tileLeft + padding, tileTop + padding, + tileLeft + view.getMeasuredWidth(), + tileTop + view.getMeasuredHeight()); + if (viewWrapper.needsSlideAnimation) { + trySlideAttachmentView(viewWrapper); + viewWrapper.needsSlideAnimation = false; + } else { + viewWrapper.prevLeft = view.getLeft(); + viewWrapper.prevTop = view.getTop(); + viewWrapper.prevWidth = view.getWidth(); + viewWrapper.prevHeight = view.getHeight(); + } + + if (i == count - 1 && mPlusTextView != null) { + // The plus text view always covers the last attachment. + mPlusTextView.layout(tileLeft + padding, tileTop + padding, + tileLeft + mPlusTextView.getMeasuredWidth(), + tileTop + mPlusTextView.getMeasuredHeight()); + } + } + } + + private void trySlideAttachmentView(final ViewWrapper viewWrapper) { + if (!(viewWrapper.attachment instanceof MediaPickerMessagePartData)) { + return; + } + final View view = viewWrapper.view; + + + final int xOffset = viewWrapper.prevLeft - view.getLeft(); + final int yOffset = viewWrapper.prevTop - view.getTop(); + final float scaleX = viewWrapper.prevWidth / (float) view.getWidth(); + final float scaleY = viewWrapper.prevHeight / (float) view.getHeight(); + + if (xOffset == 0 && yOffset == 0 && scaleX == 1 && scaleY == 1) { + // Layout hasn't changed + return; + } + + final AnimationSet animationSet = new AnimationSet( + true /* shareInterpolator */); + animationSet.addAnimation(new TranslateAnimation(xOffset, 0, yOffset, 0)); + animationSet.addAnimation(new ScaleAnimation(scaleX, 1, scaleY, 1)); + animationSet.setDuration( + UiUtils.MEDIAPICKER_TRANSITION_DURATION); + animationSet.setInterpolator(UiUtils.DEFAULT_INTERPOLATOR); + view.startAnimation(animationSet); + view.invalidate(); + viewWrapper.prevLeft = view.getLeft(); + viewWrapper.prevTop = view.getTop(); + viewWrapper.prevWidth = view.getWidth(); + viewWrapper.prevHeight = view.getHeight(); + } + + public View findViewForAttachment(final MessagePartData attachment) { + for (ViewWrapper wrapper : mPreviewViews) { + if (wrapper.attachment.equals(attachment) && + !(wrapper.attachment instanceof PendingAttachmentData)) { + return wrapper.view; + } + } + return null; + } + + private static class ViewWrapper { + final View view; + final MessagePartData attachment; + boolean needsSlideAnimation; + int prevLeft; + int prevTop; + int prevWidth; + int prevHeight; + + ViewWrapper(final View view, final MessagePartData attachment) { + this.view = view; + this.attachment = attachment; + } + } +} diff --git a/src/com/android/messaging/ui/OrientedBitmapDrawable.java b/src/com/android/messaging/ui/OrientedBitmapDrawable.java new file mode 100644 index 0000000..9242668 --- /dev/null +++ b/src/com/android/messaging/ui/OrientedBitmapDrawable.java @@ -0,0 +1,104 @@ +/* + * 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.ui; + +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Matrix; +import android.graphics.Rect; +import android.graphics.RectF; +import android.graphics.drawable.BitmapDrawable; +import android.view.Gravity; + +import com.android.messaging.util.exif.ExifInterface; + +/** + * A drawable that draws a bitmap in a flipped or rotated orientation without having to adjust the + * bitmap + */ +public class OrientedBitmapDrawable extends BitmapDrawable { + private final ExifInterface.OrientationParams mOrientationParams; + private final Rect mDstRect; + private int mCenterX; + private int mCenterY; + private boolean mApplyGravity; + + public static BitmapDrawable create(final int orientation, Resources res, Bitmap bitmap) { + if (orientation <= ExifInterface.Orientation.TOP_LEFT) { + // No need to adjust the bitmap, so just use a regular BitmapDrawable + return new BitmapDrawable(res, bitmap); + } else { + // Create an oriented bitmap drawable + return new OrientedBitmapDrawable(orientation, res, bitmap); + } + } + + private OrientedBitmapDrawable(final int orientation, Resources res, Bitmap bitmap) { + super(res, bitmap); + mOrientationParams = ExifInterface.getOrientationParams(orientation); + mApplyGravity = true; + mDstRect = new Rect(); + } + + @Override + public int getIntrinsicWidth() { + if (mOrientationParams.invertDimensions) { + return super.getIntrinsicHeight(); + } + return super.getIntrinsicWidth(); + } + + @Override + public int getIntrinsicHeight() { + if (mOrientationParams.invertDimensions) { + return super.getIntrinsicWidth(); + } + return super.getIntrinsicHeight(); + } + + @Override + protected void onBoundsChange(Rect bounds) { + super.onBoundsChange(bounds); + mApplyGravity = true; + } + + @Override + public void draw(Canvas canvas) { + if (mApplyGravity) { + Gravity.apply(getGravity(), getIntrinsicWidth(), getIntrinsicHeight(), getBounds(), + mDstRect); + mCenterX = mDstRect.centerX(); + mCenterY = mDstRect.centerY(); + if (mOrientationParams.invertDimensions) { + final Matrix matrix = new Matrix(); + matrix.setRotate(mOrientationParams.rotation, mCenterX, mCenterY); + final RectF rotatedRect = new RectF(mDstRect); + matrix.mapRect(rotatedRect); + mDstRect.set((int) rotatedRect.left, (int) rotatedRect.top, (int) rotatedRect.right, + (int) rotatedRect.bottom); + } + + mApplyGravity = false; + } + canvas.save(); + canvas.scale(mOrientationParams.scaleX, mOrientationParams.scaleY, mCenterX, mCenterY); + canvas.rotate(mOrientationParams.rotation, mCenterX, mCenterY); + canvas.drawBitmap(getBitmap(), (Rect) null, mDstRect, getPaint()); + canvas.restore(); + } +} diff --git a/src/com/android/messaging/ui/PagerViewHolder.java b/src/com/android/messaging/ui/PagerViewHolder.java new file mode 100644 index 0000000..2f33a0f --- /dev/null +++ b/src/com/android/messaging/ui/PagerViewHolder.java @@ -0,0 +1,34 @@ +/* + * 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.ui; + +import android.view.View; +import android.view.ViewGroup; + +/** + * Holds reusable View(s) for a {@link FixedViewPagerAdapter} to display a page. By using + * reusable Views inside ViewPagers this allows us to get rid of nested fragments and the messy + * activity lifecycle problems they entail. + */ +public interface PagerViewHolder extends PersistentInstanceState { + /** Instructs the pager to clean up any view related resources + * @return the destroyed View so that the adapter may remove it from the container, or + * null if no View has been created. */ + View destroyView(); + + /** @return The view that presents the page view to the user */ + View getView(ViewGroup container); +} diff --git a/src/com/android/messaging/ui/PagingAwareViewPager.java b/src/com/android/messaging/ui/PagingAwareViewPager.java new file mode 100644 index 0000000..cc7b2cd --- /dev/null +++ b/src/com/android/messaging/ui/PagingAwareViewPager.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.ui; + +import android.content.Context; +import android.support.v4.view.PagerAdapter; +import android.support.v4.view.ViewPager; +import android.util.AttributeSet; +import android.view.MotionEvent; + +import com.android.messaging.util.UiUtils; + +/** + * A simple extension on the standard ViewPager which lets you turn paging on/off. + */ +public class PagingAwareViewPager extends ViewPager { + private boolean mPagingEnabled = true; + + public PagingAwareViewPager(Context context, AttributeSet attrs) { + super(context, attrs); + } + + @Override + public void setCurrentItem(int item, boolean smoothScroll) { + super.setCurrentItem(getRtlPosition(item), smoothScroll); + } + + @Override + public void setCurrentItem(int item) { + super.setCurrentItem(getRtlPosition(item)); + } + + @Override + public int getCurrentItem() { + int position = super.getCurrentItem(); + return getRtlPosition(position); + } + + /** + * Switches position in pager to be adjusted for if we are in RtL mode + * + * @param position + * @return position adjusted if in rtl mode + */ + protected int getRtlPosition(final int position) { + final PagerAdapter adapter = getAdapter(); + if (adapter != null && UiUtils.isRtlMode()) { + return adapter.getCount() - 1 - position; + } + return position; + } + + @Override + public boolean onTouchEvent(final MotionEvent event) { + if (!mPagingEnabled) { + return false; + } + return super.onTouchEvent(event); + } + + @Override + public boolean onInterceptTouchEvent(final MotionEvent event) { + if (!mPagingEnabled) { + return false; + } + return super.onInterceptTouchEvent(event); + } + + public void setPagingEnabled(final boolean enabled) { + this.mPagingEnabled = enabled; + } + + /** This prevents touch-less scrolling eg. while doing accessibility navigation. */ + @Override + public boolean canScrollHorizontally(int direction) { + if (mPagingEnabled) { + return super.canScrollHorizontally(direction); + } else { + return false; + } + } +} diff --git a/src/com/android/messaging/ui/PermissionCheckActivity.java b/src/com/android/messaging/ui/PermissionCheckActivity.java new file mode 100644 index 0000000..e992a10 --- /dev/null +++ b/src/com/android/messaging/ui/PermissionCheckActivity.java @@ -0,0 +1,141 @@ +/* + * 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.ui; + +import android.app.Activity; +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; +import android.os.SystemClock; +import android.provider.Settings; +import android.view.View; +import android.view.View.OnClickListener; +import android.widget.TextView; + +import com.android.messaging.Factory; +import com.android.messaging.R; +import com.android.messaging.util.OsUtil; +import com.android.messaging.util.UiUtils; + +/** + * Activity to check if the user has required permissions. If not, it will try to prompt the user + * to grant permissions. However, the OS may not actually prompt the user if the user had + * previously checked the "Never ask again" checkbox while denying the required permissions. + */ +public class PermissionCheckActivity extends Activity { + private static final int REQUIRED_PERMISSIONS_REQUEST_CODE = 1; + private static final long AUTOMATED_RESULT_THRESHOLD_MILLLIS = 250; + private static final String PACKAGE_URI_PREFIX = "package:"; + private long mRequestTimeMillis; + private TextView mNextView; + private TextView mSettingsView; + + @Override + protected void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + if (redirectIfNeeded()) { + return; + } + + setContentView(R.layout.permission_check_activity); + UiUtils.setStatusBarColor(this, getColor(R.color.permission_check_activity_background)); + + findViewById(R.id.exit).setOnClickListener(new OnClickListener() { + @Override + public void onClick(final View view) { + finish(); + } + }); + + mNextView = (TextView) findViewById(R.id.next); + mNextView.setOnClickListener(new OnClickListener() { + @Override + public void onClick(final View view) { + tryRequestPermission(); + } + }); + + mSettingsView = (TextView) findViewById(R.id.settings); + mSettingsView.setOnClickListener(new OnClickListener() { + @Override + public void onClick(final View view) { + final Intent intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + Uri.parse(PACKAGE_URI_PREFIX + getPackageName())); + startActivity(intent); + } + }); + } + + @Override + public void onResume() { + super.onResume(); + + if (redirectIfNeeded()) { + return; + } + } + + private void tryRequestPermission() { + final String[] missingPermissions = OsUtil.getMissingRequiredPermissions(); + if (missingPermissions.length == 0) { + redirect(); + return; + } + + mRequestTimeMillis = SystemClock.elapsedRealtime(); + requestPermissions(missingPermissions, REQUIRED_PERMISSIONS_REQUEST_CODE); + } + + @Override + public void onRequestPermissionsResult( + final int requestCode, final String permissions[], final int[] grantResults) { + if (requestCode == REQUIRED_PERMISSIONS_REQUEST_CODE) { + // We do not use grantResults as some of the granted permissions might have been + // revoked while the permissions dialog box was being shown for the missing permissions. + if (OsUtil.hasRequiredPermissions()) { + Factory.get().onRequiredPermissionsAcquired(); + redirect(); + } else { + final long currentTimeMillis = SystemClock.elapsedRealtime(); + // If the permission request completes very quickly, it must be because the system + // automatically denied. This can happen if the user had previously denied it + // and checked the "Never ask again" check box. + if ((currentTimeMillis - mRequestTimeMillis) < AUTOMATED_RESULT_THRESHOLD_MILLLIS) { + mNextView.setVisibility(View.GONE); + + mSettingsView.setVisibility(View.VISIBLE); + findViewById(R.id.enable_permission_procedure).setVisibility(View.VISIBLE); + } + } + } + } + + /** Returns true if the redirecting was performed */ + private boolean redirectIfNeeded() { + if (!OsUtil.hasRequiredPermissions()) { + return false; + } + + redirect(); + return true; + } + + private void redirect() { + UIIntents.get().launchConversationListActivity(this); + finish(); + } +} diff --git a/src/com/android/messaging/ui/PersistentInstanceState.java b/src/com/android/messaging/ui/PersistentInstanceState.java new file mode 100644 index 0000000..3cc1856 --- /dev/null +++ b/src/com/android/messaging/ui/PersistentInstanceState.java @@ -0,0 +1,39 @@ +/* + * 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.ui; + +import android.os.Parcelable; + +/** + * Wraps around functionality to save, restore and reset a particular UI component's state. + */ +public interface PersistentInstanceState { + /** + * Saves necessary information about the current state of the instance to later restore + * the instance to its original state. + */ + Parcelable saveState(); + + /** + * Given a previously saved instance state, attempt to restore to its original view state. + */ + void restoreState(Parcelable restoredState); + + /** + * Called when any current/preserved state should be reset to zero state. + */ + void resetState(); +} diff --git a/src/com/android/messaging/ui/PersonItemView.java b/src/com/android/messaging/ui/PersonItemView.java new file mode 100644 index 0000000..afd1a99 --- /dev/null +++ b/src/com/android/messaging/ui/PersonItemView.java @@ -0,0 +1,242 @@ +/* + * 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.ui; + +import android.content.Context; +import android.content.Intent; +import android.support.v4.text.BidiFormatter; +import android.support.v4.text.TextDirectionHeuristicsCompat; +import android.text.TextUtils; +import android.util.AttributeSet; +import android.view.LayoutInflater; +import android.view.View; +import android.view.View.OnLayoutChangeListener; +import android.widget.LinearLayout; +import android.widget.TextView; + +import com.android.messaging.R; +import com.android.messaging.datamodel.binding.BindingBase; +import com.android.messaging.datamodel.binding.DetachableBinding; +import com.android.messaging.datamodel.data.PersonItemData; +import com.android.messaging.datamodel.data.PersonItemData.PersonItemDataListener; +import com.android.messaging.util.AccessibilityUtil; +import com.android.messaging.util.UiUtils; + +/** + * Shows a view for a "person" - could be a contact or a participant. This always shows a + * contact icon on the left, and the person's display name on the right. + * + * This view is always bound to an abstract PersonItemData class, so to use it for a specific + * scenario, all you need to do is to create a concrete PersonItemData subclass that bridges + * between the underlying data (e.g. ParticipantData) and what the UI wants (e.g. display name). + */ +public class PersonItemView extends LinearLayout implements PersonItemDataListener, + OnLayoutChangeListener { + public interface PersonItemViewListener { + void onPersonClicked(PersonItemData data); + boolean onPersonLongClicked(PersonItemData data); + } + + protected final DetachableBinding<PersonItemData> mBinding; + private TextView mNameTextView; + private TextView mDetailsTextView; + private ContactIconView mContactIconView; + private View mDetailsContainer; + private PersonItemViewListener mListener; + private boolean mAvatarOnly; + + public PersonItemView(final Context context, final AttributeSet attrs) { + super(context, attrs); + mBinding = BindingBase.createDetachableBinding(this); + LayoutInflater.from(getContext()).inflate(R.layout.person_item_view, this, true); + } + + @Override + protected void onFinishInflate() { + mNameTextView = (TextView) findViewById(R.id.name); + mDetailsTextView = (TextView) findViewById(R.id.details); + mContactIconView = (ContactIconView) findViewById(R.id.contact_icon); + mDetailsContainer = findViewById(R.id.details_container); + mNameTextView.addOnLayoutChangeListener(this); + } + + @Override + protected void onDetachedFromWindow() { + super.onDetachedFromWindow(); + if (mBinding.isBound()) { + mBinding.detach(); + } + } + + @Override + protected void onAttachedToWindow() { + super.onAttachedToWindow(); + mBinding.reAttachIfPossible(); + } + + /** + * Binds to a person item data which will provide us info to be displayed. + * @param personData the PersonItemData to be bound to. + */ + public void bind(final PersonItemData personData) { + if (mBinding.isBound()) { + if (mBinding.getData().equals(personData)) { + // Don't rebind if we are requesting the same data. + return; + } + mBinding.unbind(); + } + + if (personData != null) { + mBinding.bind(personData); + mBinding.getData().setListener(this); + + // Accessibility reason : in case phone numbers are mixed in the display name, + // we need to vocalize it for talkback. + final String vocalizedDisplayName = AccessibilityUtil.getVocalizedPhoneNumber( + getResources(), getDisplayName()); + mNameTextView.setContentDescription(vocalizedDisplayName); + } + updateViewAppearance(); + } + + /** + * @return Display name, possibly comma-ellipsized. + */ + private String getDisplayName() { + final int width = mNameTextView.getMeasuredWidth(); + final String displayName = mBinding.getData().getDisplayName(); + if (width == 0 || TextUtils.isEmpty(displayName) || !displayName.contains(",")) { + return displayName; + } + final String plusOneString = getContext().getString(R.string.plus_one); + final String plusNString = getContext().getString(R.string.plus_n); + return BidiFormatter.getInstance().unicodeWrap( + UiUtils.commaEllipsize( + displayName, + mNameTextView.getPaint(), + width, + plusOneString, + plusNString).toString(), + TextDirectionHeuristicsCompat.LTR); + } + + @Override + public void onLayoutChange(final View v, final int left, final int top, final int right, + final int bottom, final int oldLeft, final int oldTop, final int oldRight, + final int oldBottom) { + if (mBinding.isBound() && v == mNameTextView) { + setNameTextView(); + } + } + + /** + * When set to true, we display only the avatar of the person and hide everything else. + */ + public void setAvatarOnly(final boolean avatarOnly) { + mAvatarOnly = avatarOnly; + mDetailsContainer.setVisibility(avatarOnly ? GONE : VISIBLE); + } + + public boolean isAvatarOnly() { + return mAvatarOnly; + } + + public void setNameTextColor(final int color) { + mNameTextView.setTextColor(color); + } + + public void setDetailsTextColor(final int color) { + mDetailsTextView.setTextColor(color); + } + + public void setListener(final PersonItemViewListener listener) { + mListener = listener; + if (mListener == null) { + return; + } + setOnClickListener(new OnClickListener() { + @Override + public void onClick(final View v) { + if (mListener != null && mBinding.isBound()) { + mListener.onPersonClicked(mBinding.getData()); + } + } + }); + final OnLongClickListener onLongClickListener = new OnLongClickListener() { + @Override + public boolean onLongClick(View v) { + if (mListener != null && mBinding.isBound()) { + return mListener.onPersonLongClicked(mBinding.getData()); + } + return false; + } + }; + setOnLongClickListener(onLongClickListener); + mContactIconView.setOnLongClickListener(onLongClickListener); + } + + public void performClickOnAvatar() { + mContactIconView.performClick(); + } + + protected void updateViewAppearance() { + if (mBinding.isBound()) { + setNameTextView(); + + final String details = mBinding.getData().getDetails(); + if (TextUtils.isEmpty(details)) { + mDetailsTextView.setVisibility(GONE); + } else { + mDetailsTextView.setVisibility(VISIBLE); + mDetailsTextView.setText(details); + } + + mContactIconView.setImageResourceUri(mBinding.getData().getAvatarUri(), + mBinding.getData().getContactId(), mBinding.getData().getLookupKey(), + mBinding.getData().getNormalizedDestination()); + } else { + mNameTextView.setText(""); + mContactIconView.setImageResourceUri(null); + } + } + + private void setNameTextView() { + final String displayName = getDisplayName(); + if (TextUtils.isEmpty(displayName)) { + mNameTextView.setVisibility(GONE); + } else { + mNameTextView.setVisibility(VISIBLE); + mNameTextView.setText(displayName); + } + } + + @Override + public void onPersonDataUpdated(final PersonItemData data) { + mBinding.ensureBound(data); + updateViewAppearance(); + } + + @Override + public void onPersonDataFailed(final PersonItemData data, final Exception exception) { + mBinding.ensureBound(data); + updateViewAppearance(); + } + + public Intent getClickIntent() { + return mBinding.getData().getClickIntent(); + } +} diff --git a/src/com/android/messaging/ui/PlaceholderInsetDrawable.java b/src/com/android/messaging/ui/PlaceholderInsetDrawable.java new file mode 100644 index 0000000..dda2e7b --- /dev/null +++ b/src/com/android/messaging/ui/PlaceholderInsetDrawable.java @@ -0,0 +1,72 @@ +/* + * 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.ui; + +import android.graphics.drawable.Drawable; +import android.graphics.drawable.InsetDrawable; + +/** + * A "placeholder" drawable that has the same sizing properties as the real UI element it + * replaces. + * + * This is an InsetDrawable that takes a placeholder drawable (an animation list, or simply + * a color drawable) and place it in the center of the inset drawable that's sized to the + * requested source width and height of the image that it replaces. Unlike the base + * InsetDrawable, this implementation returns the true width and height of the real image + * that it's placeholding, instead of the intrinsic size of the contained drawable, so that + * when used in an ImageView, it may be positioned/scaled/cropped the same way the real + * image is. + */ +public class PlaceholderInsetDrawable extends InsetDrawable { + // The dimensions of the real image that this drawable is replacing. + private final int mSourceWidth; + private final int mSourceHeight; + + /** + * Given a source drawable, wraps it around in this placeholder drawable by placing the + * drawable at the center of the container if possible (or fill the container if the + * drawable doesn't have intrinsic size such as color drawable). + */ + public static PlaceholderInsetDrawable fromDrawable(final Drawable drawable, + final int sourceWidth, final int sourceHeight) { + final int drawableWidth = drawable.getIntrinsicWidth(); + final int drawableHeight = drawable.getIntrinsicHeight(); + final int insetHorizontal = drawableWidth < 0 || drawableWidth > sourceWidth ? + 0 : (sourceWidth - drawableWidth) / 2; + final int insetVertical = drawableHeight < 0 || drawableHeight > sourceHeight ? + 0 : (sourceHeight - drawableHeight) / 2; + return new PlaceholderInsetDrawable(drawable, insetHorizontal, insetVertical, + insetHorizontal, insetVertical, sourceWidth, sourceHeight); + } + + private PlaceholderInsetDrawable(final Drawable drawable, final int insetLeft, + final int insetTop, final int insetRight, final int insetBottom, + final int sourceWidth, final int sourceHeight) { + super(drawable, insetLeft, insetTop, insetRight, insetBottom); + mSourceWidth = sourceWidth; + mSourceHeight = sourceHeight; + } + + @Override + public int getIntrinsicWidth() { + return mSourceWidth; + } + + @Override + public int getIntrinsicHeight() { + return mSourceHeight; + } +}
\ No newline at end of file diff --git a/src/com/android/messaging/ui/PlainTextEditText.java b/src/com/android/messaging/ui/PlainTextEditText.java new file mode 100644 index 0000000..8d6e784 --- /dev/null +++ b/src/com/android/messaging/ui/PlainTextEditText.java @@ -0,0 +1,85 @@ +/* + * 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.ui; + +import android.content.ClipData; +import android.content.ClipboardManager; +import android.content.Context; +import android.util.AttributeSet; +import android.widget.EditText; + +/** + * We want the EditText used in Conversations to convert text to plain text on paste. This + * conversion would happen anyway on send, so without this class it could appear to the user + * that we would send e.g. bold or italic formatting, but in the sent message it would just be + * plain text. + */ +public class PlainTextEditText extends EditText { + private static final char OBJECT_UNICODE = '\uFFFC'; + + public PlainTextEditText(final Context context, final AttributeSet attrs) { + super(context, attrs); + } + + // Intercept and modify the paste event. Let everything else through unchanged. + @Override + public boolean onTextContextMenuItem(final int id) { + if (id == android.R.id.paste) { + // We can use this to know where the text position was originally before we pasted + final int selectionStartPrePaste = getSelectionStart(); + + // Let the EditText's normal paste routine fire, then modify the content after. + // This is simpler than re-implementing the paste logic, which we'd have to do + // if we want to get the text from the clipboard ourselves and then modify it. + + final boolean result = super.onTextContextMenuItem(id); + CharSequence text = getText(); + int selectionStart = getSelectionStart(); + int selectionEnd = getSelectionEnd(); + + // There is an option in the Chrome mobile app to copy image; however, instead of the + // image in the form of the uri, Chrome gives us the html source for the image, which + // the platform paste code turns into the unicode object character. The below section + // of code looks for that edge case and replaces it with the url for the image. + final int startIndex = selectionStart - 1; + final int pasteStringLength = selectionStart - selectionStartPrePaste; + // Only going to handle the case where the pasted object is the image + if (pasteStringLength == 1 && text.charAt(startIndex) == OBJECT_UNICODE) { + final ClipboardManager clipboard = + (ClipboardManager) getContext().getSystemService(Context.CLIPBOARD_SERVICE); + final ClipData clip = clipboard.getPrimaryClip(); + if (clip != null) { + ClipData.Item item = clip.getItemAt(0); + StringBuilder sb = new StringBuilder(text); + final String url = item.getText().toString(); + sb.replace(selectionStartPrePaste, selectionStart, url); + text = sb.toString(); + selectionStart = selectionStartPrePaste + url.length(); + selectionEnd = selectionStart; + } + } + + // This removes the formatting due to the conversion to string. + setText(text.toString(), BufferType.EDITABLE); + + // Restore the cursor selection state. + setSelection(selectionStart, selectionEnd); + return result; + } else { + return super.onTextContextMenuItem(id); + } + } +} diff --git a/src/com/android/messaging/ui/PlaybackStateView.java b/src/com/android/messaging/ui/PlaybackStateView.java new file mode 100644 index 0000000..8d9aac7 --- /dev/null +++ b/src/com/android/messaging/ui/PlaybackStateView.java @@ -0,0 +1,43 @@ +/* + * 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.ui; + +/** + * An interface for a UI element ("View") that reflects the playback state of a piece of media + * content. It needs to support the ability to take common playback commands (play, pause, stop, + * restart) and reflect the state in UI (through timer or progress bar etc.) + */ +public interface PlaybackStateView { + /** + * Restart the playback. + */ + void restart(); + + /** + * Reset ("stop") the playback to the starting position. + */ + void reset(); + + /** + * Resume the playback, or start it if it hasn't been started yet. + */ + void resume(); + + /** + * Pause the playback. + */ + void pause(); +} diff --git a/src/com/android/messaging/ui/RemoteInputEntrypointActivity.java b/src/com/android/messaging/ui/RemoteInputEntrypointActivity.java new file mode 100644 index 0000000..c731164 --- /dev/null +++ b/src/com/android/messaging/ui/RemoteInputEntrypointActivity.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.ui; + +import android.content.Intent; +import android.os.Bundle; +import android.telephony.TelephonyManager; + +import com.android.messaging.datamodel.NoConfirmationSmsSendService; +import com.android.messaging.util.LogUtil; + +public class RemoteInputEntrypointActivity extends BaseBugleActivity { + private static final String TAG = LogUtil.BUGLE_TAG; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + Intent intent = getIntent(); + if (intent == null) { + LogUtil.w(TAG, "No intent attached"); + setResult(RESULT_CANCELED); + finish(); + return; + } + + // Perform some action depending on the intent + String action = intent.getAction(); + if (Intent.ACTION_SENDTO.equals(action)) { + // Build and send the intent + final Intent sendIntent = new Intent(this, NoConfirmationSmsSendService.class); + sendIntent.setAction(TelephonyManager.ACTION_RESPOND_VIA_MESSAGE); + sendIntent.putExtras(intent); + // Wear apparently passes all of its extras via the clip data. Must pass it along. + sendIntent.setClipData(intent.getClipData()); + startService(sendIntent); + setResult(RESULT_OK); + } else { + LogUtil.w(TAG, "Unrecognized intent action: " + action); + setResult(RESULT_CANCELED); + } + // This activity should never stick around after processing the intent + finish(); + } +} diff --git a/src/com/android/messaging/ui/SmsStorageLowWarningActivity.java b/src/com/android/messaging/ui/SmsStorageLowWarningActivity.java new file mode 100644 index 0000000..6b3e84b --- /dev/null +++ b/src/com/android/messaging/ui/SmsStorageLowWarningActivity.java @@ -0,0 +1,36 @@ +/* + * 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.ui; + +import android.app.FragmentTransaction; +import android.os.Bundle; + +/** + * Activity to contain the dialog of warning sms storage low. + */ +public class SmsStorageLowWarningActivity extends BaseBugleFragmentActivity { + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + FragmentTransaction ft = getFragmentManager().beginTransaction(); + SmsStorageLowWarningFragment fragment = + SmsStorageLowWarningFragment.newInstance(); + ft.add(fragment, null/*tag*/); + ft.commit(); + } +} diff --git a/src/com/android/messaging/ui/SmsStorageLowWarningFragment.java b/src/com/android/messaging/ui/SmsStorageLowWarningFragment.java new file mode 100644 index 0000000..3ebfdcf --- /dev/null +++ b/src/com/android/messaging/ui/SmsStorageLowWarningFragment.java @@ -0,0 +1,267 @@ +/* + * 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.ui; + +import android.app.AlertDialog; +import android.app.Dialog; +import android.app.DialogFragment; +import android.app.Fragment; +import android.app.FragmentTransaction; +import android.content.Context; +import android.content.DialogInterface; +import android.content.res.Resources; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.View.OnClickListener; +import android.view.ViewGroup; +import android.widget.ArrayAdapter; +import android.widget.ListView; +import android.widget.TextView; + +import com.android.messaging.R; +import com.android.messaging.datamodel.action.HandleLowStorageAction; +import com.android.messaging.sms.SmsReleaseStorage; +import com.android.messaging.sms.SmsReleaseStorage.Duration; +import com.android.messaging.sms.SmsStorageStatusManager; +import com.android.messaging.util.Assert; +import com.google.common.collect.Lists; + +import java.util.List; + +/** + * Dialog to show the sms storage low warning + */ +public class SmsStorageLowWarningFragment extends Fragment { + private SmsStorageLowWarningFragment() { + } + + public static SmsStorageLowWarningFragment newInstance() { + return new SmsStorageLowWarningFragment(); + } + + @Override + public void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + final FragmentTransaction ft = getFragmentManager().beginTransaction(); + final ChooseActionDialogFragment dialog = ChooseActionDialogFragment.newInstance(); + dialog.setTargetFragment(this, 0/*requestCode*/); + dialog.show(ft, null/*tag*/); + } + + /** + * Perform confirm action for a specific action + * + * @param actionIndex + */ + private void confirm(final int actionIndex) { + final FragmentTransaction ft = getFragmentManager().beginTransaction(); + final ConfirmationDialog dialog = ConfirmationDialog.newInstance(actionIndex); + dialog.setTargetFragment(this, 0/*requestCode*/); + dialog.show(ft, null/*tag*/); + } + + /** + * The dialog is cancelled at any step + */ + private void cancel() { + getActivity().finish(); + } + + /** + * The dialog to show for user to choose what delete actions to take when storage is low + */ + private static class ChooseActionDialogFragment extends DialogFragment { + public static ChooseActionDialogFragment newInstance() { + return new ChooseActionDialogFragment(); + } + + @Override + public Dialog onCreateDialog(final Bundle savedInstanceState) { + final AlertDialog.Builder builder = new AlertDialog.Builder(getActivity()); + + final LayoutInflater inflater = getActivity().getLayoutInflater(); + final View dialogLayout = inflater.inflate( + R.layout.sms_storage_low_warning_dialog, null); + final ListView actionListView = (ListView) dialogLayout.findViewById( + R.id.free_storage_action_list); + final List<String> actions = loadFreeStorageActions(getActivity().getResources()); + final ActionListAdapter listAdapter = new ActionListAdapter(getActivity(), actions); + actionListView.setAdapter(listAdapter); + + builder.setTitle(R.string.sms_storage_low_title) + .setView(dialogLayout) + .setNegativeButton(R.string.ignore, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int id) { + dialog.cancel(); + } + }); + + final Dialog dialog = builder.create(); + dialog.setCanceledOnTouchOutside(false); + return dialog; + } + + @Override + public void onCancel(final DialogInterface dialog) { + ((SmsStorageLowWarningFragment) getTargetFragment()).cancel(); + } + + private class ActionListAdapter extends ArrayAdapter<String> { + public ActionListAdapter(final Context context, final List<String> actions) { + super(context, R.layout.sms_free_storage_action_item_view, actions); + } + + @Override + public View getView(final int position, final View view, final ViewGroup parent) { + TextView actionItemView; + if (view == null || !(view instanceof TextView)) { + final LayoutInflater inflater = LayoutInflater.from(getContext()); + actionItemView = (TextView) inflater.inflate( + R.layout.sms_free_storage_action_item_view, parent, false); + } else { + actionItemView = (TextView) view; + } + + final String action = getItem(position); + actionItemView.setText(action); + actionItemView.setOnClickListener(new OnClickListener() { + @Override + public void onClick(final View view) { + dismiss(); + ((SmsStorageLowWarningFragment) getTargetFragment()).confirm(position); + } + }); + return actionItemView; + } + } + } + + private static final String KEY_ACTION_INDEX = "action_index"; + + /** + * The dialog to confirm user's delete action + */ + private static class ConfirmationDialog extends DialogFragment { + private Duration mDuration; + private String mDurationString; + + public static ConfirmationDialog newInstance(final int actionIndex) { + final ConfirmationDialog dialog = new ConfirmationDialog(); + final Bundle args = new Bundle(); + args.putInt(KEY_ACTION_INDEX, actionIndex); + dialog.setArguments(args); + return dialog; + } + + @Override + public void onCancel(final DialogInterface dialog) { + ((SmsStorageLowWarningFragment) getTargetFragment()).cancel(); + } + + @Override + public Dialog onCreateDialog(final Bundle savedInstanceState) { + mDuration = SmsReleaseStorage.parseMessageRetainingDuration(); + mDurationString = SmsReleaseStorage.getMessageRetainingDurationString(mDuration); + + final int actionIndex = getArguments().getInt(KEY_ACTION_INDEX); + if (actionIndex < 0 || actionIndex > 1) { + return null; + } + final AlertDialog.Builder builder = new AlertDialog.Builder(getActivity()); + builder.setTitle(R.string.sms_storage_low_title) + .setMessage(getConfirmDialogMessage(actionIndex)) + .setNegativeButton(android.R.string.cancel, + new DialogInterface.OnClickListener() { + @Override + public void onClick(final DialogInterface dialog, + final int button) { + dismiss(); + ((SmsStorageLowWarningFragment) getTargetFragment()).cancel(); + } + }) + .setPositiveButton(android.R.string.ok, + new DialogInterface.OnClickListener() { + @Override + public void onClick(final DialogInterface dialog, + final int button) { + dismiss(); + handleAction(actionIndex); + getActivity().finish(); + SmsStorageStatusManager.cancelStorageLowNotification(); + } + }); + return builder.create(); + } + + private void handleAction(final int actionIndex) { + final long durationInMillis = + SmsReleaseStorage.durationToTimeInMillis(mDuration); + switch (actionIndex) { + case 0: + HandleLowStorageAction.handleDeleteMediaMessages(durationInMillis); + break; + + case 1: + HandleLowStorageAction.handleDeleteOldMessages(durationInMillis); + break; + + default: + Assert.fail("Unsupported action"); + break; + } + } + + /** + * Get the confirm dialog text for a specific delete action + * @param index The action index + * @return + */ + private String getConfirmDialogMessage(final int index) { + switch (index) { + case 0: + return getString(R.string.delete_all_media_confirmation, mDurationString); + case 1: + return getString(R.string.delete_oldest_messages_confirmation, mDurationString); + case 2: + return getString(R.string.auto_delete_oldest_messages_confirmation, + mDurationString); + } + throw new IllegalArgumentException( + "SmsStorageLowWarningFragment: invalid action index " + index); + } + } + + /** + * Load the text of delete message actions + * + * @param resources + * @return + */ + private static List<String> loadFreeStorageActions(final Resources resources) { + final Duration duration = SmsReleaseStorage.parseMessageRetainingDuration(); + final String durationString = SmsReleaseStorage.getMessageRetainingDurationString(duration); + final List<String> actions = Lists.newArrayList(); + actions.add(resources.getString(R.string.delete_all_media)); + actions.add(resources.getString(R.string.delete_oldest_messages, durationString)); + + // TODO: Auto-purging is disabled for Bugle V1. + // actions.add(resources.getString(R.string.auto_delete_oldest_messages, durationString)); + return actions; + } +} diff --git a/src/com/android/messaging/ui/SnackBar.java b/src/com/android/messaging/ui/SnackBar.java new file mode 100644 index 0000000..a278040 --- /dev/null +++ b/src/com/android/messaging/ui/SnackBar.java @@ -0,0 +1,314 @@ +/* + * 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.ui; + +import android.content.Context; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.text.TextUtils; +import android.view.LayoutInflater; +import android.view.View; +import android.view.View.OnClickListener; +import android.view.ViewGroup.MarginLayoutParams; +import android.widget.FrameLayout; +import android.widget.TextView; + +import com.android.messaging.Factory; +import com.android.messaging.R; +import com.android.messaging.util.Assert; + +import java.util.ArrayList; +import java.util.List; + +public class SnackBar { + public static final int LONG_DURATION_IN_MS = 5000; + public static final int SHORT_DURATION_IN_MS = 1000; + public static final int MAX_DURATION_IN_MS = 10000; + + public interface SnackBarListener { + void onActionClick(); + } + + /** + * Defines an action to be performed when the user clicks on the action button on the snack bar + */ + public static class Action { + private final Runnable mActionRunnable; + private final String mActionLabel; + + public final static int SNACK_BAR_UNDO = 0; + public final static int SNACK_BAR_RETRY = 1; + + private Action(@Nullable Runnable actionRunnable, @Nullable String actionLabel) { + mActionRunnable = actionRunnable; + mActionLabel = actionLabel; + } + + Runnable getActionRunnable() { + return mActionRunnable; + } + + String getActionLabel() { + return mActionLabel; + } + + public static Action createUndoAction(final Runnable undoRunnable) { + return createCustomAction(undoRunnable, Factory.get().getApplicationContext() + .getString(R.string.snack_bar_undo)); + } + + public static Action createRetryAction(final Runnable retryRunnable) { + return createCustomAction(retryRunnable, Factory.get().getApplicationContext() + .getString(R.string.snack_bar_retry)); + } + + + public static Action createCustomAction(final Runnable runnable, final String actionLabel) { + return new Action(runnable, actionLabel); + } + } + + /** + * Defines the placement of the snack bar (e.g. anchored view, anchor gravity). + */ + public static class Placement { + private final View mAnchorView; + private final boolean mAnchorAbove; + + private Placement(@NonNull final View anchorView, final boolean anchorAbove) { + Assert.notNull(anchorView); + mAnchorView = anchorView; + mAnchorAbove = anchorAbove; + } + + public View getAnchorView() { + return mAnchorView; + } + + public boolean getAnchorAbove() { + return mAnchorAbove; + } + + /** + * Anchor the snack bar above the given {@code anchorView}. + */ + public static Placement above(final View anchorView) { + return new Placement(anchorView, true); + } + + /** + * Anchor the snack bar below the given {@code anchorView}. + */ + public static Placement below(final View anchorView) { + return new Placement(anchorView, false); + } + } + + public static class Builder { + private static final List<SnackBarInteraction> NO_INTERACTIONS = + new ArrayList<SnackBarInteraction>(); + + private final Context mContext; + private final SnackBarManager mSnackBarManager; + + private String mSnackBarMessage; + private int mDuration = LONG_DURATION_IN_MS; + private List<SnackBarInteraction> mInteractions = NO_INTERACTIONS; + private Action mAction; + private Placement mPlacement; + // The parent view is only used to get a window token and doesn't affect the layout + private View mParentView; + + public Builder(final SnackBarManager snackBarManager, final View parentView) { + Assert.notNull(snackBarManager); + Assert.notNull(parentView); + mSnackBarManager = snackBarManager; + mContext = parentView.getContext(); + mParentView = parentView; + } + + public Builder setText(final String snackBarMessage) { + Assert.isTrue(!TextUtils.isEmpty(snackBarMessage)); + mSnackBarMessage = snackBarMessage; + return this; + } + + public Builder setAction(final Action action) { + mAction = action; + return this; + } + + /** + * Sets the duration to show this toast for in milliseconds. + */ + public Builder setDuration(final int duration) { + Assert.isTrue(0 < duration && duration < MAX_DURATION_IN_MS); + mDuration = duration; + return this; + } + + /** + * Sets the components that this toast's animation will interact with. These components may + * be animated to make room for the toast. + */ + public Builder withInteractions(final List<SnackBarInteraction> interactions) { + mInteractions = interactions; + return this; + } + + /** + * Place the snack bar with the given placement requirement. + */ + public Builder withPlacement(final Placement placement) { + Assert.isNull(mPlacement); + mPlacement = placement; + return this; + } + + public SnackBar build() { + return new SnackBar(this); + } + + public void show() { + mSnackBarManager.show(build()); + } + } + + private final View mRootView; + private final Context mContext; + private final View mSnackBarView; + private final String mText; + private final int mDuration; + private final List<SnackBarInteraction> mInteractions; + private final Action mAction; + private final Placement mPlacement; + private final TextView mActionTextView; + private final TextView mMessageView; + private final FrameLayout mMessageWrapper; + private final View mParentView; + + private SnackBarListener mListener; + + private SnackBar(final Builder builder) { + mContext = builder.mContext; + mRootView = LayoutInflater.from(mContext).inflate(R.layout.snack_bar, + null /* WindowManager will show this in show() below */); + mSnackBarView = mRootView.findViewById(R.id.snack_bar); + mText = builder.mSnackBarMessage; + mDuration = builder.mDuration; + mAction = builder.mAction; + mPlacement = builder.mPlacement; + mParentView = builder.mParentView; + if (builder.mInteractions == null) { + mInteractions = new ArrayList<SnackBarInteraction>(); + } else { + mInteractions = builder.mInteractions; + } + + mActionTextView = (TextView) mRootView.findViewById(R.id.snack_bar_action); + mMessageView = (TextView) mRootView.findViewById(R.id.snack_bar_message); + mMessageWrapper = (FrameLayout) mRootView.findViewById(R.id.snack_bar_message_wrapper); + + setUpButton(); + setUpTextLines(); + } + + public Context getContext() { + return mContext; + } + + public View getRootView() { + return mRootView; + } + + public View getParentView() { + return mParentView; + } + + public View getSnackBarView() { + return mSnackBarView; + } + + public String getMessageText() { + return mText; + } + + public String getActionLabel() { + if (mAction == null) { + return null; + } + return mAction.getActionLabel(); + } + + public int getDuration() { + return mDuration; + } + + public Placement getPlacement() { + return mPlacement; + } + + public List<SnackBarInteraction> getInteractions() { + return mInteractions; + } + + public void setEnabled(final boolean enabled) { + mActionTextView.setClickable(enabled); + } + + public void setListener(final SnackBarListener snackBarListener) { + mListener = snackBarListener; + } + + private void setUpButton() { + if (mAction == null || mAction.getActionRunnable() == null) { + mActionTextView.setVisibility(View.GONE); + // In the XML layout we add left/right padding to the button to add space between + // the message text and the button and on the right side we add padding to put space + // between the button and the edge of the snack bar. This is so the button can use the + // padding area as part of it's click target. Since we have no button, we need to put + // some margin on the right side. While the left margin is already set on the wrapper, + // we're setting it again to not have to make a special case for RTL. + final MarginLayoutParams lp = (MarginLayoutParams) mMessageWrapper.getLayoutParams(); + final int leftRightMargin = mContext.getResources().getDimensionPixelSize( + R.dimen.snack_bar_left_right_margin); + lp.leftMargin = leftRightMargin; + lp.rightMargin = leftRightMargin; + mMessageWrapper.setLayoutParams(lp); + } else { + mActionTextView.setVisibility(View.VISIBLE); + mActionTextView.setText(mAction.getActionLabel()); + mActionTextView.setOnClickListener(new OnClickListener() { + @Override + public void onClick(final View v) { + mAction.getActionRunnable().run(); + if (mListener != null) { + mListener.onActionClick(); + } + } + }); + } + } + + private void setUpTextLines() { + if (mText == null) { + mMessageView.setVisibility(View.GONE); + } else { + mMessageView.setVisibility(View.VISIBLE); + mMessageView.setText(mText); + } + } +} diff --git a/src/com/android/messaging/ui/SnackBarInteraction.java b/src/com/android/messaging/ui/SnackBarInteraction.java new file mode 100644 index 0000000..f723caa --- /dev/null +++ b/src/com/android/messaging/ui/SnackBarInteraction.java @@ -0,0 +1,67 @@ +/* + * 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.ui; + +import android.view.Gravity; +import android.view.View; +import android.view.ViewPropertyAnimator; + +import com.google.common.base.Preconditions; + +/** + * An interface that defines how a component can be animated with an {@link SnackBar}. + */ +public interface SnackBarInteraction { + /** + * Returns the animator that will be run in reaction to the given SnackBar being shown. + * + * Implementations may return null here if it determines that the given SnackBar does not need + * to animate this component. + */ + ViewPropertyAnimator animateOnSnackBarShow(SnackBar snackBar); + + /** + * Returns the animator that will be run in reaction to the given SnackBar being dismissed. + * + * Implementations may return null here if it determines that the given SnackBar does not need + * to animate this component. + */ + ViewPropertyAnimator animateOnSnackBarDismiss(SnackBar snackBar); + + /** + * A basic implementation of {@link SnackBarInteraction} that assumes that the + * {@link SnackBar} is always shown with {@link Gravity#BOTTOM} and that the provided View will + * always need to be translated up to make room for the SnackBar. + */ + public static class BasicSnackBarInteraction implements SnackBarInteraction { + private final View mView; + + public BasicSnackBarInteraction(final View view) { + mView = Preconditions.checkNotNull(view); + } + + @Override + public ViewPropertyAnimator animateOnSnackBarShow(final SnackBar snackBar) { + final View rootView = snackBar.getRootView(); + return mView.animate().translationY(-rootView.getMeasuredHeight()); + } + + @Override + public ViewPropertyAnimator animateOnSnackBarDismiss(final SnackBar snackBar) { + return mView.animate().translationY(0); + } + } +}
\ No newline at end of file diff --git a/src/com/android/messaging/ui/SnackBarManager.java b/src/com/android/messaging/ui/SnackBarManager.java new file mode 100644 index 0000000..e107999 --- /dev/null +++ b/src/com/android/messaging/ui/SnackBarManager.java @@ -0,0 +1,365 @@ +/* + * 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.ui; + +import android.content.Context; +import android.graphics.Point; +import android.graphics.Rect; +import android.os.Handler; +import android.text.TextUtils; +import android.util.DisplayMetrics; +import android.view.Gravity; +import android.view.MotionEvent; +import android.view.View; +import android.view.View.MeasureSpec; +import android.view.View.OnTouchListener; +import android.view.ViewGroup; +import android.view.ViewGroup.LayoutParams; +import android.view.ViewPropertyAnimator; +import android.view.ViewTreeObserver.OnGlobalLayoutListener; +import android.view.WindowManager; +import android.widget.PopupWindow; +import android.widget.PopupWindow.OnDismissListener; + +import com.android.messaging.Factory; +import com.android.messaging.R; +import com.android.messaging.ui.SnackBar.Placement; +import com.android.messaging.ui.SnackBar.SnackBarListener; +import com.android.messaging.util.AccessibilityUtil; +import com.android.messaging.util.Assert; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.OsUtil; +import com.android.messaging.util.TextUtil; +import com.android.messaging.util.UiUtils; +import com.google.common.base.Joiner; + +import java.util.List; + +public class SnackBarManager { + + private static SnackBarManager sInstance; + + public static SnackBarManager get() { + if (sInstance == null) { + synchronized (SnackBarManager.class) { + if (sInstance == null) { + sInstance = new SnackBarManager(); + } + } + } + return sInstance; + } + + private final Runnable mDismissRunnable = new Runnable() { + @Override + public void run() { + dismiss(); + } + }; + + private final OnTouchListener mDismissOnTouchListener = new OnTouchListener() { + @Override + public boolean onTouch(final View view, final MotionEvent event) { + // Dismiss the {@link SnackBar} but don't consume the event. + dismiss(); + return false; + } + }; + + private final SnackBarListener mDismissOnUserTapListener = new SnackBarListener() { + @Override + public void onActionClick() { + dismiss(); + } + }; + + private final int mTranslationDurationMs; + private final Handler mHideHandler; + + private SnackBar mCurrentSnackBar; + private SnackBar mLatestSnackBar; + private SnackBar mNextSnackBar; + private boolean mIsCurrentlyDismissing; + private PopupWindow mPopupWindow; + + private SnackBarManager() { + mTranslationDurationMs = Factory.get().getApplicationContext().getResources().getInteger( + R.integer.snackbar_translation_duration_ms); + mHideHandler = new Handler(); + } + + public SnackBar getLatestSnackBar() { + return mLatestSnackBar; + } + + public SnackBar.Builder newBuilder(final View parentView) { + return new SnackBar.Builder(this, parentView); + } + + /** + * The given snackBar is not guaranteed to be shown. If the previous snackBar is animating away, + * and another snackBar is requested to show after this one, this snackBar will be skipped. + */ + public void show(final SnackBar snackBar) { + Assert.notNull(snackBar); + + if (mCurrentSnackBar != null) { + LogUtil.d(LogUtil.BUGLE_TAG, "Showing snack bar, but currentSnackBar was not null."); + + // Dismiss the current snack bar. That will cause the next snack bar to be shown on + // completion. + mNextSnackBar = snackBar; + mLatestSnackBar = snackBar; + dismiss(); + return; + } + + mCurrentSnackBar = snackBar; + mLatestSnackBar = snackBar; + + // We want to know when either button was tapped so we can dismiss. + snackBar.setListener(mDismissOnUserTapListener); + + // Cancel previous dismisses & set dismiss for the delay time. + mHideHandler.removeCallbacks(mDismissRunnable); + mHideHandler.postDelayed(mDismissRunnable, snackBar.getDuration()); + + snackBar.setEnabled(false); + + // For some reason, the addView function does not respect layoutParams. + // We need to explicitly set it first here. + final View rootView = snackBar.getRootView(); + + if (LogUtil.isLoggable(LogUtil.BUGLE_TAG, LogUtil.DEBUG)) { + LogUtil.d(LogUtil.BUGLE_TAG, "Showing snack bar: " + snackBar); + } + // Measure the snack bar root view so we know how much to translate by. + measureSnackBar(snackBar); + mPopupWindow = new PopupWindow(snackBar.getContext()); + mPopupWindow.setWidth(LayoutParams.MATCH_PARENT); + mPopupWindow.setHeight(LayoutParams.WRAP_CONTENT); + mPopupWindow.setBackgroundDrawable(null); + mPopupWindow.setContentView(rootView); + final Placement placement = snackBar.getPlacement(); + if (placement == null) { + mPopupWindow.showAtLocation( + snackBar.getParentView(), Gravity.BOTTOM | Gravity.START, + 0, getScreenBottomOffset(snackBar)); + } else { + final View anchorView = placement.getAnchorView(); + + // You'd expect PopupWindow.showAsDropDown to ensure the popup moves with the anchor + // view, which it does for scrolling, but not layout changes, so we have to manually + // update while the snackbar is showing + final OnGlobalLayoutListener listener = new OnGlobalLayoutListener() { + @Override + public void onGlobalLayout() { + mPopupWindow.update(anchorView, 0, getRelativeOffset(snackBar), + anchorView.getWidth(), LayoutParams.WRAP_CONTENT); + } + }; + anchorView.getViewTreeObserver().addOnGlobalLayoutListener(listener); + mPopupWindow.setOnDismissListener(new OnDismissListener() { + @Override + public void onDismiss() { + anchorView.getViewTreeObserver().removeOnGlobalLayoutListener(listener); + } + }); + mPopupWindow.showAsDropDown(anchorView, 0, getRelativeOffset(snackBar)); + } + + + // Animate the toast bar into view. + placeSnackBarOffScreen(snackBar); + animateSnackBarOnScreen(snackBar).withEndAction(new Runnable() { + @Override + public void run() { + mCurrentSnackBar.setEnabled(true); + makeCurrentSnackBarDismissibleOnTouch(); + // Fire an accessibility event as needed + String snackBarText = snackBar.getMessageText(); + if (!TextUtils.isEmpty(snackBarText) && + TextUtils.getTrimmedLength(snackBarText) > 0) { + snackBarText = snackBarText.trim(); + final String snackBarActionText = snackBar.getActionLabel(); + if (!TextUtil.isAllWhitespace(snackBarActionText)) { + snackBarText = Joiner.on(", ").join(snackBarText, snackBarActionText); + } + AccessibilityUtil.announceForAccessibilityCompat(snackBar.getSnackBarView(), + null /*accessibilityManager*/, snackBarText); + } + } + }); + + // Animate any interaction views out of the way. + animateInteractionsOnShow(snackBar); + } + + /** + * Dismisses the current toast that is showing. If there is a toast waiting to be shown, that + * toast will be shown when the current one has been dismissed. + */ + public void dismiss() { + mHideHandler.removeCallbacks(mDismissRunnable); + + if (mCurrentSnackBar == null || mIsCurrentlyDismissing) { + return; + } + + final SnackBar snackBar = mCurrentSnackBar; + + LogUtil.d(LogUtil.BUGLE_TAG, "Dismissing snack bar."); + mIsCurrentlyDismissing = true; + + snackBar.setEnabled(false); + + // Animate the toast bar down. + final View rootView = snackBar.getRootView(); + animateSnackBarOffScreen(snackBar).withEndAction(new Runnable() { + @Override + public void run() { + rootView.setVisibility(View.GONE); + try { + mPopupWindow.dismiss(); + } catch (IllegalArgumentException e) { + // PopupWindow.dismiss() will fire an IllegalArgumentException if the activity + // has already ended while we were animating + } + + mCurrentSnackBar = null; + mIsCurrentlyDismissing = false; + + // Show the next toast if one is waiting. + if (mNextSnackBar != null) { + final SnackBar localNextSnackBar = mNextSnackBar; + mNextSnackBar = null; + show(localNextSnackBar); + } + } + }); + + // Animate any interaction views back. + animateInteractionsOnDismiss(snackBar); + } + + private void makeCurrentSnackBarDismissibleOnTouch() { + // Set touching on the entire view, the {@link SnackBar} itself, as + // well as the button's dismiss the toast. + mCurrentSnackBar.getRootView().setOnTouchListener(mDismissOnTouchListener); + mCurrentSnackBar.getSnackBarView().setOnTouchListener(mDismissOnTouchListener); + } + + private void measureSnackBar(final SnackBar snackBar) { + final View rootView = snackBar.getRootView(); + final Point displaySize = new Point(); + getWindowManager(snackBar.getContext()).getDefaultDisplay().getSize(displaySize); + final int widthSpec = ViewGroup.getChildMeasureSpec( + MeasureSpec.makeMeasureSpec(displaySize.x, MeasureSpec.EXACTLY), + 0, LayoutParams.MATCH_PARENT); + final int heightSpec = ViewGroup.getChildMeasureSpec( + MeasureSpec.makeMeasureSpec(displaySize.y, MeasureSpec.EXACTLY), + 0, LayoutParams.WRAP_CONTENT); + rootView.measure(widthSpec, heightSpec); + } + + private void placeSnackBarOffScreen(final SnackBar snackBar) { + final View rootView = snackBar.getRootView(); + final View snackBarView = snackBar.getSnackBarView(); + snackBarView.setTranslationY(rootView.getMeasuredHeight()); + } + + private ViewPropertyAnimator animateSnackBarOnScreen(final SnackBar snackBar) { + final View snackBarView = snackBar.getSnackBarView(); + return normalizeAnimator(snackBarView.animate()).translationX(0).translationY(0); + } + + private ViewPropertyAnimator animateSnackBarOffScreen(final SnackBar snackBar) { + final View rootView = snackBar.getRootView(); + final View snackBarView = snackBar.getSnackBarView(); + return normalizeAnimator(snackBarView.animate()).translationY(rootView.getHeight()); + } + + private void animateInteractionsOnShow(final SnackBar snackBar) { + final List<SnackBarInteraction> interactions = snackBar.getInteractions(); + for (final SnackBarInteraction interaction : interactions) { + if (interaction != null) { + final ViewPropertyAnimator animator = interaction.animateOnSnackBarShow(snackBar); + if (animator != null) { + normalizeAnimator(animator); + } + } + } + } + + private void animateInteractionsOnDismiss(final SnackBar snackBar) { + final List<SnackBarInteraction> interactions = snackBar.getInteractions(); + for (final SnackBarInteraction interaction : interactions) { + if (interaction != null) { + final ViewPropertyAnimator animator = + interaction.animateOnSnackBarDismiss(snackBar); + if (animator != null) { + normalizeAnimator(animator); + } + } + } + } + + private ViewPropertyAnimator normalizeAnimator(final ViewPropertyAnimator animator) { + return animator + .setInterpolator(UiUtils.DEFAULT_INTERPOLATOR) + .setDuration(mTranslationDurationMs); + } + + private WindowManager getWindowManager(final Context context) { + return (WindowManager) context.getSystemService(Context.WINDOW_SERVICE); + } + + /** + * Get the offset from the bottom of the screen where the snack bar should be placed. + */ + private int getScreenBottomOffset(final SnackBar snackBar) { + final WindowManager windowManager = getWindowManager(snackBar.getContext()); + final DisplayMetrics displayMetrics = new DisplayMetrics(); + if (OsUtil.isAtLeastL()) { + windowManager.getDefaultDisplay().getRealMetrics(displayMetrics); + } else { + windowManager.getDefaultDisplay().getMetrics(displayMetrics); + } + final int screenHeight = displayMetrics.heightPixels; + + if (OsUtil.isAtLeastL()) { + // In L, the navigation bar is included in the space for the popup window, so we have to + // offset by the size of the navigation bar + final Rect displayRect = new Rect(); + snackBar.getParentView().getRootView().getWindowVisibleDisplayFrame(displayRect); + return screenHeight - displayRect.bottom; + } + + return 0; + } + + private int getRelativeOffset(final SnackBar snackBar) { + final Placement placement = snackBar.getPlacement(); + Assert.notNull(placement); + final View anchorView = placement.getAnchorView(); + if (placement.getAnchorAbove()) { + return -snackBar.getRootView().getMeasuredHeight() - anchorView.getHeight(); + } else { + // Use the default dropdown positioning + return 0; + } + } +} diff --git a/src/com/android/messaging/ui/TestActivity.java b/src/com/android/messaging/ui/TestActivity.java new file mode 100644 index 0000000..1693660 --- /dev/null +++ b/src/com/android/messaging/ui/TestActivity.java @@ -0,0 +1,88 @@ +/* + * 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.ui; + +import android.app.Fragment; +import android.os.Bundle; +import android.support.v4.app.FragmentActivity; +import android.view.View; +import android.view.WindowManager; +import android.widget.FrameLayout; + +import com.android.messaging.R; +import com.android.messaging.util.LogUtil; +import com.google.common.annotations.VisibleForTesting; + +/** + * An empty activity that can be used to host a fragment or view for unit testing purposes. Lives in + * app code vs test code due to requirement of ActivityInstrumentationTestCase2. + */ +public class TestActivity extends FragmentActivity { + private FragmentEventListener mFragmentEventListener; + + public interface FragmentEventListener { + public void onAttachFragment(Fragment fragment); + } + + @Override + protected void onCreate(final Bundle bundle) { + super.onCreate(bundle); + + if (bundle != null) { + // The test case may have configured the fragment, and recreating the activity will + // lose that configuration. The real activity is the only activity that would know + // how to reapply that configuration. + throw new IllegalStateException("TestActivity cannot get recreated"); + } + + // There is a race condition, but this often makes it possible for tests to run with the + // key guard up + getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON | + WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD | + WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED | + WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON); + + setContentView(R.layout.test_activity); + } + + @VisibleForTesting + public void setFragment(final Fragment fragment) { + LogUtil.i(LogUtil.BUGLE_TAG, "TestActivity.setFragment"); + getFragmentManager() + .beginTransaction() + .replace(R.id.test_content, fragment) + .commit(); + getFragmentManager().executePendingTransactions(); + } + + @VisibleForTesting + public void setView(final View view) { + LogUtil.i(LogUtil.BUGLE_TAG, "TestActivity.setView"); + ((FrameLayout) findViewById(R.id.test_content)).addView(view); + } + + @Override + public void onAttachFragment(final Fragment fragment) { + if (mFragmentEventListener != null) { + mFragmentEventListener.onAttachFragment(fragment); + } + } + + public void setFragmentEventListener(final FragmentEventListener fragmentEventListener) { + mFragmentEventListener = fragmentEventListener; + } +} diff --git a/src/com/android/messaging/ui/UIIntents.java b/src/com/android/messaging/ui/UIIntents.java new file mode 100644 index 0000000..e5f8a52 --- /dev/null +++ b/src/com/android/messaging/ui/UIIntents.java @@ -0,0 +1,378 @@ +/* + * 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.ui; + +import android.app.Activity; +import android.app.Fragment; +import android.app.PendingIntent; +import android.content.ContentValues; +import android.content.Context; +import android.content.Intent; +import android.graphics.Point; +import android.graphics.Rect; +import android.net.Uri; +import android.os.Bundle; + +import com.android.messaging.Factory; +import com.android.messaging.datamodel.data.MessageData; +import com.android.messaging.util.ConversationIdSet; + +/** + * A central repository of Intents used to start activities. + */ +public abstract class UIIntents { + public static UIIntents get() { + return Factory.get().getUIIntents(); + } + + // Intent extras + public static final String UI_INTENT_EXTRA_CONVERSATION_ID = "conversation_id"; + + // Sending draft data (from share intent / message forwarding) to the ConversationActivity. + public static final String UI_INTENT_EXTRA_DRAFT_DATA = "draft_data"; + + // The request code for picking image from the Document picker. + public static final int REQUEST_PICK_IMAGE_FROM_DOCUMENT_PICKER = 1400; + + // Indicates what type of notification this applies to (See BugleNotifications: + // UPDATE_NONE, UPDATE_MESSAGES, UPDATE_ERRORS, UPDATE_ALL) + public static final String UI_INTENT_EXTRA_NOTIFICATIONS_UPDATE = "notifications_update"; + + // Pass a set of conversation id's. + public static final String UI_INTENT_EXTRA_CONVERSATION_ID_SET = "conversation_id_set"; + + // Sending class zero message to its activity + public static final String UI_INTENT_EXTRA_MESSAGE_VALUES = "message_values"; + + // For the widget to go to the ConversationList from the Conversation. + public static final String UI_INTENT_EXTRA_GOTO_CONVERSATION_LIST = "goto_conv_list"; + + // Indicates whether a conversation is launched with custom transition. + public static final String UI_INTENT_EXTRA_WITH_CUSTOM_TRANSITION = "with_custom_transition"; + + public static final String ACTION_RESET_NOTIFICATIONS = + "com.android.messaging.reset_notifications"; + + // Sending VCard uri to VCard detail activity + public static final String UI_INTENT_EXTRA_VCARD_URI = "vcard_uri"; + + public static final String CMAS_COMPONENT = "com.android.cellbroadcastreceiver"; + + // Intent action for local broadcast receiver for conversation self id change. + public static final String CONVERSATION_SELF_ID_CHANGE_BROADCAST_ACTION = + "conversation_self_id_change"; + + // Conversation self id + public static final String UI_INTENT_EXTRA_CONVERSATION_SELF_ID = "conversation_self_id"; + + // For opening an APN editor on a particular row in the apn database. + public static final String UI_INTENT_EXTRA_APN_ROW_ID = "apn_row_id"; + + // Subscription id + public static final String UI_INTENT_EXTRA_SUB_ID = "sub_id"; + + // Per-Subscription setting activity title + public static final String UI_INTENT_EXTRA_PER_SUBSCRIPTION_SETTING_TITLE = + "per_sub_setting_title"; + + // Is application settings launched as the top level settings activity? + public static final String UI_INTENT_EXTRA_TOP_LEVEL_SETTINGS = "top_level_settings"; + + // Sending attachment uri from widget + public static final String UI_INTENT_EXTRA_ATTACHMENT_URI = "attachment_uri"; + + // Sending attachment content type from widget + public static final String UI_INTENT_EXTRA_ATTACHMENT_TYPE = "attachment_type"; + + public static final String ACTION_WIDGET_CONVERSATION = + "com.android.messaging.widget_conversation:"; + + public static final String UI_INTENT_EXTRA_REQUIRES_MMS = "requires_mms"; + + public static final String UI_INTENT_EXTRA_SELF_ID = "self_id"; + + // Message position to scroll to. + public static final String UI_INTENT_EXTRA_MESSAGE_POSITION = "message_position"; + + /** + * Launch the permission check activity + */ + public abstract void launchPermissionCheckActivity(final Context context); + + public abstract void launchConversationListActivity(final Context context); + + /** + * Launch an activity to show a conversation. This method by default provides no additional + * activity options. + */ + public void launchConversationActivity(final Context context, + final String conversationId, final MessageData draft) { + launchConversationActivity(context, conversationId, draft, null, + false /* withCustomTransition */); + } + + /** + * Launch an activity to show a conversation. + */ + public abstract void launchConversationActivity(final Context context, + final String conversationId, final MessageData draft, final Bundle activityOptions, + final boolean withCustomTransition); + + + /** + * Launch an activity to show conversation with conversation list in back stack. + */ + public abstract void launchConversationActivityWithParentStack(Context context, + String conversationId, String smsBody); + + /** + * Launch an activity to show a conversation as a new task. + */ + public abstract void launchConversationActivityNewTask(final Context context, + final String conversationId); + + /** + * Launch an activity to start a new conversation + */ + public abstract void launchCreateNewConversationActivity(final Context context, + final MessageData draft); + + /** + * Launch debug activity to set MMS config options. + */ + public abstract void launchDebugMmsConfigActivity(final Context context); + + /** + * Launch an activity to change settings. + */ + public abstract void launchSettingsActivity(final Context context); + + /** + * Launch an activity to add a contact with a given destination. + */ + public abstract void launchAddContactActivity(final Context context, final String destination); + + /** + * Launch an activity to show the document picker to pick an image. + * @param fragment the requesting fragment + */ + public abstract void launchDocumentImagePicker(final Fragment fragment); + + /** + * Launch an activity to show people & options for a given conversation. + */ + public abstract void launchPeopleAndOptionsActivity(final Activity context, + final String conversationId); + + /** + * Launch an external activity to handle a phone call + * @param phoneNumber the phone number to call + * @param clickPosition is the location tapped to start this launch for transition use + */ + public abstract void launchPhoneCallActivity(final Context context, final String phoneNumber, + final Point clickPosition); + + /** + * Launch an activity to show archived conversations. + */ + public abstract void launchArchivedConversationsActivity(final Context context); + + /** + * Launch an activity to show blocked participants. + */ + public abstract void launchBlockedParticipantsActivity(final Context context); + + /** + * Launch an activity to show a class zero message + */ + public abstract void launchClassZeroActivity(Context context, ContentValues messageValues); + + /** + * Launch an activity to let the user forward a message + */ + public abstract void launchForwardMessageActivity(Context context, MessageData message); + + /** + * Launch an activity to show details for a VCard + */ + public abstract void launchVCardDetailActivity(Context context, Uri vcardUri); + + /** + * Launch an external activity that handles the intent to add VCard to contacts + */ + public abstract void launchSaveVCardToContactsActivity(Context context, Uri vcardUri); + + /** + * Launch an activity to let the user select & unselect the list of attachments to send. + */ + public abstract void launchAttachmentChooserActivity(final Activity activity, + final String conversationId, final int requestCode); + + /** + * Launch full screen video viewer. + */ + public abstract void launchFullScreenVideoViewer(Context context, Uri videoUri); + + /** + * Launch full screen photo viewer. + */ + public abstract void launchFullScreenPhotoViewer(Activity activity, Uri initialPhoto, + Rect initialPhotoBounds, Uri photosUri); + + /** + * Launch an activity to show general app settings + * @param topLevel indicates whether the app settings is launched as the top-level settings + * activity (instead of SettingsActivity which shows a collapsed view of the app + * settings + one settings item per subscription). This is true when there's only one + * active SIM in the system so we can show this activity directly. + */ + public abstract void launchApplicationSettingsActivity(Context context, boolean topLevel); + + /** + * Launch an activity to show per-subscription settings + */ + public abstract void launchPerSubscriptionSettingsActivity(Context context, int subId, + String settingTitle); + + /** + * Get a ACTION_VIEW intent + * @param url display the data in the url to users + */ + public abstract Intent getViewUrlIntent(final String url); + + /** + * Get an intent to launch the ringtone picker + * @param title the title to show in the ringtone picker + * @param existingUri the currently set uri + * @param defaultUri the default uri if none is currently set + * @param toneType type of ringtone to pick, maybe any of RingtoneManager.TYPE_* + */ + public abstract Intent getRingtonePickerIntent(final String title, final Uri existingUri, + final Uri defaultUri, final int toneType); + + /** + * Get an intent to launch the wireless alert viewer. + */ + public abstract Intent getWirelessAlertsIntent(); + + /** + * Get an intent to launch the dialog for changing the default SMS App. + */ + public abstract Intent getChangeDefaultSmsAppIntent(final Activity activity); + + /** + * Broadcast conversation self id change so it may be reflected in the message compose UI. + */ + public abstract void broadcastConversationSelfIdChange(final Context context, + final String conversationId, final String conversationSelfId); + + /** + * Get a PendingIntent for starting conversation list from notifications. + */ + public abstract PendingIntent getPendingIntentForConversationListActivity( + final Context context); + + /** + * Get a PendingIntent for starting conversation list from widget. + */ + public abstract PendingIntent getWidgetPendingIntentForConversationListActivity( + final Context context); + + /** + * Get a PendingIntent for showing a conversation from notifications. + */ + public abstract PendingIntent getPendingIntentForConversationActivity(final Context context, + final String conversationId, final MessageData draft); + + /** + * Get an Intent for showing a conversation from the widget. + */ + public abstract Intent getIntentForConversationActivity(final Context context, + final String conversationId, final MessageData draft); + + /** + * Get a PendingIntent for sending a message to a conversation, without opening the Bugle UI. + * + * <p>This is intended to be used by the Android Wear companion app when sending transcribed + * voice replies. + */ + public abstract PendingIntent getPendingIntentForSendingMessageToConversation( + final Context context, final String conversationId, final String selfId, + final boolean requiresMms, final int requestCode); + + /** + * Get a PendingIntent for clearing notifications. + * + * <p>This is intended to be used by notifications. + */ + public abstract PendingIntent getPendingIntentForClearingNotifications(final Context context, + final int updateTargets, final ConversationIdSet conversationIdSet, + final int requestCode); + + /** + * Get a PendingIntent for showing low storage notifications. + */ + public abstract PendingIntent getPendingIntentForLowStorageNotifications(final Context context); + + /** + * Get a PendingIntent for showing a new message to a secondary user. + */ + public abstract PendingIntent getPendingIntentForSecondaryUserNewMessageNotification( + final Context context); + + /** + * Get an intent for showing the APN editor. + */ + public abstract Intent getApnEditorIntent(final Context context, final String rowId, int subId); + + /** + * Get an intent for showing the APN settings. + */ + public abstract Intent getApnSettingsIntent(final Context context, final int subId); + + /** + * Get an intent for showing advanced settings. + */ + public abstract Intent getAdvancedSettingsIntent(final Context context); + + /** + * Get an intent for the LaunchConversationActivity. + */ + public abstract Intent getLaunchConversationActivityIntent(final Context context); + + /** + * Tell MediaScanner to re-scan the specified volume. + */ + public abstract void kickMediaScanner(final Context context, final String volume); + + /** + * Launch to browser for a url. + */ + public abstract void launchBrowserForUrl(final Context context, final String url); + + /** + * Get a PendingIntent for the widget conversation template. + */ + public abstract PendingIntent getWidgetPendingIntentForConversationActivity( + final Context context, final String conversationId, final int requestCode); + + /** + * Get a PendingIntent for the conversation widget configuration activity template. + */ + public abstract PendingIntent getWidgetPendingIntentForConfigurationActivity( + final Context context, final int appWidgetId); + +} diff --git a/src/com/android/messaging/ui/UIIntentsImpl.java b/src/com/android/messaging/ui/UIIntentsImpl.java new file mode 100644 index 0000000..b7db719 --- /dev/null +++ b/src/com/android/messaging/ui/UIIntentsImpl.java @@ -0,0 +1,577 @@ +/* + * 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.ui; + +import android.app.Activity; +import android.app.Fragment; +import android.app.PendingIntent; +import android.appwidget.AppWidgetManager; +import android.content.ActivityNotFoundException; +import android.content.ClipData; +import android.content.ComponentName; +import android.content.ContentValues; +import android.content.Context; +import android.content.Intent; +import android.graphics.Point; +import android.graphics.Rect; +import android.media.RingtoneManager; +import android.net.Uri; +import android.os.Bundle; +import android.provider.ContactsContract.Contacts; +import android.provider.ContactsContract.Intents; +import android.provider.MediaStore; +import android.provider.Telephony; +import android.support.annotation.Nullable; +import android.support.v4.app.TaskStackBuilder; +import android.support.v4.content.LocalBroadcastManager; +import android.text.TextUtils; + +import com.android.ex.photo.Intents.PhotoViewIntentBuilder; +import com.android.messaging.R; +import com.android.messaging.datamodel.ConversationImagePartsView; +import com.android.messaging.datamodel.MediaScratchFileProvider; +import com.android.messaging.datamodel.MessagingContentProvider; +import com.android.messaging.datamodel.data.MessageData; +import com.android.messaging.datamodel.data.MessagePartData; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.receiver.NotificationReceiver; +import com.android.messaging.sms.MmsSmsUtils; +import com.android.messaging.ui.appsettings.ApnEditorActivity; +import com.android.messaging.ui.appsettings.ApnSettingsActivity; +import com.android.messaging.ui.appsettings.ApplicationSettingsActivity; +import com.android.messaging.ui.appsettings.PerSubscriptionSettingsActivity; +import com.android.messaging.ui.appsettings.SettingsActivity; +import com.android.messaging.ui.attachmentchooser.AttachmentChooserActivity; +import com.android.messaging.ui.conversation.ConversationActivity; +import com.android.messaging.ui.conversation.LaunchConversationActivity; +import com.android.messaging.ui.conversationlist.ArchivedConversationListActivity; +import com.android.messaging.ui.conversationlist.ConversationListActivity; +import com.android.messaging.ui.conversationlist.ForwardMessageActivity; +import com.android.messaging.ui.conversationsettings.PeopleAndOptionsActivity; +import com.android.messaging.ui.debug.DebugMmsConfigActivity; +import com.android.messaging.ui.photoviewer.BuglePhotoViewActivity; +import com.android.messaging.util.Assert; +import com.android.messaging.util.ContentType; +import com.android.messaging.util.ConversationIdSet; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.UiUtils; +import com.android.messaging.util.UriUtil; + +/** + * A central repository of Intents used to start activities. + */ +public class UIIntentsImpl extends UIIntents { + private static final String CELL_BROADCAST_LIST_ACTIVITY = + "com.android.cellbroadcastreceiver.CellBroadcastListActivity"; + private static final String CALL_TARGET_CLICK_KEY = "touchPoint"; + private static final String CALL_TARGET_CLICK_EXTRA_KEY = + "android.telecom.extra.OUTGOING_CALL_EXTRAS"; + private static final String MEDIA_SCANNER_CLASS = + "com.android.providers.media.MediaScannerService"; + private static final String MEDIA_SCANNER_PACKAGE = "com.android.providers.media"; + private static final String MEDIA_SCANNER_SCAN_ACTION = "android.media.IMediaScannerService"; + + /** + * Get an intent which takes you to a conversation + */ + private Intent getConversationActivityIntent(final Context context, + final String conversationId, final MessageData draft, + final boolean withCustomTransition) { + final Intent intent = new Intent(context, ConversationActivity.class); + + // Always try to reuse the same ConversationActivity in the current task so that we don't + // have two conversation activities in the back stack. + intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); + + // Otherwise we're starting a new conversation + if (conversationId != null) { + intent.putExtra(UI_INTENT_EXTRA_CONVERSATION_ID, conversationId); + } + if (draft != null) { + intent.putExtra(UI_INTENT_EXTRA_DRAFT_DATA, draft); + + // If draft attachments came from an external content provider via a share intent, we + // need to propagate the URI permissions through to ConversationActivity. This requires + // putting the URIs into the ClipData (setData also works, but accepts only one URI). + ClipData clipData = null; + for (final MessagePartData partData : draft.getParts()) { + if (partData.isAttachment()) { + final Uri uri = partData.getContentUri(); + if (clipData == null) { + clipData = ClipData.newRawUri("Attachments", uri); + } else { + clipData.addItem(new ClipData.Item(uri)); + } + } + } + if (clipData != null) { + intent.setClipData(clipData); + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + } + } + if (withCustomTransition) { + intent.putExtra(UI_INTENT_EXTRA_WITH_CUSTOM_TRANSITION, true); + } + + if (!(context instanceof Activity)) { + // If the caller supplies an application context, and not an activity context, we must + // include this flag + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + } + return intent; + } + + @Override + public void launchPermissionCheckActivity(final Context context) { + final Intent intent = new Intent(context, PermissionCheckActivity.class); + context.startActivity(intent); + } + + /** + * Get an intent which takes you to the conversation list + */ + private Intent getConversationListActivityIntent(final Context context) { + return new Intent(context, ConversationListActivity.class); + } + + @Override + public void launchConversationListActivity(final Context context) { + final Intent intent = getConversationListActivityIntent(context); + context.startActivity(intent); + } + + /** + * Get an intent which shows the low storage warning activity. + */ + private Intent getSmsStorageLowWarningActivityIntent(final Context context) { + return new Intent(context, SmsStorageLowWarningActivity.class); + } + + @Override + public void launchConversationActivity(final Context context, + final String conversationId, final MessageData draft, final Bundle activityOptions, + final boolean withCustomTransition) { + Assert.isTrue(!withCustomTransition || activityOptions != null); + final Intent intent = getConversationActivityIntent(context, conversationId, draft, + withCustomTransition); + context.startActivity(intent, activityOptions); + } + + @Override + public void launchConversationActivityNewTask( + final Context context, final String conversationId) { + final Intent intent = getConversationActivityIntent(context, conversationId, null, + false /* withCustomTransition */); + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + context.startActivity(intent); + } + + @Override + public void launchConversationActivityWithParentStack(final Context context, + final String conversationId, final String smsBody) { + final MessageData messageData = TextUtils.isEmpty(smsBody) + ? null + : MessageData.createDraftSmsMessage(conversationId, null, smsBody); + TaskStackBuilder.create(context) + .addNextIntentWithParentStack( + getConversationActivityIntent(context, conversationId, messageData, + false /* withCustomTransition */)) + .startActivities(); + } + + @Override + public void launchCreateNewConversationActivity(final Context context, + final MessageData draft) { + final Intent intent = getConversationActivityIntent(context, null, draft, + false /* withCustomTransition */); + context.startActivity(intent); + } + + @Override + public void launchDebugMmsConfigActivity(final Context context) { + context.startActivity(new Intent(context, DebugMmsConfigActivity.class)); + } + + @Override + public void launchAddContactActivity(final Context context, final String destination) { + final Intent intent = new Intent(Intent.ACTION_INSERT_OR_EDIT); + final String destinationType = MmsSmsUtils.isEmailAddress(destination) ? + Intents.Insert.EMAIL : Intents.Insert.PHONE; + intent.setType(Contacts.CONTENT_ITEM_TYPE); + intent.putExtra(destinationType, destination); + startExternalActivity(context, intent); + } + + @Override + public void launchSettingsActivity(final Context context) { + final Intent intent = new Intent(context, SettingsActivity.class); + context.startActivity(intent); + } + + @Override + public void launchArchivedConversationsActivity(final Context context) { + final Intent intent = new Intent(context, ArchivedConversationListActivity.class); + context.startActivity(intent); + } + + @Override + public void launchBlockedParticipantsActivity(final Context context) { + final Intent intent = new Intent(context, BlockedParticipantsActivity.class); + context.startActivity(intent); + } + + @Override + public void launchDocumentImagePicker(final Fragment fragment) { + final Intent intent = new Intent(Intent.ACTION_GET_CONTENT); + intent.putExtra(Intent.EXTRA_MIME_TYPES, MessagePartData.ACCEPTABLE_IMAGE_TYPES); + intent.addCategory(Intent.CATEGORY_OPENABLE); + intent.setType(ContentType.IMAGE_UNSPECIFIED); + + fragment.startActivityForResult(intent, REQUEST_PICK_IMAGE_FROM_DOCUMENT_PICKER); + } + + @Override + public void launchPeopleAndOptionsActivity(final Activity activity, + final String conversationId) { + final Intent intent = new Intent(activity, PeopleAndOptionsActivity.class); + intent.putExtra(UI_INTENT_EXTRA_CONVERSATION_ID, conversationId); + activity.startActivityForResult(intent, 0); + } + + @Override + public void launchPhoneCallActivity(final Context context, final String phoneNumber, + final Point clickPosition) { + final Intent intent = new Intent(Intent.ACTION_CALL, + Uri.parse(UriUtil.SCHEME_TEL + phoneNumber)); + final Bundle extras = new Bundle(); + extras.putParcelable(CALL_TARGET_CLICK_KEY, clickPosition); + intent.putExtra(CALL_TARGET_CLICK_EXTRA_KEY, extras); + startExternalActivity(context, intent); + } + + @Override + public void launchClassZeroActivity(final Context context, final ContentValues messageValues) { + final Intent classZeroIntent = new Intent(context, ClassZeroActivity.class) + .putExtra(UI_INTENT_EXTRA_MESSAGE_VALUES, messageValues) + .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_MULTIPLE_TASK); + context.startActivity(classZeroIntent); + } + + @Override + public void launchForwardMessageActivity(final Context context, final MessageData message) { + final Intent forwardMessageIntent = new Intent(context, ForwardMessageActivity.class) + .putExtra(UI_INTENT_EXTRA_DRAFT_DATA, message); + context.startActivity(forwardMessageIntent); + } + + @Override + public void launchVCardDetailActivity(final Context context, final Uri vcardUri) { + final Intent vcardDetailIntent = new Intent(context, VCardDetailActivity.class) + .putExtra(UI_INTENT_EXTRA_VCARD_URI, vcardUri); + context.startActivity(vcardDetailIntent); + } + + @Override + public void launchSaveVCardToContactsActivity(final Context context, final Uri vcardUri) { + Assert.isTrue(MediaScratchFileProvider.isMediaScratchSpaceUri(vcardUri)); + final Intent intent = new Intent(); + intent.setAction(Intent.ACTION_VIEW); + intent.setDataAndType(vcardUri, ContentType.TEXT_VCARD.toLowerCase()); + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + startExternalActivity(context, intent); + } + + @Override + public void launchAttachmentChooserActivity(final Activity activity, + final String conversationId, final int requestCode) { + final Intent intent = new Intent(activity, AttachmentChooserActivity.class); + intent.putExtra(UI_INTENT_EXTRA_CONVERSATION_ID, conversationId); + activity.startActivityForResult(intent, requestCode); + } + + @Override + public void launchFullScreenVideoViewer(final Context context, final Uri videoUri) { + final Intent intent = new Intent(Intent.ACTION_VIEW); + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + + // So we don't see "surrounding" images in Gallery + intent.putExtra("SingleItemOnly", true); + intent.setDataAndType(videoUri, ContentType.VIDEO_UNSPECIFIED); + startExternalActivity(context, intent); + } + + @Override + public void launchFullScreenPhotoViewer(final Activity activity, final Uri initialPhoto, + final Rect initialPhotoBounds, final Uri photosUri) { + final PhotoViewIntentBuilder builder = + com.android.ex.photo.Intents.newPhotoViewIntentBuilder( + activity, BuglePhotoViewActivity.class); + builder.setPhotosUri(photosUri.toString()); + builder.setInitialPhotoUri(initialPhoto.toString()); + builder.setProjection(ConversationImagePartsView.PhotoViewQuery.PROJECTION); + + // Set the location of the imageView so that the photoviewer can animate from that location + // to full screen. + builder.setScaleAnimation(initialPhotoBounds.left, initialPhotoBounds.top, + initialPhotoBounds.width(), initialPhotoBounds.height()); + + builder.setDisplayThumbsFullScreen(false); + builder.setMaxInitialScale(8); + activity.startActivity(builder.build()); + activity.overridePendingTransition(0, 0); + } + + @Override + public void launchApplicationSettingsActivity(final Context context, final boolean topLevel) { + final Intent intent = new Intent(context, ApplicationSettingsActivity.class); + intent.putExtra(UI_INTENT_EXTRA_TOP_LEVEL_SETTINGS, topLevel); + context.startActivity(intent); + } + + @Override + public void launchPerSubscriptionSettingsActivity(final Context context, final int subId, + final String settingTitle) { + final Intent intent = getPerSubscriptionSettingsIntent(context, subId, settingTitle); + context.startActivity(intent); + } + + @Override + public Intent getViewUrlIntent(final String url) { + final Uri uri = Uri.parse(url); + return new Intent(Intent.ACTION_VIEW, uri); + } + + @Override + public void broadcastConversationSelfIdChange(final Context context, + final String conversationId, final String conversationSelfId) { + final Intent intent = new Intent(CONVERSATION_SELF_ID_CHANGE_BROADCAST_ACTION); + intent.putExtra(UI_INTENT_EXTRA_CONVERSATION_ID, conversationId); + intent.putExtra(UI_INTENT_EXTRA_CONVERSATION_SELF_ID, conversationSelfId); + LocalBroadcastManager.getInstance(context).sendBroadcast(intent); + } + + @Override + public PendingIntent getPendingIntentForConversationListActivity(final Context context) { + final Intent intent = getConversationListActivityIntent(context); + return getPendingIntentWithParentStack(context, intent, 0); + } + + @Override + public PendingIntent getPendingIntentForConversationActivity(final Context context, + final String conversationId, final MessageData draft) { + final Intent intent = getConversationActivityIntent(context, conversationId, draft, + false /* withCustomTransition */); + // Ensure that the platform doesn't reuse PendingIntents across conversations + intent.setData(MessagingContentProvider.buildConversationMetadataUri(conversationId)); + return getPendingIntentWithParentStack(context, intent, 0); + } + + @Override + public Intent getIntentForConversationActivity(final Context context, + final String conversationId, final MessageData draft) { + final Intent intent = getConversationActivityIntent(context, conversationId, draft, + false /* withCustomTransition */); + return intent; + } + + @Override + public PendingIntent getPendingIntentForSendingMessageToConversation(final Context context, + final String conversationId, final String selfId, final boolean requiresMms, + final int requestCode) { + final Intent intent = new Intent(context, RemoteInputEntrypointActivity.class); + intent.setAction(Intent.ACTION_SENDTO); + // Ensure that the platform doesn't reuse PendingIntents across conversations + intent.setData(MessagingContentProvider.buildConversationMetadataUri(conversationId)); + intent.putExtra(UIIntents.UI_INTENT_EXTRA_CONVERSATION_ID, conversationId); + intent.putExtra(UIIntents.UI_INTENT_EXTRA_SELF_ID, selfId); + intent.putExtra(UIIntents.UI_INTENT_EXTRA_REQUIRES_MMS, requiresMms); + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); + return getPendingIntentWithParentStack(context, intent, requestCode); + } + + @Override + public PendingIntent getPendingIntentForClearingNotifications(final Context context, + final int updateTargets, final ConversationIdSet conversationIdSet, + final int requestCode) { + final Intent intent = new Intent(context, NotificationReceiver.class); + intent.setAction(ACTION_RESET_NOTIFICATIONS); + intent.putExtra(UI_INTENT_EXTRA_NOTIFICATIONS_UPDATE, updateTargets); + if (conversationIdSet != null) { + intent.putExtra(UI_INTENT_EXTRA_CONVERSATION_ID_SET, + conversationIdSet.getDelimitedString()); + } + return PendingIntent.getBroadcast(context, + requestCode, intent, + PendingIntent.FLAG_UPDATE_CURRENT); + } + + /** + * Gets a PendingIntent associated with an Intent to start an Activity. All notifications + * that starts an Activity must use this method to get a PendingIntent, which achieves two + * goals: + * 1. The target activities will be created, with any existing ones destroyed. This ensures + * we don't end up with multiple instances of ConversationListActivity, for example. + * 2. The target activity, when launched, will have its backstack correctly constructed so + * back navigation will work correctly. + */ + private static PendingIntent getPendingIntentWithParentStack(final Context context, + final Intent intent, final int requestCode) { + final TaskStackBuilder stackBuilder = TaskStackBuilder.create(context); + // Adds the back stack for the Intent (plus the Intent itself) + stackBuilder.addNextIntentWithParentStack(intent); + final PendingIntent resultPendingIntent = + stackBuilder.getPendingIntent(requestCode, PendingIntent.FLAG_UPDATE_CURRENT); + return resultPendingIntent; + } + + @Override + public Intent getRingtonePickerIntent(final String title, final Uri existingUri, + final Uri defaultUri, final int toneType) { + return new Intent(RingtoneManager.ACTION_RINGTONE_PICKER) + .putExtra(RingtoneManager.EXTRA_RINGTONE_TYPE, toneType) + .putExtra(RingtoneManager.EXTRA_RINGTONE_TITLE, title) + .putExtra(RingtoneManager.EXTRA_RINGTONE_EXISTING_URI, existingUri) + .putExtra(RingtoneManager.EXTRA_RINGTONE_DEFAULT_URI, defaultUri); + } + + @Override + public PendingIntent getPendingIntentForLowStorageNotifications(final Context context) { + final TaskStackBuilder taskStackBuilder = TaskStackBuilder.create(context); + final Intent conversationListIntent = getConversationListActivityIntent(context); + taskStackBuilder.addNextIntent(conversationListIntent); + taskStackBuilder.addNextIntentWithParentStack( + getSmsStorageLowWarningActivityIntent(context)); + + return taskStackBuilder.getPendingIntent( + 0, PendingIntent.FLAG_UPDATE_CURRENT); + } + + @Override + public PendingIntent getPendingIntentForSecondaryUserNewMessageNotification( + final Context context) { + return getPendingIntentForConversationListActivity(context); + } + + @Override + public Intent getWirelessAlertsIntent() { + final Intent intent = new Intent(Intent.ACTION_MAIN); + intent.setComponent(new ComponentName(CMAS_COMPONENT, CELL_BROADCAST_LIST_ACTIVITY)); + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + return intent; + } + + @Override + public Intent getApnEditorIntent(final Context context, final String rowId, final int subId) { + final Intent intent = new Intent(context, ApnEditorActivity.class); + intent.putExtra(UI_INTENT_EXTRA_APN_ROW_ID, rowId); + intent.putExtra(UI_INTENT_EXTRA_SUB_ID, subId); + return intent; + } + + @Override + public Intent getApnSettingsIntent(final Context context, final int subId) { + final Intent intent = new Intent(context, ApnSettingsActivity.class) + .putExtra(UI_INTENT_EXTRA_SUB_ID, subId); + return intent; + } + + @Override + public Intent getAdvancedSettingsIntent(final Context context) { + return getPerSubscriptionSettingsIntent(context, ParticipantData.DEFAULT_SELF_SUB_ID, null); + } + + @Override + public Intent getChangeDefaultSmsAppIntent(final Activity activity) { + final Intent intent = new Intent(Telephony.Sms.Intents.ACTION_CHANGE_DEFAULT); + intent.putExtra(Telephony.Sms.Intents.EXTRA_PACKAGE_NAME, activity.getPackageName()); + return intent; + } + + @Override + public void launchBrowserForUrl(final Context context, final String url) { + final Intent intent = getViewUrlIntent(url); + startExternalActivity(context, intent); + } + + /** + * Provides a safe way to handle external activities which may not exist. + */ + private void startExternalActivity(final Context context, final Intent intent) { + try { + context.startActivity(intent); + } catch (final ActivityNotFoundException ex) { + LogUtil.w(LogUtil.BUGLE_TAG, "Couldn't find activity:", ex); + UiUtils.showToastAtBottom(R.string.activity_not_found_message); + } + } + + private Intent getPerSubscriptionSettingsIntent(final Context context, final int subId, + @Nullable final String settingTitle) { + return new Intent(context, PerSubscriptionSettingsActivity.class) + .putExtra(UI_INTENT_EXTRA_SUB_ID, subId) + .putExtra(UI_INTENT_EXTRA_PER_SUBSCRIPTION_SETTING_TITLE, settingTitle); + } + + @Override + public Intent getLaunchConversationActivityIntent(final Context context) { + final Intent intent = new Intent(context, LaunchConversationActivity.class); + intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_NO_HISTORY); + return intent; + } + + @Override + public void kickMediaScanner(final Context context, final String volume) { + final Intent intent = new Intent(MEDIA_SCANNER_SCAN_ACTION) + .putExtra(MediaStore.MEDIA_SCANNER_VOLUME, volume) + .setClassName(MEDIA_SCANNER_PACKAGE, MEDIA_SCANNER_CLASS); + context.startService(intent); + } + + @Override + public PendingIntent getWidgetPendingIntentForConversationActivity(final Context context, + final String conversationId, final int requestCode) { + final Intent intent = getConversationActivityIntent(context, null, null, + false /* withCustomTransition */); + if (conversationId != null) { + intent.putExtra(UI_INTENT_EXTRA_CONVERSATION_ID, conversationId); + + // Set the action to something unique to this conversation so if someone calls this + // function again on a different conversation, they'll get a new PendingIntent instead + // of the old one. + intent.setAction(ACTION_WIDGET_CONVERSATION + conversationId); + } + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + return getPendingIntentWithParentStack(context, intent, requestCode); + } + + @Override + public PendingIntent getWidgetPendingIntentForConversationListActivity( + final Context context) { + final Intent intent = getConversationListActivityIntent(context); + return getPendingIntentWithParentStack(context, intent, 0); + } + + @Override + public PendingIntent getWidgetPendingIntentForConfigurationActivity(final Context context, + final int appWidgetId) { + final Intent configureIntent = new Intent(context, WidgetPickConversationActivity.class); + configureIntent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId); + configureIntent.setAction(AppWidgetManager.ACTION_APPWIDGET_CONFIGURE); + configureIntent.setData(Uri.parse(configureIntent.toUri(Intent.URI_INTENT_SCHEME))); + configureIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_NO_HISTORY); + return getPendingIntentWithParentStack(context, configureIntent, 0); + } +} diff --git a/src/com/android/messaging/ui/VCardDetailActivity.java b/src/com/android/messaging/ui/VCardDetailActivity.java new file mode 100644 index 0000000..fecdc34 --- /dev/null +++ b/src/com/android/messaging/ui/VCardDetailActivity.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.ui; + +import android.app.Fragment; +import android.net.Uri; +import android.os.Bundle; +import android.view.MenuItem; + +import com.android.messaging.R; +import com.android.messaging.util.Assert; + +/** + * An activity that hosts VCardDetailFragment that shows the content of a VCard that contains one + * or more contacts. + */ +public class VCardDetailActivity extends BugleActionBarActivity { + @Override + protected void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.vcard_detail_activity); + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + } + + @Override + public void onAttachFragment(final Fragment fragment) { + Assert.isTrue(fragment instanceof VCardDetailFragment); + final Uri vCardUri = getIntent().getParcelableExtra(UIIntents.UI_INTENT_EXTRA_VCARD_URI); + Assert.notNull(vCardUri); + final VCardDetailFragment vCardDetailFragment = (VCardDetailFragment) fragment; + vCardDetailFragment.setVCardUri(vCardUri); + } + + @Override + public boolean onOptionsItemSelected(final MenuItem item) { + switch (item.getItemId()) { + case android.R.id.home: + // Treat the home press as back press so that when we go back to + // ConversationActivity, it doesn't lose its original intent (conversation id etc.) + onBackPressed(); + return true; + + default: + return super.onOptionsItemSelected(item); + } + } +} diff --git a/src/com/android/messaging/ui/VCardDetailAdapter.java b/src/com/android/messaging/ui/VCardDetailAdapter.java new file mode 100644 index 0000000..cfdd836 --- /dev/null +++ b/src/com/android/messaging/ui/VCardDetailAdapter.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.ui; + +import android.content.Context; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.BaseExpandableListAdapter; + +import com.android.messaging.R; +import com.android.messaging.datamodel.media.VCardResourceEntry; +import com.android.messaging.datamodel.media.VCardResourceEntry.VCardResourceEntryDestinationItem; + +import java.util.List; + +/** + * Displays a list of expandable contact cards shown in the VCardDetailActivity. + */ +public class VCardDetailAdapter extends BaseExpandableListAdapter { + private final List<VCardResourceEntry> mVCards; + private final LayoutInflater mInflater; + + public VCardDetailAdapter(final Context context, final List<VCardResourceEntry> vCards) { + mVCards = vCards; + mInflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE); + } + + @Override + public Object getChild(final int groupPosition, final int childPosition) { + return mVCards.get(groupPosition).getContactInfo().get(childPosition); + } + + @Override + public long getChildId(final int groupPosition, final int childPosition) { + return childPosition; + } + + @Override + public View getChildView(final int groupPosition, final int childPosition, + final boolean isLastChild, final View convertView, final ViewGroup parent) { + PersonItemView v; + if (convertView == null) { + v = instantiateView(parent); + } else { + v = (PersonItemView) convertView; + } + + final VCardResourceEntryDestinationItem item = (VCardResourceEntryDestinationItem) + getChild(groupPosition, childPosition); + + v.bind(item.getDisplayItem()); + return v; + } + + @Override + public int getChildrenCount(final int groupPosition) { + return mVCards.get(groupPosition).getContactInfo().size(); + } + + @Override + public Object getGroup(final int groupPosition) { + return mVCards.get(groupPosition); + } + + @Override + public int getGroupCount() { + return mVCards.size(); + } + + @Override + public long getGroupId(final int groupPosition) { + return groupPosition; + } + + @Override + public View getGroupView(final int groupPosition, final boolean isExpanded, + final View convertView, final ViewGroup parent) { + PersonItemView v; + if (convertView == null) { + v = instantiateView(parent); + } else { + v = (PersonItemView) convertView; + } + + final VCardResourceEntry item = (VCardResourceEntry) getGroup(groupPosition); + v.bind(item.getDisplayItem()); + return v; + } + + @Override + public boolean isChildSelectable(final int groupPosition, final int childPosition) { + return true; + } + + @Override + public boolean hasStableIds() { + return true; + } + + private PersonItemView instantiateView(final ViewGroup parent) { + final PersonItemView v = (PersonItemView) mInflater.inflate(R.layout.people_list_item_view, + parent, false); + v.setClickable(false); + return v; + } +} diff --git a/src/com/android/messaging/ui/VCardDetailFragment.java b/src/com/android/messaging/ui/VCardDetailFragment.java new file mode 100644 index 0000000..1b2b88d --- /dev/null +++ b/src/com/android/messaging/ui/VCardDetailFragment.java @@ -0,0 +1,197 @@ +/* + * 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.ui; + +import android.app.Fragment; +import android.content.ActivityNotFoundException; +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.View.OnLayoutChangeListener; +import android.view.ViewGroup; +import android.widget.ExpandableListAdapter; +import android.widget.ExpandableListView; +import android.widget.ExpandableListView.OnChildClickListener; + +import com.android.messaging.R; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.MediaScratchFileProvider; +import com.android.messaging.datamodel.binding.Binding; +import com.android.messaging.datamodel.binding.BindingBase; +import com.android.messaging.datamodel.data.PersonItemData; +import com.android.messaging.datamodel.data.VCardContactItemData; +import com.android.messaging.datamodel.data.PersonItemData.PersonItemDataListener; +import com.android.messaging.util.Assert; +import com.android.messaging.util.SafeAsyncTask; +import com.android.messaging.util.UiUtils; +import com.android.messaging.util.UriUtil; + +/** + * A fragment that shows the content of a VCard that contains one or more contacts. + */ +public class VCardDetailFragment extends Fragment implements PersonItemDataListener { + private final Binding<VCardContactItemData> mBinding = + BindingBase.createBinding(this); + private ExpandableListView mListView; + private VCardDetailAdapter mAdapter; + private Uri mVCardUri; + + /** + * We need to persist the VCard in the scratch directory before letting the user view it. + * We save this Uri locally, so that if the user cancels the action and re-perform the add + * to contacts action we don't have to persist it again. + */ + private Uri mScratchSpaceUri; + + @Override + public void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setHasOptionsMenu(true); + } + + @Override + public View onCreateView(final LayoutInflater inflater, final ViewGroup container, + final Bundle savedInstanceState) { + Assert.notNull(mVCardUri); + final View view = inflater.inflate(R.layout.vcard_detail_fragment, container, false); + mListView = (ExpandableListView) view.findViewById(R.id.list); + mListView.addOnLayoutChangeListener(new OnLayoutChangeListener() { + @Override + public void onLayoutChange(final View v, final int left, final int top, final int right, + final int bottom, final int oldLeft, final int oldTop, final int oldRight, + final int oldBottom) { + mListView.setIndicatorBounds(mListView.getWidth() - getResources() + .getDimensionPixelSize(R.dimen.vcard_detail_group_indicator_width), + mListView.getWidth()); + } + }); + mListView.setOnChildClickListener(new OnChildClickListener() { + @Override + public boolean onChildClick(ExpandableListView expandableListView, View clickedView, + int groupPosition, int childPosition, long childId) { + if (!(clickedView instanceof PersonItemView)) { + return false; + } + final Intent intent = ((PersonItemView) clickedView).getClickIntent(); + if (intent != null) { + try { + startActivity(intent); + } catch (ActivityNotFoundException e) { + return false; + } + return true; + } + return false; + } + }); + mBinding.bind(DataModel.get().createVCardContactItemData(getActivity(), mVCardUri)); + mBinding.getData().setListener(this); + return view; + } + + @Override + public void onDestroy() { + super.onDestroy(); + if (mBinding.isBound()) { + mBinding.unbind(); + } + mListView.setAdapter((ExpandableListAdapter) null); + } + + private boolean shouldShowAddToContactsItem() { + return mBinding.isBound() && mBinding.getData().hasValidVCard(); + } + + @Override + public void onCreateOptionsMenu(final Menu menu, final MenuInflater inflater) { + super.onCreateOptionsMenu(menu, inflater); + inflater.inflate(R.menu.vcard_detail_fragment_menu, menu); + final MenuItem addToContactsItem = menu.findItem(R.id.action_add_contact); + addToContactsItem.setVisible(shouldShowAddToContactsItem()); + } + + @Override + public boolean onOptionsItemSelected(final MenuItem item) { + switch (item.getItemId()) { + case R.id.action_add_contact: + mBinding.ensureBound(); + final Uri vCardUri = mBinding.getData().getVCardUri(); + + // We have to do things in the background in case we need to copy the vcard data. + new SafeAsyncTask<Void, Void, Uri>() { + @Override + protected Uri doInBackgroundTimed(final Void... params) { + // We can't delete the persisted vCard file because we don't know when to + // delete it, since the app that uses it (contacts, dialer) may start or + // shut down at any point. Therefore, we rely on the system to clean up + // the cache directory for us. + return mScratchSpaceUri != null ? mScratchSpaceUri : + UriUtil.persistContentToScratchSpace(vCardUri); + } + + @Override + protected void onPostExecute(final Uri result) { + if (result != null) { + mScratchSpaceUri = result; + if (getActivity() != null) { + MediaScratchFileProvider.addUriToDisplayNameEntry( + result, mBinding.getData().getDisplayName()); + UIIntents.get().launchSaveVCardToContactsActivity(getActivity(), + result); + } + } + } + }.executeOnThreadPool(); + return true; + + default: + return super.onOptionsItemSelected(item); + } + } + + public void setVCardUri(final Uri vCardUri) { + Assert.isTrue(!mBinding.isBound()); + mVCardUri = vCardUri; + } + + @Override + public void onPersonDataUpdated(final PersonItemData data) { + Assert.isTrue(data instanceof VCardContactItemData); + mBinding.ensureBound(); + final VCardContactItemData vCardData = (VCardContactItemData) data; + Assert.isTrue(vCardData.hasValidVCard()); + mAdapter = new VCardDetailAdapter(getActivity(), vCardData.getVCardResource().getVCards()); + mListView.setAdapter(mAdapter); + + // Expand the contact card if there's only one contact. + if (mAdapter.getGroupCount() == 1) { + mListView.expandGroup(0); + } + getActivity().invalidateOptionsMenu(); + } + + @Override + public void onPersonDataFailed(final PersonItemData data, final Exception exception) { + mBinding.ensureBound(); + UiUtils.showToastAtBottom(R.string.failed_loading_vcard); + getActivity().finish(); + } +} diff --git a/src/com/android/messaging/ui/VideoThumbnailView.java b/src/com/android/messaging/ui/VideoThumbnailView.java new file mode 100644 index 0000000..966336e --- /dev/null +++ b/src/com/android/messaging/ui/VideoThumbnailView.java @@ -0,0 +1,343 @@ +/* + * 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.ui; + +import android.content.Context; +import android.content.res.TypedArray; +import android.media.MediaPlayer; +import android.net.Uri; +import android.util.AttributeSet; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.FrameLayout; +import android.widget.ImageButton; +import android.widget.ImageView.ScaleType; +import android.widget.VideoView; + +import com.android.messaging.R; +import com.android.messaging.datamodel.data.MessagePartData; +import com.android.messaging.datamodel.media.ImageRequest; +import com.android.messaging.datamodel.media.MessagePartVideoThumbnailRequestDescriptor; +import com.android.messaging.datamodel.media.VideoThumbnailRequest; +import com.android.messaging.util.Assert; + +/** + * View that encapsulates a video preview (either as a thumbnail image, or video player), and the + * a play button to overlay it. Ensures that the video preview maintains the aspect ratio of the + * original video while trying to respect minimum width/height and constraining to the available + * bounds + */ +public class VideoThumbnailView extends FrameLayout { + /** + * When in this mode the VideoThumbnailView is a lightweight AsyncImageView with an ImageButton + * to play the video. Clicking play will launch a full screen player + */ + private static final int MODE_IMAGE_THUMBNAIL = 0; + + /** + * When in this mode the VideoThumbnailVideo will include a VideoView, and the play button will + * play the video inline. When in this mode, the loop and playOnLoad attributes can be applied + * to auto-play or loop the video. + */ + private static final int MODE_PLAYABLE_VIDEO = 1; + + private final int mMode; + private final boolean mPlayOnLoad; + private final boolean mAllowCrop; + private final VideoView mVideoView; + private final ImageButton mPlayButton; + private final AsyncImageView mThumbnailImage; + private int mVideoWidth; + private int mVideoHeight; + private Uri mVideoSource; + private boolean mAnimating; + private boolean mVideoLoaded; + + public VideoThumbnailView(final Context context, final AttributeSet attrs) { + super(context, attrs); + final TypedArray typedAttributes = + context.obtainStyledAttributes(attrs, R.styleable.VideoThumbnailView); + + final LayoutInflater inflater = LayoutInflater.from(context); + inflater.inflate(R.layout.video_thumbnail_view, this, true); + + mPlayOnLoad = typedAttributes.getBoolean(R.styleable.VideoThumbnailView_playOnLoad, false); + final boolean loop = + typedAttributes.getBoolean(R.styleable.VideoThumbnailView_loop, false); + mMode = typedAttributes.getInt(R.styleable.VideoThumbnailView_mode, MODE_IMAGE_THUMBNAIL); + mAllowCrop = typedAttributes.getBoolean(R.styleable.VideoThumbnailView_allowCrop, false); + + mVideoWidth = ImageRequest.UNSPECIFIED_SIZE; + mVideoHeight = ImageRequest.UNSPECIFIED_SIZE; + + if (mMode == MODE_PLAYABLE_VIDEO) { + mVideoView = new VideoView(context); + // Video view tries to request focus on start which pulls focus from the user's intended + // focus when we add this control. Remove focusability to prevent this. The play + // button can still be focused + mVideoView.setFocusable(false); + mVideoView.setFocusableInTouchMode(false); + mVideoView.clearFocus(); + addView(mVideoView, 0, new ViewGroup.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)); + mVideoView.setOnPreparedListener(new MediaPlayer.OnPreparedListener() { + @Override + public void onPrepared(final MediaPlayer mediaPlayer) { + mVideoLoaded = true; + mVideoWidth = mediaPlayer.getVideoWidth(); + mVideoHeight = mediaPlayer.getVideoHeight(); + mediaPlayer.setLooping(loop); + trySwitchToVideo(); + } + }); + mVideoView.setOnCompletionListener(new MediaPlayer.OnCompletionListener() { + @Override + public void onCompletion(final MediaPlayer mediaPlayer) { + mPlayButton.setVisibility(View.VISIBLE); + } + }); + mVideoView.setOnErrorListener(new MediaPlayer.OnErrorListener() { + @Override + public boolean onError(final MediaPlayer mediaPlayer, final int i, final int i2) { + return true; + } + }); + } else { + mVideoView = null; + } + + mPlayButton = (ImageButton) findViewById(R.id.video_thumbnail_play_button); + if (loop) { + mPlayButton.setVisibility(View.GONE); + } else { + mPlayButton.setOnClickListener(new OnClickListener() { + @Override + public void onClick(final View view) { + if (mVideoSource == null) { + return; + } + + if (mMode == MODE_PLAYABLE_VIDEO) { + mVideoView.seekTo(0); + start(); + } else { + UIIntents.get().launchFullScreenVideoViewer(getContext(), mVideoSource); + } + } + }); + mPlayButton.setOnLongClickListener(new OnLongClickListener() { + @Override + public boolean onLongClick(final View view) { + // Button prevents long click from propagating up, do it manually + VideoThumbnailView.this.performLongClick(); + return true; + } + }); + } + + mThumbnailImage = (AsyncImageView) findViewById(R.id.video_thumbnail_image); + if (mAllowCrop) { + mThumbnailImage.getLayoutParams().width = ViewGroup.LayoutParams.MATCH_PARENT; + mThumbnailImage.getLayoutParams().height = ViewGroup.LayoutParams.MATCH_PARENT; + mThumbnailImage.setScaleType(ScaleType.CENTER_CROP); + } else { + // This is the default setting in the layout, so No-op. + } + final int maxHeight = typedAttributes.getDimensionPixelSize( + R.styleable.VideoThumbnailView_android_maxHeight, ImageRequest.UNSPECIFIED_SIZE); + if (maxHeight != ImageRequest.UNSPECIFIED_SIZE) { + mThumbnailImage.setMaxHeight(maxHeight); + mThumbnailImage.setAdjustViewBounds(true); + } + + typedAttributes.recycle(); + } + + @Override + protected void onAnimationStart() { + super.onAnimationStart(); + mAnimating = true; + } + + @Override + protected void onAnimationEnd() { + super.onAnimationEnd(); + mAnimating = false; + trySwitchToVideo(); + } + + private void trySwitchToVideo() { + if (mAnimating) { + // Don't start video or hide image until after animation completes + return; + } + + if (!mVideoLoaded) { + // Video hasn't loaded, nothing more to do + return; + } + + if (mPlayOnLoad) { + start(); + } else { + mVideoView.seekTo(0); + } + } + + private boolean hasVideoSize() { + return mVideoWidth != ImageRequest.UNSPECIFIED_SIZE && + mVideoHeight != ImageRequest.UNSPECIFIED_SIZE; + } + + public void start() { + Assert.equals(MODE_PLAYABLE_VIDEO, mMode); + mPlayButton.setVisibility(View.GONE); + mThumbnailImage.setVisibility(View.GONE); + mVideoView.start(); + } + + // TODO: The check could be added to MessagePartData itself so that all users of MessagePartData + // get the right behavior, instead of requiring all the users to do similar checks. + private static boolean shouldUseGenericVideoIcon(final boolean incomingMessage) { + return incomingMessage && !VideoThumbnailRequest.shouldShowIncomingVideoThumbnails(); + } + + public void setSource(final MessagePartData part, final boolean incomingMessage) { + if (part == null) { + clearSource(); + } else { + mVideoSource = part.getContentUri(); + if (shouldUseGenericVideoIcon(incomingMessage)) { + mThumbnailImage.setImageResource(R.drawable.generic_video_icon); + mVideoWidth = ImageRequest.UNSPECIFIED_SIZE; + mVideoHeight = ImageRequest.UNSPECIFIED_SIZE; + } else { + mThumbnailImage.setImageResourceId( + new MessagePartVideoThumbnailRequestDescriptor(part)); + if (mVideoView != null) { + mVideoView.setVideoURI(mVideoSource); + } + mVideoWidth = part.getWidth(); + mVideoHeight = part.getHeight(); + } + } + } + + public void setSource(final Uri videoSource, final boolean incomingMessage) { + if (videoSource == null) { + clearSource(); + } else { + mVideoSource = videoSource; + if (shouldUseGenericVideoIcon(incomingMessage)) { + mThumbnailImage.setImageResource(R.drawable.generic_video_icon); + mVideoWidth = ImageRequest.UNSPECIFIED_SIZE; + mVideoHeight = ImageRequest.UNSPECIFIED_SIZE; + } else { + mThumbnailImage.setImageResourceId( + new MessagePartVideoThumbnailRequestDescriptor(videoSource)); + if (mVideoView != null) { + mVideoView.setVideoURI(videoSource); + } + } + } + } + + private void clearSource() { + mVideoSource = null; + mThumbnailImage.setImageResourceId(null); + mVideoWidth = ImageRequest.UNSPECIFIED_SIZE; + mVideoHeight = ImageRequest.UNSPECIFIED_SIZE; + if (mVideoView != null) { + mVideoView.setVideoURI(null); + } + } + + @Override + public void setMinimumWidth(final int minWidth) { + super.setMinimumWidth(minWidth); + if (mVideoView != null) { + mVideoView.setMinimumWidth(minWidth); + } + } + + @Override + public void setMinimumHeight(final int minHeight) { + super.setMinimumHeight(minHeight); + if (mVideoView != null) { + mVideoView.setMinimumHeight(minHeight); + } + } + + public void setColorFilter(int color) { + mThumbnailImage.setColorFilter(color); + mPlayButton.setColorFilter(color); + } + + public void clearColorFilter() { + mThumbnailImage.clearColorFilter(); + mPlayButton.clearColorFilter(); + } + + @Override + protected void onMeasure(final int widthMeasureSpec, final int heightMeasureSpec) { + if (mAllowCrop) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + return; + } + int desiredWidth = 1; + int desiredHeight = 1; + if (mVideoView != null) { + mVideoView.measure(widthMeasureSpec, heightMeasureSpec); + } + mThumbnailImage.measure(widthMeasureSpec, heightMeasureSpec); + if (hasVideoSize()) { + desiredWidth = mVideoWidth; + desiredHeight = mVideoHeight; + } else { + desiredWidth = mThumbnailImage.getMeasuredWidth(); + desiredHeight = mThumbnailImage.getMeasuredHeight(); + } + + final int minimumWidth = getMinimumWidth(); + final int minimumHeight = getMinimumHeight(); + + // Constrain the scale to fit within the supplied size + final float maxScale = Math.max( + MeasureSpec.getSize(widthMeasureSpec) / (float) desiredWidth, + MeasureSpec.getSize(heightMeasureSpec) / (float) desiredHeight); + + // Scale up to reach minimum width/height + final float widthScale = Math.max(1, minimumWidth / (float) desiredWidth); + final float heightScale = Math.max(1, minimumHeight / (float) desiredHeight); + final float scale = Math.min(maxScale, Math.max(widthScale, heightScale)); + desiredWidth = (int) (desiredWidth * scale); + desiredHeight = (int) (desiredHeight * scale); + + setMeasuredDimension(desiredWidth, desiredHeight); + } + + @Override + protected void onLayout(final boolean changed, final int left, final int top, final int right, + final int bottom) { + final int count = getChildCount(); + for (int i = 0; i < count; i++) { + final View child = getChildAt(i); + child.layout(0, 0, right - left, bottom - top); + } + } +} diff --git a/src/com/android/messaging/ui/ViewPagerTabStrip.java b/src/com/android/messaging/ui/ViewPagerTabStrip.java new file mode 100644 index 0000000..e088296 --- /dev/null +++ b/src/com/android/messaging/ui/ViewPagerTabStrip.java @@ -0,0 +1,102 @@ +/* + * 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.ui; + +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.util.AttributeSet; +import android.view.View; +import android.widget.LinearLayout; + +import com.android.messaging.R; +import com.android.messaging.util.OsUtil; + +public class ViewPagerTabStrip extends LinearLayout { + private int mSelectedUnderlineThickness; + private final Paint mSelectedUnderlinePaint; + + private int mIndexForSelection; + private float mSelectionOffset; + + public ViewPagerTabStrip(Context context) { + this(context, null); + } + + public ViewPagerTabStrip(Context context, AttributeSet attrs) { + super(context, attrs); + + final Resources res = context.getResources(); + + mSelectedUnderlineThickness = + res.getDimensionPixelSize(R.dimen.pager_tab_underline_selected); + int underlineColor = res.getColor(R.color.contact_picker_tab_underline); + int backgroundColor = res.getColor(R.color.action_bar_background_color); + + mSelectedUnderlinePaint = new Paint(); + mSelectedUnderlinePaint.setColor(underlineColor); + + setBackgroundColor(backgroundColor); + setWillNotDraw(false); + } + + /** + * Notifies this view that view pager has been scrolled. We save the tab index + * and selection offset for interpolating the position and width of selection + * underline. + */ + void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) { + mIndexForSelection = position; + mSelectionOffset = positionOffset; + invalidate(); + } + + @Override + protected void onDraw(Canvas canvas) { + int childCount = getChildCount(); + + // Thick colored underline below the current selection + if (childCount > 0) { + View selectedTitle = getChildAt(mIndexForSelection); + int selectedLeft = selectedTitle.getLeft(); + int selectedRight = selectedTitle.getRight(); + final boolean isRtl = isRtl(); + final boolean hasNextTab = isRtl ? mIndexForSelection > 0 + : (mIndexForSelection < (getChildCount() - 1)); + if ((mSelectionOffset > 0.0f) && hasNextTab) { + // Draw the selection partway between the tabs + View nextTitle = getChildAt(mIndexForSelection + (isRtl ? -1 : 1)); + int nextLeft = nextTitle.getLeft(); + int nextRight = nextTitle.getRight(); + + selectedLeft = (int) (mSelectionOffset * nextLeft + + (1.0f - mSelectionOffset) * selectedLeft); + selectedRight = (int) (mSelectionOffset * nextRight + + (1.0f - mSelectionOffset) * selectedRight); + } + + int height = getHeight(); + canvas.drawRect(selectedLeft, height - mSelectedUnderlineThickness, + selectedRight, height, mSelectedUnderlinePaint); + } + } + + private boolean isRtl() { + return OsUtil.isAtLeastJB_MR2() ? getLayoutDirection() == View.LAYOUT_DIRECTION_RTL : false; + } +}
\ No newline at end of file diff --git a/src/com/android/messaging/ui/ViewPagerTabs.java b/src/com/android/messaging/ui/ViewPagerTabs.java new file mode 100644 index 0000000..f31a071 --- /dev/null +++ b/src/com/android/messaging/ui/ViewPagerTabs.java @@ -0,0 +1,236 @@ +/* + * 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.ui; + +import android.content.Context; +import android.content.res.ColorStateList; +import android.content.res.TypedArray; +import android.graphics.Outline; +import android.graphics.drawable.ColorDrawable; +import android.support.v4.view.PagerAdapter; +import android.support.v4.view.ViewPager; +import android.util.AttributeSet; +import android.util.TypedValue; +import android.view.Gravity; +import android.view.View; +import android.view.ViewOutlineProvider; +import android.widget.FrameLayout; +import android.widget.HorizontalScrollView; +import android.widget.LinearLayout; +import android.widget.TextView; +import android.widget.Toast; + +import com.android.messaging.Factory; +import com.android.messaging.R; +import com.android.messaging.util.OsUtil; + +/** + * Lightweight implementation of ViewPager tabs. This looks similar to traditional actionBar tabs, + * but allows for the view containing the tabs to be placed anywhere on screen. Text-related + * attributes can also be assigned in XML - these will get propogated to the child TextViews + * automatically. + * + * Note: this file is taken from the AOSP /packages/apps/ContactsCommon/src/com/android/contacts/ + * common/list/ViewPagerTabs.java. Some platform specific API calls (e.g. ViewOutlineProvider which + * assumes L and above) have been modified to support down to Api Level 16. + */ +public class ViewPagerTabs extends HorizontalScrollView implements ViewPager.OnPageChangeListener { + + ViewPager mPager; + private ViewPagerTabStrip mTabStrip; + + /** + * Linearlayout that will contain the TextViews serving as tabs. This is the only child + * of the parent HorizontalScrollView. + */ + final int mTextStyle; + final ColorStateList mTextColor; + final int mTextSize; + final boolean mTextAllCaps; + int mPrevSelected = -1; + int mSidePadding; + + private static final int TAB_SIDE_PADDING_IN_DPS = 10; + + // TODO: This should use <declare-styleable> in the future + private static final int[] ATTRS = new int[] { + android.R.attr.textSize, + android.R.attr.textStyle, + android.R.attr.textColor, + android.R.attr.textAllCaps + }; + + /** + * Shows a toast with the tab description when long-clicked. + */ + private class OnTabLongClickListener implements OnLongClickListener { + final String mTabDescription; + + public OnTabLongClickListener(String tabDescription) { + mTabDescription = tabDescription; + } + + @Override + public boolean onLongClick(View v) { + final int[] screenPos = new int[2]; + getLocationOnScreen(screenPos); + + final Context context = getContext(); + final int width = getWidth(); + final int height = getHeight(); + final int screenWidth = context.getResources().getDisplayMetrics().widthPixels; + + Toast toast = Toast.makeText(context, mTabDescription, Toast.LENGTH_SHORT); + + // Show the toast under the tab + toast.setGravity(Gravity.TOP | Gravity.CENTER_HORIZONTAL, + (screenPos[0] + width / 2) - screenWidth / 2, screenPos[1] + height); + + toast.show(); + return true; + } + } + + public ViewPagerTabs(Context context) { + this(context, null); + } + + public ViewPagerTabs(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + public ViewPagerTabs(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + setFillViewport(true); + + mSidePadding = (int) (getResources().getDisplayMetrics().density * TAB_SIDE_PADDING_IN_DPS); + + final TypedArray a = context.obtainStyledAttributes(attrs, ATTRS); + mTextSize = a.getDimensionPixelSize(0, 0); + mTextStyle = a.getInt(1, 0); + mTextColor = a.getColorStateList(2); + mTextAllCaps = a.getBoolean(3, false); + + mTabStrip = new ViewPagerTabStrip(context); + addView(mTabStrip, + new FrameLayout.LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.MATCH_PARENT)); + a.recycle(); + + // enable shadow casting from view bounds + if (OsUtil.isAtLeastL()) { + setOutlineProvider(new ViewOutlineProvider() { + @Override + public void getOutline(View view, Outline outline) { + outline.setRect(0, 0, view.getWidth(), view.getHeight()); + } + }); + } + } + + public void setViewPager(ViewPager viewPager) { + mPager = viewPager; + addTabs(mPager.getAdapter()); + } + + private void addTabs(PagerAdapter adapter) { + mTabStrip.removeAllViews(); + + final int count = adapter.getCount(); + for (int i = 0; i < count; i++) { + addTab(adapter.getPageTitle(i), i); + } + } + + private void addTab(CharSequence tabTitle, final int position) { + final TextView textView = new TextView(getContext()); + textView.setText(tabTitle); + textView.setBackgroundResource(R.drawable.contact_picker_tab_background_selector); + textView.setGravity(Gravity.CENTER); + textView.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + mPager.setCurrentItem(getRtlPosition(position)); + } + }); + + // Assign various text appearance related attributes to child views. + if (mTextStyle > 0) { + textView.setTypeface(textView.getTypeface(), mTextStyle); + } + if (mTextSize > 0) { + textView.setTextSize(TypedValue.COMPLEX_UNIT_PX, mTextSize); + } + if (mTextColor != null) { + textView.setTextColor(mTextColor); + } + textView.setAllCaps(mTextAllCaps); + textView.setPadding(mSidePadding, 0, mSidePadding, 0); + mTabStrip.addView(textView, new LinearLayout.LayoutParams(LayoutParams.WRAP_CONTENT, + LayoutParams.MATCH_PARENT, 1)); + // Default to the first child being selected + if (position == 0) { + mPrevSelected = 0; + textView.setSelected(true); + } + } + + @Override + public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) { + position = getRtlPosition(position); + int tabStripChildCount = mTabStrip.getChildCount(); + if ((tabStripChildCount == 0) || (position < 0) || (position >= tabStripChildCount)) { + return; + } + + mTabStrip.onPageScrolled(position, positionOffset, positionOffsetPixels); + } + + @Override + public void onPageSelected(int position) { + position = getRtlPosition(position); + int tabStripChildCount = mTabStrip.getChildCount(); + if ((tabStripChildCount == 0) || (position < 0) || (position >= tabStripChildCount)) { + return; + } + + if (mPrevSelected >= 0 && mPrevSelected < tabStripChildCount) { + mTabStrip.getChildAt(mPrevSelected).setSelected(false); + } + final View selectedChild = mTabStrip.getChildAt(position); + selectedChild.setSelected(true); + + // Update scroll position + final int scrollPos = selectedChild.getLeft() - (getWidth() - selectedChild.getWidth()) / 2; + smoothScrollTo(scrollPos, 0); + mPrevSelected = position; + } + + @Override + public void onPageScrollStateChanged(int state) { + } + + private int getRtlPosition(int position) { + if (OsUtil.isAtLeastJB_MR2() && Factory.get().getApplicationContext().getResources() + .getConfiguration().getLayoutDirection() == View.LAYOUT_DIRECTION_RTL) { + return mTabStrip.getChildCount() - 1 - position; + } + return position; + } + + public int getSelectedItemPosition() { + return mPrevSelected; + } +} diff --git a/src/com/android/messaging/ui/WidgetPickConversationActivity.java b/src/com/android/messaging/ui/WidgetPickConversationActivity.java new file mode 100644 index 0000000..60e1318 --- /dev/null +++ b/src/com/android/messaging/ui/WidgetPickConversationActivity.java @@ -0,0 +1,114 @@ +/* + * 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.ui; + +import android.app.Fragment; +import android.appwidget.AppWidgetManager; +import android.content.Intent; +import android.os.Bundle; + +import com.android.messaging.Factory; +import com.android.messaging.datamodel.data.ConversationListItemData; +import com.android.messaging.ui.conversationlist.ShareIntentFragment; +import com.android.messaging.util.Assert; +import com.android.messaging.util.BuglePrefs; +import com.android.messaging.widget.WidgetConversationProvider; + +public class WidgetPickConversationActivity extends BaseBugleActivity implements + ShareIntentFragment.HostInterface { + + private int mAppWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID; + + @Override + protected void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + + // Set the result to CANCELED. This will cause the widget host to cancel + // out of the widget placement if they press the back button. + setResult(RESULT_CANCELED); + + // Find the widget id from the intent. + final Intent intent = getIntent(); + final Bundle extras = intent.getExtras(); + if (extras != null) { + mAppWidgetId = extras.getInt( + AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID); + } + + // If they gave us an intent without the widget id, just bail. + if (mAppWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) { + finish(); + } + + final ShareIntentFragment convPicker = new ShareIntentFragment(); + final Bundle bundle = new Bundle(); + bundle.putBoolean(ShareIntentFragment.HIDE_NEW_CONVERSATION_BUTTON_KEY, true); + convPicker.setArguments(bundle); + convPicker.show(getFragmentManager(), "ShareIntentFragment"); + } + + @Override + public void onAttachFragment(final Fragment fragment) { + final Intent intent = getIntent(); + final String action = intent.getAction(); + if (!AppWidgetManager.ACTION_APPWIDGET_CONFIGURE.equals(action)) { + // Unsupported action. + Assert.fail("Unsupported action type: " + action); + } + } + + @Override + public void onConversationClick(final ConversationListItemData conversationListItemData) { + saveConversationidPref(mAppWidgetId, conversationListItemData.getConversationId()); + + // Push widget update to surface with newly set prefix + WidgetConversationProvider.rebuildWidget(this, mAppWidgetId); + + // Make sure we pass back the original appWidgetId + Intent resultValue = new Intent(); + resultValue.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, mAppWidgetId); + setResult(RESULT_OK, resultValue); + finish(); + } + + @Override + public void onCreateConversationClick() { + // We should never get here because we're hiding the new conversation button in the + // ShareIntentFragment by setting HIDE_NEW_CONVERSATION_BUTTON_KEY in the arguments. + finish(); + } + + // Write the ConversationId to the SharedPreferences object for this widget + static void saveConversationidPref(int appWidgetId, String conversationId) { + final BuglePrefs prefs = Factory.get().getWidgetPrefs(); + prefs.putString(UIIntents.UI_INTENT_EXTRA_CONVERSATION_ID + appWidgetId, conversationId); + } + + // Read the ConversationId from the SharedPreferences object for this widget. + public static String getConversationIdPref(int appWidgetId) { + final BuglePrefs prefs = Factory.get().getWidgetPrefs(); + return prefs.getString(UIIntents.UI_INTENT_EXTRA_CONVERSATION_ID + appWidgetId, null); + } + + // Delete the ConversationId preference from the SharedPreferences object for this widget. + public static void deleteConversationIdPref(int appWidgetId) { + final BuglePrefs prefs = Factory.get().getWidgetPrefs(); + prefs.remove(UIIntents.UI_INTENT_EXTRA_CONVERSATION_ID + appWidgetId); + } + +} diff --git a/src/com/android/messaging/ui/animation/PopupTransitionAnimation.java b/src/com/android/messaging/ui/animation/PopupTransitionAnimation.java new file mode 100644 index 0000000..21529c6 --- /dev/null +++ b/src/com/android/messaging/ui/animation/PopupTransitionAnimation.java @@ -0,0 +1,302 @@ +/* + * 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.ui.animation; + +import android.animation.TypeEvaluator; +import android.app.Activity; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Rect; +import android.view.Gravity; +import android.view.View; +import android.view.ViewGroup; +import android.view.animation.Animation; +import android.view.animation.Transformation; +import android.widget.PopupWindow; + +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.ThreadUtil; +import com.android.messaging.util.UiUtils; + +/** + * Animates viewToAnimate from startRect to the place where it is in the layout, viewToAnimate + * should be in its final destination location before startAfterLayoutComplete is called. + * viewToAnimate will be drawn scaled and offset in a popupWindow. + * This class handles the case where the viewToAnimate moves during the animation + */ +public class PopupTransitionAnimation extends Animation { + /** The view we're animating */ + private final View mViewToAnimate; + + /** The rect to start the slide in animation from */ + private final Rect mStartRect; + + /** The rect of the currently animated view */ + private Rect mCurrentRect; + + /** The rect that we're animating to. This can change during the animation */ + private final Rect mDestRect; + + /** The bounds of the popup in window coordinates. Does not include notification bar */ + private final Rect mPopupRect; + + /** The bounds of the action bar in window coordinates. We clip the popup to below this */ + private final Rect mActionBarRect; + + /** Interpolates between the start and end rect for every animation tick */ + private final TypeEvaluator<Rect> mRectEvaluator; + + /** The popup window that holds contains the animating view */ + private PopupWindow mPopupWindow; + + /** The layout root for the popup which is where the animated view is rendered */ + private View mPopupRoot; + + /** The action bar's view */ + private final View mActionBarView; + + private Runnable mOnStartCallback; + private Runnable mOnStopCallback; + + public PopupTransitionAnimation(final Rect startRect, final View viewToAnimate) { + mViewToAnimate = viewToAnimate; + mStartRect = startRect; + mCurrentRect = new Rect(mStartRect); + mDestRect = new Rect(); + mPopupRect = new Rect(); + mActionBarRect = new Rect(); + final Activity activity = (Activity) viewToAnimate.getRootView().getContext(); + mActionBarView = activity.getWindow().getDecorView().findViewById( + android.support.v7.appcompat.R.id.action_bar); + mRectEvaluator = RectEvaluatorCompat.create(); + setDuration(UiUtils.MEDIAPICKER_TRANSITION_DURATION); + setInterpolator(UiUtils.DEFAULT_INTERPOLATOR); + setAnimationListener(new AnimationListener() { + @Override + public void onAnimationStart(final Animation animation) { + if (mOnStartCallback != null) { + mOnStartCallback.run(); + } + mEvents.append("oAS,"); + } + + @Override + public void onAnimationEnd(final Animation animation) { + if (mOnStopCallback != null) { + mOnStopCallback.run(); + } + dismiss(); + mEvents.append("oAE,"); + } + + @Override + public void onAnimationRepeat(final Animation animation) { + } + }); + } + + private final StringBuilder mEvents = new StringBuilder(); + private final Runnable mCleanupRunnable = new Runnable() { + @Override + public void run() { + LogUtil.w(LogUtil.BUGLE_TAG, "PopupTransitionAnimation: " + mEvents); + } + }; + + /** + * Ensures the animation is ready before starting the animation. + * viewToAnimate must first be layed out so we know where we will animate to + */ + public void startAfterLayoutComplete() { + // We want layout to occur, and then we immediately animate it in, so hide it initially to + // reduce jank on the first frame + mViewToAnimate.setVisibility(View.INVISIBLE); + mViewToAnimate.setAlpha(0); + + final Runnable startAnimation = new Runnable() { + boolean mRunComplete = false; + boolean mFirstTry = true; + + @Override + public void run() { + if (mRunComplete) { + return; + } + + mViewToAnimate.getGlobalVisibleRect(mDestRect); + // In Android views which are visible but haven't computed their size yet have a + // size of 1x1 because anything with a size of 0x0 is considered hidden. We can't + // start the animation until after the size is greater than 1x1 + if (mDestRect.width() <= 1 || mDestRect.height() <= 1) { + // Layout hasn't occurred yet + if (!mFirstTry) { + // Give up if this is not the first try, since layout change still doesn't + // yield a size for the view. This is likely because the media picker is + // full screen so there's no space left for the animated view. We give up + // on animation, but need to make sure the view that was initially + // hidden is re-shown. + mViewToAnimate.setAlpha(1); + mViewToAnimate.setVisibility(View.VISIBLE); + } else { + mFirstTry = false; + UiUtils.doOnceAfterLayoutChange(mViewToAnimate, this); + } + return; + } + + mRunComplete = true; + mViewToAnimate.startAnimation(PopupTransitionAnimation.this); + mViewToAnimate.invalidate(); + // http://b/20856505: The PopupWindow sometimes does not get dismissed. + ThreadUtil.getMainThreadHandler().postDelayed(mCleanupRunnable, getDuration() * 2); + } + }; + + startAnimation.run(); + } + + public PopupTransitionAnimation setOnStartCallback(final Runnable onStart) { + mOnStartCallback = onStart; + return this; + } + + public PopupTransitionAnimation setOnStopCallback(final Runnable onStop) { + mOnStopCallback = onStop; + return this; + } + + @Override + protected void applyTransformation(final float interpolatedTime, final Transformation t) { + if (mPopupWindow == null) { + initPopupWindow(); + } + // Update mDestRect as it may have moved during the animation + mPopupRect.set(UiUtils.getMeasuredBoundsOnScreen(mPopupRoot)); + mActionBarRect.set(UiUtils.getMeasuredBoundsOnScreen(mActionBarView)); + computeDestRect(); + + // Update currentRect to the new animated coordinates, and request mPopupRoot to redraw + // itself at the new coordinates + mCurrentRect = mRectEvaluator.evaluate(interpolatedTime, mStartRect, mDestRect); + mPopupRoot.invalidate(); + + if (interpolatedTime >= 0.98) { + mEvents.append("aT").append(interpolatedTime).append(','); + } + if (interpolatedTime == 1) { + dismiss(); + } + } + + private void dismiss() { + mEvents.append("d,"); + mViewToAnimate.setAlpha(1); + mViewToAnimate.setVisibility(View.VISIBLE); + // Delay dismissing the popup window to let mViewToAnimate draw under it and reduce the + // flash + ThreadUtil.getMainThreadHandler().post(new Runnable() { + @Override + public void run() { + try { + mPopupWindow.dismiss(); + } catch (IllegalArgumentException e) { + // PopupWindow.dismiss() will fire an IllegalArgumentException if the activity + // has already ended while we were animating + } + ThreadUtil.getMainThreadHandler().removeCallbacks(mCleanupRunnable); + } + }); + } + + @Override + public boolean willChangeBounds() { + return false; + } + + /** + * Computes mDestRect (the position in window space of the placeholder view that we should + * animate to). Some frames during the animation fail to compute getGlobalVisibleRect, so use + * the last known values in that case + */ + private void computeDestRect() { + final int prevTop = mDestRect.top; + final int prevLeft = mDestRect.left; + final int prevRight = mDestRect.right; + final int prevBottom = mDestRect.bottom; + + if (!getViewScreenMeasureRect(mViewToAnimate, mDestRect)) { + mDestRect.top = prevTop; + mDestRect.left = prevLeft; + mDestRect.bottom = prevBottom; + mDestRect.right = prevRight; + } + } + + /** + * Sets up the PopupWindow that the view will animate in. Animating the size and position of a + * popup can be choppy, so instead we make the popup fill the entire space of the screen, and + * animate the position of viewToAnimate within the popup using a Transformation + */ + private void initPopupWindow() { + mPopupRoot = new View(mViewToAnimate.getContext()) { + @Override + protected void onDraw(final Canvas canvas) { + canvas.save(); + canvas.clipRect(getLeft(), mActionBarRect.bottom - mPopupRect.top, getRight(), + getBottom()); + canvas.drawColor(Color.TRANSPARENT); + final float previousAlpha = mViewToAnimate.getAlpha(); + mViewToAnimate.setAlpha(1); + // The view's global position includes the notification bar height, but + // the popup window may or may not cover the notification bar (depending on screen + // rotation, IME status etc.), so we need to compensate for this difference by + // offseting vertically. + canvas.translate(mCurrentRect.left, mCurrentRect.top - mPopupRect.top); + + final float viewWidth = mViewToAnimate.getWidth(); + final float viewHeight = mViewToAnimate.getHeight(); + if (viewWidth > 0 && viewHeight > 0) { + canvas.scale(mCurrentRect.width() / viewWidth, + mCurrentRect.height() / viewHeight); + } + canvas.clipRect(0, 0, mCurrentRect.width(), mCurrentRect.height()); + if (!mPopupRect.isEmpty()) { + // HACK: Layout is unstable until mPopupRect is non-empty. + mViewToAnimate.draw(canvas); + } + mViewToAnimate.setAlpha(previousAlpha); + canvas.restore(); + } + }; + mPopupWindow = new PopupWindow(mViewToAnimate.getContext()); + mPopupWindow.setBackgroundDrawable(null); + mPopupWindow.setContentView(mPopupRoot); + mPopupWindow.setWidth(ViewGroup.LayoutParams.MATCH_PARENT); + mPopupWindow.setHeight(ViewGroup.LayoutParams.MATCH_PARENT); + mPopupWindow.setTouchable(false); + // We must pass a non-zero value for the y offset, or else the system resets the status bar + // color to black (M only) during the animation. The actual position of the window (and + // the animated view inside it) are still correct, regardless of what we pass for the y + // parameter (e.g. 1 and 100 both work). Not entirely sure why this works. + mPopupWindow.showAtLocation(mViewToAnimate, Gravity.TOP, 0, 1); + } + + private static boolean getViewScreenMeasureRect(final View view, final Rect outRect) { + outRect.set(UiUtils.getMeasuredBoundsOnScreen(view)); + return !outRect.isEmpty(); + } +} diff --git a/src/com/android/messaging/ui/animation/RectEvaluatorCompat.java b/src/com/android/messaging/ui/animation/RectEvaluatorCompat.java new file mode 100644 index 0000000..e3c60fc --- /dev/null +++ b/src/com/android/messaging/ui/animation/RectEvaluatorCompat.java @@ -0,0 +1,45 @@ +/* + * 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.ui.animation; + +import android.animation.RectEvaluator; +import android.animation.TypeEvaluator; +import android.graphics.Rect; + +import com.android.messaging.util.OsUtil; + +/** + * This evaluator can be used to perform type interpolation between <code>Rect</code> values. + * It's backward compatible to Api Level 11. + */ +public class RectEvaluatorCompat implements TypeEvaluator<Rect> { + public static TypeEvaluator<Rect> create() { + if (OsUtil.isAtLeastJB_MR2()) { + return new RectEvaluator(); + } else { + return new RectEvaluatorCompat(); + } + } + + @Override + public Rect evaluate(float fraction, Rect startValue, Rect endValue) { + int left = startValue.left + (int) ((endValue.left - startValue.left) * fraction); + int top = startValue.top + (int) ((endValue.top - startValue.top) * fraction); + int right = startValue.right + (int) ((endValue.right - startValue.right) * fraction); + int bottom = startValue.bottom + (int) ((endValue.bottom - startValue.bottom) * fraction); + return new Rect(left, top, right, bottom); + } +} diff --git a/src/com/android/messaging/ui/animation/ViewGroupItemVerticalExplodeAnimation.java b/src/com/android/messaging/ui/animation/ViewGroupItemVerticalExplodeAnimation.java new file mode 100644 index 0000000..6abfdf9 --- /dev/null +++ b/src/com/android/messaging/ui/animation/ViewGroupItemVerticalExplodeAnimation.java @@ -0,0 +1,208 @@ +/* + * 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.ui.animation; + +import android.annotation.TargetApi; +import android.app.Activity; +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Rect; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.ColorDrawable; +import android.graphics.drawable.Drawable; +import android.support.v4.view.ViewCompat; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewGroupOverlay; +import android.view.ViewOverlay; +import android.widget.FrameLayout; + +import com.android.messaging.R; +import com.android.messaging.util.ImageUtils; +import com.android.messaging.util.OsUtil; +import com.android.messaging.util.UiUtils; + +/** + * <p> + * Shows a vertical "explode" animation for any view inside a view group (e.g. views inside a + * ListView). During the animation, a snapshot is taken for the view to the animated and + * presented in a popup window or view overlay on top of the original view group. The background + * of the view (a highlight) vertically expands (explodes) during the animation. + * </p> + * <p> + * The exact implementation of the animation depends on platform API level. For JB_MR2 and later, + * the implementation utilizes ViewOverlay to perform highly performant overlay animations; for + * older API levels, the implementation falls back to using a full screen popup window to stage + * the animation. + * </p> + * <p> + * To start this animation, call {@link #startAnimationForView(ViewGroup, View, View, boolean, int)} + * </p> + */ +public class ViewGroupItemVerticalExplodeAnimation { + /** + * Starts a vertical explode animation for a given view situated in a given container. + * + * @param container the container of the view which determines the explode animation's final + * size + * @param viewToAnimate the view to be animated. The view will be highlighted by the explode + * highlight, which expands from the size of the view to the size of the container. + * @param animationStagingView the view that stages the animation. Since viewToAnimate may be + * removed from the view tree during the animation, we need a view that'll be alive + * for the duration of the animation so that the animation won't get cancelled. + * @param snapshotView whether a snapshot of the view to animate is needed. + */ + public static void startAnimationForView(final ViewGroup container, final View viewToAnimate, + final View animationStagingView, final boolean snapshotView, final int duration) { + if (OsUtil.isAtLeastJB_MR2() && (viewToAnimate.getContext() instanceof Activity)) { + new ViewExplodeAnimationJellyBeanMR2(viewToAnimate, container, snapshotView, duration) + .startAnimation(); + } else { + // Pre JB_MR2, this animation can cause rendering failures which causes the framework + // to fall back to software rendering where camera preview isn't supported (b/18264647) + // just skip the animation to avoid this case. + } + } + + /** + * Implementation class for API level >= 18. + */ + @TargetApi(18) + private static class ViewExplodeAnimationJellyBeanMR2 { + private final View mViewToAnimate; + private final ViewGroup mContainer; + private final View mSnapshot; + private final Bitmap mViewBitmap; + private final int mDuration; + + public ViewExplodeAnimationJellyBeanMR2(final View viewToAnimate, final ViewGroup container, + final boolean snapshotView, final int duration) { + mViewToAnimate = viewToAnimate; + mContainer = container; + mDuration = duration; + if (snapshotView) { + mViewBitmap = snapshotView(viewToAnimate); + mSnapshot = new View(viewToAnimate.getContext()); + } else { + mSnapshot = null; + mViewBitmap = null; + } + } + + public void startAnimation() { + final Context context = mViewToAnimate.getContext(); + final Resources resources = context.getResources(); + final View decorView = ((Activity) context).getWindow().getDecorView(); + final ViewOverlay viewOverlay = decorView.getOverlay(); + if (viewOverlay instanceof ViewGroupOverlay) { + final ViewGroupOverlay overlay = (ViewGroupOverlay) viewOverlay; + + // Add a shadow layer to the overlay. + final FrameLayout shadowContainerLayer = new FrameLayout(context); + final Drawable oldBackground = mViewToAnimate.getBackground(); + final Rect containerRect = UiUtils.getMeasuredBoundsOnScreen(mContainer); + final Rect decorRect = UiUtils.getMeasuredBoundsOnScreen(decorView); + // Position the container rect relative to the decor rect since the decor rect + // defines whether the view overlay will be positioned. + containerRect.offset(-decorRect.left, -decorRect.top); + shadowContainerLayer.setLeft(containerRect.left); + shadowContainerLayer.setTop(containerRect.top); + shadowContainerLayer.setBottom(containerRect.bottom); + shadowContainerLayer.setRight(containerRect.right); + shadowContainerLayer.setBackgroundColor(resources.getColor( + R.color.open_conversation_animation_background_shadow)); + // Per design request, temporarily clear out the background of the item content + // to not show any ripple effects during animation. + if (!(oldBackground instanceof ColorDrawable)) { + mViewToAnimate.setBackground(null); + } + overlay.add(shadowContainerLayer); + + // Add a expand layer and position it with in the shadow background, so it can + // be properly clipped to the container bounds during the animation. + final View expandLayer = new View(context); + final int elevation = resources.getDimensionPixelSize( + R.dimen.explode_animation_highlight_elevation); + final Rect viewRect = UiUtils.getMeasuredBoundsOnScreen(mViewToAnimate); + // Frame viewRect from screen space to containerRect space. + viewRect.offset(-containerRect.left - decorRect.left, + -containerRect.top - decorRect.top); + // Since the expand layer expands at the same rate above and below, we need to + // compute the expand scale using the bigger of the top/bottom distances. + final int expandLayerHalfHeight = viewRect.height() / 2; + final int topDist = viewRect.top; + final int bottomDist = containerRect.height() - viewRect.bottom; + final float scale = expandLayerHalfHeight == 0 ? 1 : + ((float) Math.max(topDist, bottomDist) + expandLayerHalfHeight) / + expandLayerHalfHeight; + // Position the expand layer initially to exactly match the animated item. + shadowContainerLayer.addView(expandLayer); + expandLayer.setLeft(viewRect.left); + expandLayer.setTop(viewRect.top); + expandLayer.setBottom(viewRect.bottom); + expandLayer.setRight(viewRect.right); + expandLayer.setBackgroundColor(resources.getColor( + R.color.conversation_background)); + ViewCompat.setElevation(expandLayer, elevation); + + // Conditionally stage the snapshot in the overlay. + if (mSnapshot != null) { + shadowContainerLayer.addView(mSnapshot); + mSnapshot.setLeft(viewRect.left); + mSnapshot.setTop(viewRect.top); + mSnapshot.setBottom(viewRect.bottom); + mSnapshot.setRight(viewRect.right); + mSnapshot.setBackground(new BitmapDrawable(resources, mViewBitmap)); + ViewCompat.setElevation(mSnapshot, elevation); + } + + // Apply a scale animation to scale to full screen. + expandLayer.animate().scaleY(scale) + .setDuration(mDuration) + .setInterpolator(UiUtils.EASE_IN_INTERPOLATOR) + .withEndAction(new Runnable() { + @Override + public void run() { + // Clean up the views added to overlay on animation finish. + overlay.remove(shadowContainerLayer); + mViewToAnimate.setBackground(oldBackground); + if (mViewBitmap != null) { + mViewBitmap.recycle(); + } + } + }); + } + } + } + + /** + * Take a snapshot of the given review, return a Bitmap object that's owned by the caller. + */ + static Bitmap snapshotView(final View view) { + // Save the content of the view into a bitmap. + final Bitmap viewBitmap = Bitmap.createBitmap(view.getWidth(), + view.getHeight(), Bitmap.Config.ARGB_8888); + // Strip the view of its background when taking a snapshot so that things like touch + // feedback don't get accidentally snapshotted. + final Drawable viewBackground = view.getBackground(); + ImageUtils.setBackgroundDrawableOnView(view, null); + view.draw(new Canvas(viewBitmap)); + ImageUtils.setBackgroundDrawableOnView(view, viewBackground); + return viewBitmap; + } +} diff --git a/src/com/android/messaging/ui/appsettings/ApnEditorActivity.java b/src/com/android/messaging/ui/appsettings/ApnEditorActivity.java new file mode 100644 index 0000000..b7cb7ae --- /dev/null +++ b/src/com/android/messaging/ui/appsettings/ApnEditorActivity.java @@ -0,0 +1,463 @@ +/* + * 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.ui.appsettings; + +import android.app.AlertDialog; +import android.app.Dialog; +import android.content.ContentValues; +import android.content.Intent; +import android.content.SharedPreferences; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.os.AsyncTask; +import android.os.Bundle; +import android.preference.EditTextPreference; +import android.preference.Preference; +import android.preference.PreferenceFragment; +import android.provider.Telephony; +import android.support.v4.app.NavUtils; +import android.view.KeyEvent; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; + +import com.android.messaging.R; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.sms.ApnDatabase; +import com.android.messaging.sms.BugleApnSettingsLoader; +import com.android.messaging.ui.BugleActionBarActivity; +import com.android.messaging.ui.UIIntents; +import com.android.messaging.util.PhoneUtils; + +public class ApnEditorActivity extends BugleActionBarActivity { + private static final int ERROR_DIALOG_ID = 0; + private static final String ERROR_MESSAGE_KEY = "error_msg"; + private ApnEditorFragment mApnEditorFragment; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + + // Display the fragment as the main content. + mApnEditorFragment = new ApnEditorFragment(); + mApnEditorFragment.setSubId(getIntent().getIntExtra(UIIntents.UI_INTENT_EXTRA_SUB_ID, + ParticipantData.DEFAULT_SELF_SUB_ID)); + getFragmentManager().beginTransaction() + .replace(android.R.id.content, mApnEditorFragment) + .commit(); + } + + @Override + public boolean onOptionsItemSelected(final MenuItem item) { + switch (item.getItemId()) { + case android.R.id.home: + NavUtils.navigateUpFromSameTask(this); + return true; + } + return super.onOptionsItemSelected(item); + } + + @Override + protected Dialog onCreateDialog(int id, Bundle args) { + + if (id == ERROR_DIALOG_ID) { + String msg = args.getString(ERROR_MESSAGE_KEY); + + return new AlertDialog.Builder(this) + .setPositiveButton(android.R.string.ok, null) + .setMessage(msg) + .create(); + } + + return super.onCreateDialog(id); + } + + @Override + public boolean onKeyDown(int keyCode, KeyEvent event) { + switch (keyCode) { + case KeyEvent.KEYCODE_BACK: { + if (mApnEditorFragment.validateAndSave(false)) { + finish(); + } + return true; + } + } + return super.onKeyDown(keyCode, event); + } + + @Override + protected void onPrepareDialog(int id, Dialog dialog, Bundle args) { + super.onPrepareDialog(id, dialog); + + if (id == ERROR_DIALOG_ID) { + final String msg = args.getString(ERROR_MESSAGE_KEY); + + if (msg != null) { + ((AlertDialog) dialog).setMessage(msg); + } + } + } + + public static class ApnEditorFragment extends PreferenceFragment implements + SharedPreferences.OnSharedPreferenceChangeListener { + + private static final String SAVED_POS = "pos"; + + private static final int MENU_DELETE = Menu.FIRST; + private static final int MENU_SAVE = Menu.FIRST + 1; + private static final int MENU_CANCEL = Menu.FIRST + 2; + + private EditTextPreference mMmsProxy; + private EditTextPreference mMmsPort; + private EditTextPreference mName; + private EditTextPreference mMmsc; + private EditTextPreference mMcc; + private EditTextPreference mMnc; + private static String sNotSet; + + private String mCurMnc; + private String mCurMcc; + + private Cursor mCursor; + private boolean mNewApn; + private boolean mFirstTime; + private String mCurrentId; + + private int mSubId; + + /** + * Standard projection for the interesting columns of a normal note. + */ + private static final String[] sProjection = new String[] { + Telephony.Carriers._ID, // 0 + Telephony.Carriers.NAME, // 1 + Telephony.Carriers.MMSC, // 2 + Telephony.Carriers.MCC, // 3 + Telephony.Carriers.MNC, // 4 + Telephony.Carriers.NUMERIC, // 5 + Telephony.Carriers.MMSPROXY, // 6 + Telephony.Carriers.MMSPORT, // 7 + Telephony.Carriers.TYPE, // 8 + }; + + private static final int ID_INDEX = 0; + private static final int NAME_INDEX = 1; + private static final int MMSC_INDEX = 2; + private static final int MCC_INDEX = 3; + private static final int MNC_INDEX = 4; + private static final int NUMERIC_INDEX = 5; + private static final int MMSPROXY_INDEX = 6; + private static final int MMSPORT_INDEX = 7; + private static final int TYPE_INDEX = 8; + + private SQLiteDatabase mDatabase; + + @Override + public void onCreate(Bundle icicle) { + super.onCreate(icicle); + + } + + @Override + public void onActivityCreated(Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + + addPreferencesFromResource(R.xml.apn_editor); + + setHasOptionsMenu(true); + + sNotSet = getResources().getString(R.string.apn_not_set); + mName = (EditTextPreference) findPreference("apn_name"); + mMmsProxy = (EditTextPreference) findPreference("apn_mms_proxy"); + mMmsPort = (EditTextPreference) findPreference("apn_mms_port"); + mMmsc = (EditTextPreference) findPreference("apn_mmsc"); + mMcc = (EditTextPreference) findPreference("apn_mcc"); + mMnc = (EditTextPreference) findPreference("apn_mnc"); + + final Intent intent = getActivity().getIntent(); + + mFirstTime = savedInstanceState == null; + mCurrentId = intent.getStringExtra(UIIntents.UI_INTENT_EXTRA_APN_ROW_ID); + mNewApn = mCurrentId == null; + + mDatabase = ApnDatabase.getApnDatabase().getWritableDatabase(); + + if (mNewApn) { + fillUi(); + } else { + // Do initial query not on the UI thread + new AsyncTask<Void, Void, Void>() { + @Override + protected Void doInBackground(Void... params) { + if (mCurrentId != null) { + String selection = Telephony.Carriers._ID + " =?"; + String[] selectionArgs = new String[]{ mCurrentId }; + mCursor = mDatabase.query(ApnDatabase.APN_TABLE, sProjection, selection, + selectionArgs, null, null, null, null); + } + return null; + } + + @Override + protected void onPostExecute(Void result) { + if (mCursor == null) { + getActivity().finish(); + return; + } + mCursor.moveToFirst(); + + fillUi(); + } + }.execute((Void) null); + } + } + + @Override + public void onDestroy() { + super.onDestroy(); + if (mCursor != null) { + mCursor.close(); + mCursor = null; + } + } + + @Override + public void onResume() { + super.onResume(); + getPreferenceScreen().getSharedPreferences() + .registerOnSharedPreferenceChangeListener(this); + } + + @Override + public void onPause() { + getPreferenceScreen().getSharedPreferences() + .unregisterOnSharedPreferenceChangeListener(this); + super.onPause(); + } + + public void setSubId(final int subId) { + mSubId = subId; + } + + private void fillUi() { + if (mNewApn) { + mMcc.setText(null); + mMnc.setText(null); + String numeric = PhoneUtils.get(mSubId).getSimOperatorNumeric(); + // MCC is first 3 chars and then in 2 - 3 chars of MNC + if (numeric != null && numeric.length() > 4) { + // Country code + String mcc = numeric.substring(0, 3); + // Network code + String mnc = numeric.substring(3); + // Auto populate MNC and MCC for new entries, based on what SIM reports + mMcc.setText(mcc); + mMnc.setText(mnc); + mCurMnc = mnc; + mCurMcc = mcc; + } + mName.setText(null); + mMmsProxy.setText(null); + mMmsPort.setText(null); + mMmsc.setText(null); + } else if (mFirstTime) { + mFirstTime = false; + // Fill in all the values from the db in both text editor and summary + mName.setText(mCursor.getString(NAME_INDEX)); + mMmsProxy.setText(mCursor.getString(MMSPROXY_INDEX)); + mMmsPort.setText(mCursor.getString(MMSPORT_INDEX)); + mMmsc.setText(mCursor.getString(MMSC_INDEX)); + mMcc.setText(mCursor.getString(MCC_INDEX)); + mMnc.setText(mCursor.getString(MNC_INDEX)); + } + + mName.setSummary(checkNull(mName.getText())); + mMmsProxy.setSummary(checkNull(mMmsProxy.getText())); + mMmsPort.setSummary(checkNull(mMmsPort.getText())); + mMmsc.setSummary(checkNull(mMmsc.getText())); + mMcc.setSummary(checkNull(mMcc.getText())); + mMnc.setSummary(checkNull(mMnc.getText())); + } + + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { + super.onCreateOptionsMenu(menu, inflater); + // If it's a new APN, then cancel will delete the new entry in onPause + if (!mNewApn) { + menu.add(0, MENU_DELETE, 0, R.string.menu_delete_apn) + .setIcon(R.drawable.ic_delete_small_dark); + } + menu.add(0, MENU_SAVE, 0, R.string.menu_save_apn) + .setIcon(android.R.drawable.ic_menu_save); + menu.add(0, MENU_CANCEL, 0, R.string.menu_discard_apn_change) + .setIcon(android.R.drawable.ic_menu_close_clear_cancel); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + switch (item.getItemId()) { + case MENU_DELETE: + deleteApn(); + return true; + + case MENU_SAVE: + if (validateAndSave(false)) { + getActivity().finish(); + } + return true; + + case MENU_CANCEL: + getActivity().finish(); + return true; + + case android.R.id.home: + getActivity().onBackPressed(); + return true; + } + return super.onOptionsItemSelected(item); + } + + @Override + public void onSaveInstanceState(Bundle icicle) { + super.onSaveInstanceState(icicle); + if (validateAndSave(true) && mCursor != null) { + icicle.putInt(SAVED_POS, mCursor.getInt(ID_INDEX)); + } + } + + /** + * Check the key fields' validity and save if valid. + * @param force save even if the fields are not valid, if the app is + * being suspended + * @return true if the data was saved + */ + private boolean validateAndSave(boolean force) { + final String name = checkNotSet(mName.getText()); + final String mcc = checkNotSet(mMcc.getText()); + final String mnc = checkNotSet(mMnc.getText()); + + if (getErrorMsg() != null && !force) { + final Bundle bundle = new Bundle(); + bundle.putString(ERROR_MESSAGE_KEY, getErrorMsg()); + getActivity().showDialog(ERROR_DIALOG_ID, bundle); + return false; + } + + // Make database changes not on the UI thread + new AsyncTask<Void, Void, Void>() { + @Override + protected Void doInBackground(Void... params) { + ContentValues values = new ContentValues(); + + // Add a dummy name "Untitled", if the user exits the screen without adding a + // name but entered other information worth keeping. + values.put(Telephony.Carriers.NAME, name.length() < 1 ? + getResources().getString(R.string.untitled_apn) : name); + values.put(Telephony.Carriers.MMSPROXY, checkNotSet(mMmsProxy.getText())); + values.put(Telephony.Carriers.MMSPORT, checkNotSet(mMmsPort.getText())); + values.put(Telephony.Carriers.MMSC, checkNotSet(mMmsc.getText())); + + values.put(Telephony.Carriers.TYPE, BugleApnSettingsLoader.APN_TYPE_MMS); + + values.put(Telephony.Carriers.MCC, mcc); + values.put(Telephony.Carriers.MNC, mnc); + + values.put(Telephony.Carriers.NUMERIC, mcc + mnc); + + if (mCurMnc != null && mCurMcc != null) { + if (mCurMnc.equals(mnc) && mCurMcc.equals(mcc)) { + values.put(Telephony.Carriers.CURRENT, 1); + } + } + + if (mNewApn) { + mDatabase.insert(ApnDatabase.APN_TABLE, null, values); + } else { + // update the APN + String selection = Telephony.Carriers._ID + " =?"; + String[] selectionArgs = new String[]{ mCurrentId }; + int updated = mDatabase.update(ApnDatabase.APN_TABLE, values, + selection, selectionArgs); + } + return null; + } + }.execute((Void) null); + + return true; + } + + private void deleteApn() { + // Make database changes not on the UI thread + new AsyncTask<Void, Void, Void>() { + @Override + protected Void doInBackground(Void... params) { + // delete the APN + String where = Telephony.Carriers._ID + " =?"; + String[] whereArgs = new String[]{ mCurrentId }; + + mDatabase.delete(ApnDatabase.APN_TABLE, where, whereArgs); + return null; + } + }.execute((Void) null); + + getActivity().finish(); + } + + private String checkNull(String value) { + if (value == null || value.length() == 0) { + return sNotSet; + } else { + return value; + } + } + + public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) { + Preference pref = findPreference(key); + if (pref != null) { + pref.setSummary(checkNull(sharedPreferences.getString(key, ""))); + } + } + + private String getErrorMsg() { + String errorMsg = null; + + String name = checkNotSet(mName.getText()); + String mcc = checkNotSet(mMcc.getText()); + String mnc = checkNotSet(mMnc.getText()); + + if (name.length() < 1) { + errorMsg = getString(R.string.error_apn_name_empty); + } else if (mcc.length() != 3) { + errorMsg = getString(R.string.error_mcc_not3); + } else if ((mnc.length() & 0xFFFE) != 2) { + errorMsg = getString(R.string.error_mnc_not23); + } + + return errorMsg; + } + + private String checkNotSet(String value) { + if (value == null || value.equals(sNotSet)) { + return ""; + } else { + return value; + } + } + } +} diff --git a/src/com/android/messaging/ui/appsettings/ApnPreference.java b/src/com/android/messaging/ui/appsettings/ApnPreference.java new file mode 100644 index 0000000..74c6a08 --- /dev/null +++ b/src/com/android/messaging/ui/appsettings/ApnPreference.java @@ -0,0 +1,151 @@ +/* + * 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.ui.appsettings; + +import android.content.Context; +import android.preference.Preference; +import android.util.AttributeSet; +import android.util.Log; +import android.view.View; +import android.view.View.OnClickListener; +import android.view.ViewGroup; +import android.widget.CompoundButton; +import android.widget.RadioButton; +import android.widget.RelativeLayout; +import android.widget.TextView; + +import com.android.messaging.R; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.ui.UIIntents; + +/** + * ApnPreference implements a pref, typically used as a list item, that has a title/summary on + * the left and a radio button on the right. + * + */ +public class ApnPreference extends Preference implements + CompoundButton.OnCheckedChangeListener, OnClickListener { + static final String TAG = "ApnPreference"; + + public ApnPreference(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + } + + public ApnPreference(Context context, AttributeSet attrs) { + this(context, attrs, R.attr.apnPreferenceStyle); + } + + public ApnPreference(Context context) { + this(context, null); + } + + private static String mSelectedKey = null; + private static CompoundButton mCurrentChecked = null; + private boolean mProtectFromCheckedChange = false; + private boolean mSelectable = true; + private int mSubId = ParticipantData.DEFAULT_SELF_SUB_ID; + + @Override + public View getView(View convertView, ViewGroup parent) { + View view = super.getView(convertView, parent); + + View widget = view.findViewById(R.id.apn_radiobutton); + if ((widget != null) && widget instanceof RadioButton) { + RadioButton rb = (RadioButton) widget; + if (mSelectable) { + rb.setOnCheckedChangeListener(this); + + boolean isChecked = getKey().equals(mSelectedKey); + if (isChecked) { + mCurrentChecked = rb; + mSelectedKey = getKey(); + } + + mProtectFromCheckedChange = true; + rb.setChecked(isChecked); + mProtectFromCheckedChange = false; + } else { + rb.setVisibility(View.GONE); + } + setApnRadioButtonContentDescription(rb); + } + + View textLayout = view.findViewById(R.id.text_layout); + if ((textLayout != null) && textLayout instanceof RelativeLayout) { + textLayout.setOnClickListener(this); + } + + return view; + } + + public void setApnRadioButtonContentDescription(final CompoundButton buttonView) { + final View widget = (View) buttonView.getParent(); + final TextView tv = (TextView) widget.findViewById(android.R.id.title); + final String apnTitle = tv.getText().toString(); + buttonView.setContentDescription(apnTitle); + } + + public boolean isChecked() { + return getKey().equals(mSelectedKey); + } + + public void setChecked() { + mSelectedKey = getKey(); + } + + public void setSubId(final int subId) { + mSubId = subId; + } + + public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { + Log.i(TAG, "ID: " + getKey() + " :" + isChecked); + if (mProtectFromCheckedChange) { + return; + } + + if (isChecked) { + if (mCurrentChecked != null) { + mCurrentChecked.setChecked(false); + } + mCurrentChecked = buttonView; + mSelectedKey = getKey(); + callChangeListener(mSelectedKey); + } else { + mCurrentChecked = null; + mSelectedKey = null; + } + setApnRadioButtonContentDescription(buttonView); + } + + public void onClick(android.view.View v) { + if ((v != null) && (R.id.text_layout == v.getId())) { + Context context = getContext(); + if (context != null) { + context.startActivity( + UIIntents.get().getApnEditorIntent(context, getKey(), mSubId)); + } + } + } + + public void setSelectable(boolean selectable) { + mSelectable = selectable; + } + + public boolean getSelectable() { + return mSelectable; + } +} diff --git a/src/com/android/messaging/ui/appsettings/ApnSettingsActivity.java b/src/com/android/messaging/ui/appsettings/ApnSettingsActivity.java new file mode 100644 index 0000000..28dfc2a --- /dev/null +++ b/src/com/android/messaging/ui/appsettings/ApnSettingsActivity.java @@ -0,0 +1,406 @@ +/* + * 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.ui.appsettings; + +import android.app.Activity; +import android.app.Dialog; +import android.app.ProgressDialog; +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.os.AsyncTask; +import android.os.Bundle; +import android.os.Handler; +import android.os.HandlerThread; +import android.os.Looper; +import android.os.Message; +import android.os.UserManager; +import android.preference.Preference; +import android.preference.PreferenceFragment; +import android.preference.PreferenceGroup; +import android.preference.PreferenceScreen; +import android.provider.Telephony; +import android.support.v4.app.NavUtils; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.widget.ListView; +import android.widget.TextView; +import android.widget.Toast; + +import com.android.messaging.R; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.sms.ApnDatabase; +import com.android.messaging.sms.BugleApnSettingsLoader; +import com.android.messaging.ui.BugleActionBarActivity; +import com.android.messaging.ui.UIIntents; +import com.android.messaging.util.OsUtil; +import com.android.messaging.util.PhoneUtils; + +public class ApnSettingsActivity extends BugleActionBarActivity { + private static final int DIALOG_RESTORE_DEFAULTAPN = 1001; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + + // Display the fragment as the main content. + final ApnSettingsFragment fragment = new ApnSettingsFragment(); + fragment.setSubId(getIntent().getIntExtra(UIIntents.UI_INTENT_EXTRA_SUB_ID, + ParticipantData.DEFAULT_SELF_SUB_ID)); + getFragmentManager().beginTransaction() + .replace(android.R.id.content, fragment) + .commit(); + } + + @Override + public boolean onOptionsItemSelected(final MenuItem item) { + switch (item.getItemId()) { + case android.R.id.home: + NavUtils.navigateUpFromSameTask(this); + return true; + } + return super.onOptionsItemSelected(item); + } + + @Override + protected Dialog onCreateDialog(int id) { + if (id == DIALOG_RESTORE_DEFAULTAPN) { + ProgressDialog dialog = new ProgressDialog(this); + dialog.setMessage(getResources().getString(R.string.restore_default_apn)); + dialog.setCancelable(false); + return dialog; + } + return null; + } + + public static class ApnSettingsFragment extends PreferenceFragment implements + Preference.OnPreferenceChangeListener { + public static final String EXTRA_POSITION = "position"; + + public static final String APN_ID = "apn_id"; + + private static final String[] APN_PROJECTION = { + Telephony.Carriers._ID, // 0 + Telephony.Carriers.NAME, // 1 + Telephony.Carriers.APN, // 2 + Telephony.Carriers.TYPE // 3 + }; + private static final int ID_INDEX = 0; + private static final int NAME_INDEX = 1; + private static final int APN_INDEX = 2; + private static final int TYPES_INDEX = 3; + + private static final int MENU_NEW = Menu.FIRST; + private static final int MENU_RESTORE = Menu.FIRST + 1; + + private static final int EVENT_RESTORE_DEFAULTAPN_START = 1; + private static final int EVENT_RESTORE_DEFAULTAPN_COMPLETE = 2; + + private static boolean mRestoreDefaultApnMode; + + private RestoreApnUiHandler mRestoreApnUiHandler; + private RestoreApnProcessHandler mRestoreApnProcessHandler; + private HandlerThread mRestoreDefaultApnThread; + + private String mSelectedKey; + + private static final ContentValues sCurrentNullMap; + private static final ContentValues sCurrentSetMap; + + private UserManager mUm; + + private boolean mUnavailable; + private int mSubId; + + static { + sCurrentNullMap = new ContentValues(1); + sCurrentNullMap.putNull(Telephony.Carriers.CURRENT); + + sCurrentSetMap = new ContentValues(1); + sCurrentSetMap.put(Telephony.Carriers.CURRENT, "2"); // 2 for user-selected APN, + // 1 for Bugle-selected APN + } + + private SQLiteDatabase mDatabase; + + public void setSubId(final int subId) { + mSubId = subId; + } + + @Override + public void onCreate(Bundle icicle) { + super.onCreate(icicle); + + mDatabase = ApnDatabase.getApnDatabase().getWritableDatabase(); + + if (OsUtil.isAtLeastL()) { + mUm = (UserManager) getActivity().getSystemService(Context.USER_SERVICE); + if (!mUm.hasUserRestriction(UserManager.DISALLOW_CONFIG_MOBILE_NETWORKS)) { + setHasOptionsMenu(true); + } + } else { + setHasOptionsMenu(true); + } + } + + @Override + public void onActivityCreated(Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + + final ListView lv = (ListView) getView().findViewById(android.R.id.list); + TextView empty = (TextView) getView().findViewById(android.R.id.empty); + if (empty != null) { + empty.setText(R.string.apn_settings_not_available); + lv.setEmptyView(empty); + } + + if (OsUtil.isAtLeastL() && + mUm.hasUserRestriction(UserManager.DISALLOW_CONFIG_MOBILE_NETWORKS)) { + mUnavailable = true; + setPreferenceScreen(getPreferenceManager().createPreferenceScreen(getActivity())); + return; + } + + addPreferencesFromResource(R.xml.apn_settings); + + lv.setItemsCanFocus(true); + } + + @Override + public void onResume() { + super.onResume(); + + if (mUnavailable) { + return; + } + + if (!mRestoreDefaultApnMode) { + fillList(); + } + } + + @Override + public void onPause() { + super.onPause(); + + if (mUnavailable) { + return; + } + } + + @Override + public void onDestroy() { + super.onDestroy(); + + if (mRestoreDefaultApnThread != null) { + mRestoreDefaultApnThread.quit(); + } + } + + private void fillList() { + final String mccMnc = PhoneUtils.getMccMncString(PhoneUtils.get(mSubId).getMccMnc()); + + new AsyncTask<Void, Void, Cursor>() { + @Override + protected Cursor doInBackground(Void... params) { + String selection = Telephony.Carriers.NUMERIC + " =?"; + String[] selectionArgs = new String[]{ mccMnc }; + final Cursor cursor = mDatabase.query(ApnDatabase.APN_TABLE, APN_PROJECTION, + selection, selectionArgs, null, null, null, null); + return cursor; + } + + @Override + protected void onPostExecute(Cursor cursor) { + if (cursor != null) { + try { + PreferenceGroup apnList = (PreferenceGroup) + findPreference(getString(R.string.apn_list_pref_key)); + apnList.removeAll(); + + mSelectedKey = BugleApnSettingsLoader.getFirstTryApn(mDatabase, mccMnc); + while (cursor.moveToNext()) { + String name = cursor.getString(NAME_INDEX); + String apn = cursor.getString(APN_INDEX); + String key = cursor.getString(ID_INDEX); + String type = cursor.getString(TYPES_INDEX); + + if (BugleApnSettingsLoader.isValidApnType(type, + BugleApnSettingsLoader.APN_TYPE_MMS)) { + ApnPreference pref = new ApnPreference(getActivity()); + pref.setKey(key); + pref.setTitle(name); + pref.setSummary(apn); + pref.setPersistent(false); + pref.setOnPreferenceChangeListener(ApnSettingsFragment.this); + pref.setSelectable(true); + + // Turn on the radio button for the currently selected APN. If + // there is no selected APN, don't select an APN. + if ((mSelectedKey != null && mSelectedKey.equals(key))) { + pref.setChecked(); + } + apnList.addPreference(pref); + } + } + } finally { + cursor.close(); + } + } + } + }.execute((Void) null); + } + + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { + if (!mUnavailable) { + menu.add(0, MENU_NEW, 0, + getResources().getString(R.string.menu_new_apn)) + .setIcon(R.drawable.ic_add_gray) + .setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM); + menu.add(0, MENU_RESTORE, 0, + getResources().getString(R.string.menu_restore_default_apn)) + .setIcon(android.R.drawable.ic_menu_upload); + } + + super.onCreateOptionsMenu(menu, inflater); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + switch (item.getItemId()) { + case MENU_NEW: + addNewApn(); + return true; + + case MENU_RESTORE: + restoreDefaultApn(); + return true; + } + return super.onOptionsItemSelected(item); + } + + private void addNewApn() { + startActivity(UIIntents.get().getApnEditorIntent(getActivity(), null, mSubId)); + } + + @Override + public boolean onPreferenceTreeClick(PreferenceScreen preferenceScreen, + Preference preference) { + startActivity( + UIIntents.get().getApnEditorIntent(getActivity(), preference.getKey(), mSubId)); + return true; + } + + @Override + public boolean onPreferenceChange(Preference preference, Object newValue) { + if (newValue instanceof String) { + setSelectedApnKey((String) newValue); + } + + return true; + } + + // current=2 means user selected APN + private static final String UPDATE_SELECTION = Telephony.Carriers.CURRENT + " =?"; + private static final String[] UPDATE_SELECTION_ARGS = new String[] { "2" }; + private void setSelectedApnKey(final String key) { + mSelectedKey = key; + + // Make database changes not on the UI thread + new AsyncTask<Void, Void, Void>() { + @Override + protected Void doInBackground(Void... params) { + // null out the previous "current=2" APN + mDatabase.update(ApnDatabase.APN_TABLE, sCurrentNullMap, + UPDATE_SELECTION, UPDATE_SELECTION_ARGS); + + // set the new "current" APN (2) + String selection = Telephony.Carriers._ID + " =?"; + String[] selectionArgs = new String[]{ key }; + + mDatabase.update(ApnDatabase.APN_TABLE, sCurrentSetMap, + selection, selectionArgs); + return null; + } + }.execute((Void) null); + } + + private boolean restoreDefaultApn() { + getActivity().showDialog(DIALOG_RESTORE_DEFAULTAPN); + mRestoreDefaultApnMode = true; + + if (mRestoreApnUiHandler == null) { + mRestoreApnUiHandler = new RestoreApnUiHandler(); + } + + if (mRestoreApnProcessHandler == null || + mRestoreDefaultApnThread == null) { + mRestoreDefaultApnThread = new HandlerThread( + "Restore default APN Handler: Process Thread"); + mRestoreDefaultApnThread.start(); + mRestoreApnProcessHandler = new RestoreApnProcessHandler( + mRestoreDefaultApnThread.getLooper(), mRestoreApnUiHandler); + } + + mRestoreApnProcessHandler.sendEmptyMessage(EVENT_RESTORE_DEFAULTAPN_START); + return true; + } + + private class RestoreApnUiHandler extends Handler { + @Override + public void handleMessage(Message msg) { + switch (msg.what) { + case EVENT_RESTORE_DEFAULTAPN_COMPLETE: + fillList(); + getPreferenceScreen().setEnabled(true); + mRestoreDefaultApnMode = false; + final Activity activity = getActivity(); + activity.dismissDialog(DIALOG_RESTORE_DEFAULTAPN); + Toast.makeText(activity, getResources().getString( + R.string.restore_default_apn_completed), Toast.LENGTH_LONG) + .show(); + break; + } + } + } + + private class RestoreApnProcessHandler extends Handler { + private Handler mCachedRestoreApnUiHandler; + + public RestoreApnProcessHandler(Looper looper, Handler restoreApnUiHandler) { + super(looper); + this.mCachedRestoreApnUiHandler = restoreApnUiHandler; + } + + @Override + public void handleMessage(Message msg) { + switch (msg.what) { + case EVENT_RESTORE_DEFAULTAPN_START: + ApnDatabase.forceBuildAndLoadApnTables(); + mCachedRestoreApnUiHandler.sendEmptyMessage( + EVENT_RESTORE_DEFAULTAPN_COMPLETE); + break; + } + } + } + } +} diff --git a/src/com/android/messaging/ui/appsettings/ApplicationSettingsActivity.java b/src/com/android/messaging/ui/appsettings/ApplicationSettingsActivity.java new file mode 100644 index 0000000..906009f --- /dev/null +++ b/src/com/android/messaging/ui/appsettings/ApplicationSettingsActivity.java @@ -0,0 +1,262 @@ +/* + * 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.ui.appsettings; + +import android.app.FragmentTransaction; +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.SharedPreferences.OnSharedPreferenceChangeListener; +import android.media.Ringtone; +import android.media.RingtoneManager; +import android.net.Uri; +import android.os.Bundle; +import android.preference.Preference; +import android.preference.PreferenceFragment; +import android.preference.PreferenceScreen; +import android.preference.RingtonePreference; +import android.preference.TwoStatePreference; +import android.provider.Settings; +import android.support.v4.app.NavUtils; +import android.text.TextUtils; +import android.view.Menu; +import android.view.MenuItem; + +import com.android.messaging.R; +import com.android.messaging.ui.BugleActionBarActivity; +import com.android.messaging.ui.LicenseActivity; +import com.android.messaging.ui.UIIntents; +import com.android.messaging.util.BuglePrefs; +import com.android.messaging.util.DebugUtils; +import com.android.messaging.util.OsUtil; +import com.android.messaging.util.PhoneUtils; + +public class ApplicationSettingsActivity extends BugleActionBarActivity { + @Override + protected void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + final boolean topLevel = getIntent().getBooleanExtra( + UIIntents.UI_INTENT_EXTRA_TOP_LEVEL_SETTINGS, false); + if (topLevel) { + getSupportActionBar().setTitle(getString(R.string.settings_activity_title)); + } + + FragmentTransaction ft = getFragmentManager().beginTransaction(); + ft.replace(android.R.id.content, new ApplicationSettingsFragment()); + ft.commit(); + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + if (super.onCreateOptionsMenu(menu)) { + return true; + } + getMenuInflater().inflate(R.menu.settings_menu, menu); + return true; + } + + @Override + public boolean onOptionsItemSelected(final MenuItem item) { + switch (item.getItemId()) { + case android.R.id.home: + NavUtils.navigateUpFromSameTask(this); + return true; + case R.id.action_license: + final Intent intent = new Intent(this, LicenseActivity.class); + startActivity(intent); + return true; + } + return super.onOptionsItemSelected(item); + } + + public static class ApplicationSettingsFragment extends PreferenceFragment implements + OnSharedPreferenceChangeListener { + + private String mNotificationsEnabledPreferenceKey; + private TwoStatePreference mNotificationsEnabledPreference; + private String mRingtonePreferenceKey; + private RingtonePreference mRingtonePreference; + private Preference mVibratePreference; + private String mSmsDisabledPrefKey; + private Preference mSmsDisabledPreference; + private String mSmsEnabledPrefKey; + private Preference mSmsEnabledPreference; + private boolean mIsSmsPreferenceClicked; + + public ApplicationSettingsFragment() { + // Required empty constructor + } + + @Override + public void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + getPreferenceManager().setSharedPreferencesName(BuglePrefs.SHARED_PREFERENCES_NAME); + addPreferencesFromResource(R.xml.preferences_application); + + mNotificationsEnabledPreferenceKey = + getString(R.string.notifications_enabled_pref_key); + mNotificationsEnabledPreference = (TwoStatePreference) findPreference( + mNotificationsEnabledPreferenceKey); + mRingtonePreferenceKey = getString(R.string.notification_sound_pref_key); + mRingtonePreference = (RingtonePreference) findPreference(mRingtonePreferenceKey); + mVibratePreference = findPreference( + getString(R.string.notification_vibration_pref_key)); + mSmsDisabledPrefKey = getString(R.string.sms_disabled_pref_key); + mSmsDisabledPreference = findPreference(mSmsDisabledPrefKey); + mSmsEnabledPrefKey = getString(R.string.sms_enabled_pref_key); + mSmsEnabledPreference = findPreference(mSmsEnabledPrefKey); + mIsSmsPreferenceClicked = false; + + final SharedPreferences prefs = getPreferenceScreen().getSharedPreferences(); + updateSoundSummary(prefs); + + if (!DebugUtils.isDebugEnabled()) { + final Preference debugCategory = findPreference(getString( + R.string.debug_pref_key)); + getPreferenceScreen().removePreference(debugCategory); + } + + final PreferenceScreen advancedScreen = (PreferenceScreen) findPreference( + getString(R.string.advanced_pref_key)); + final boolean topLevel = getActivity().getIntent().getBooleanExtra( + UIIntents.UI_INTENT_EXTRA_TOP_LEVEL_SETTINGS, false); + if (topLevel) { + advancedScreen.setIntent(UIIntents.get() + .getAdvancedSettingsIntent(getPreferenceScreen().getContext())); + } else { + // Hide the Advanced settings screen if this is not top-level; these are shown at + // the parent SettingsActivity. + getPreferenceScreen().removePreference(advancedScreen); + } + } + + @Override + public boolean onPreferenceTreeClick (PreferenceScreen preferenceScreen, + Preference preference) { + if (preference.getKey() == mSmsDisabledPrefKey || + preference.getKey() == mSmsEnabledPrefKey) { + mIsSmsPreferenceClicked = true; + } + return super.onPreferenceTreeClick(preferenceScreen, preference); + } + + private void updateSoundSummary(final SharedPreferences sharedPreferences) { + // The silent ringtone just returns an empty string + String ringtoneName = mRingtonePreference.getContext().getString( + R.string.silent_ringtone); + + String ringtoneString = sharedPreferences.getString(mRingtonePreferenceKey, null); + + // Bootstrap the default setting in the preferences so that we have a valid selection + // in the dialog the first time that the user opens it. + if (ringtoneString == null) { + ringtoneString = Settings.System.DEFAULT_NOTIFICATION_URI.toString(); + final SharedPreferences.Editor editor = sharedPreferences.edit(); + editor.putString(mRingtonePreferenceKey, ringtoneString); + editor.apply(); + } + + if (!TextUtils.isEmpty(ringtoneString)) { + final Uri ringtoneUri = Uri.parse(ringtoneString); + final Ringtone tone = RingtoneManager.getRingtone(mRingtonePreference.getContext(), + ringtoneUri); + + if (tone != null) { + ringtoneName = tone.getTitle(mRingtonePreference.getContext()); + } + } + + mRingtonePreference.setSummary(ringtoneName); + } + + private void updateSmsEnabledPreferences() { + if (!OsUtil.isAtLeastKLP()) { + getPreferenceScreen().removePreference(mSmsDisabledPreference); + getPreferenceScreen().removePreference(mSmsEnabledPreference); + } else { + final String defaultSmsAppLabel = getString(R.string.default_sms_app, + PhoneUtils.getDefault().getDefaultSmsAppLabel()); + boolean isSmsEnabledBeforeState; + boolean isSmsEnabledCurrentState; + if (PhoneUtils.getDefault().isDefaultSmsApp()) { + if (getPreferenceScreen().findPreference(mSmsEnabledPrefKey) == null) { + getPreferenceScreen().addPreference(mSmsEnabledPreference); + isSmsEnabledBeforeState = false; + } else { + isSmsEnabledBeforeState = true; + } + isSmsEnabledCurrentState = true; + getPreferenceScreen().removePreference(mSmsDisabledPreference); + mSmsEnabledPreference.setSummary(defaultSmsAppLabel); + } else { + if (getPreferenceScreen().findPreference(mSmsDisabledPrefKey) == null) { + getPreferenceScreen().addPreference(mSmsDisabledPreference); + isSmsEnabledBeforeState = true; + } else { + isSmsEnabledBeforeState = false; + } + isSmsEnabledCurrentState = false; + getPreferenceScreen().removePreference(mSmsEnabledPreference); + mSmsDisabledPreference.setSummary(defaultSmsAppLabel); + } + updateNotificationsPreferences(); + } + mIsSmsPreferenceClicked = false; + } + + private void updateNotificationsPreferences() { + final boolean canNotify = !OsUtil.isAtLeastKLP() + || PhoneUtils.getDefault().isDefaultSmsApp(); + mNotificationsEnabledPreference.setEnabled(canNotify); + } + + @Override + public void onStart() { + super.onStart(); + // We do this on start rather than on resume because the sound picker is in a + // separate activity. + getPreferenceScreen().getSharedPreferences() + .registerOnSharedPreferenceChangeListener(this); + } + + @Override + public void onResume() { + super.onResume(); + updateSmsEnabledPreferences(); + updateNotificationsPreferences(); + } + + @Override + public void onSharedPreferenceChanged(final SharedPreferences sharedPreferences, + final String key) { + if (key.equals(mNotificationsEnabledPreferenceKey)) { + updateNotificationsPreferences(); + } else if (key.equals(mRingtonePreferenceKey)) { + updateSoundSummary(sharedPreferences); + } + } + + @Override + public void onStop() { + super.onStop(); + getPreferenceScreen().getSharedPreferences() + .unregisterOnSharedPreferenceChangeListener(this); + } + } +} diff --git a/src/com/android/messaging/ui/appsettings/GroupMmsSettingDialog.java b/src/com/android/messaging/ui/appsettings/GroupMmsSettingDialog.java new file mode 100644 index 0000000..739d2dc --- /dev/null +++ b/src/com/android/messaging/ui/appsettings/GroupMmsSettingDialog.java @@ -0,0 +1,92 @@ +/* + * 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.ui.appsettings; + +import android.app.AlertDialog; +import android.content.Context; +import android.view.LayoutInflater; +import android.view.View; +import android.view.View.OnClickListener; +import android.widget.RadioButton; + +import com.android.messaging.R; +import com.android.messaging.util.Assert; +import com.android.messaging.util.BuglePrefs; + +/** + * Displays an on/off switch for group MMS setting for a given subscription. + */ +public class GroupMmsSettingDialog { + private final Context mContext; + private final int mSubId; + private AlertDialog mDialog; + + /** + * Shows a new group MMS setting dialog. + */ + public static void showDialog(final Context context, final int subId) { + new GroupMmsSettingDialog(context, subId).show(); + } + + private GroupMmsSettingDialog(final Context context, final int subId) { + mContext = context; + mSubId = subId; + } + + private void show() { + Assert.isNull(mDialog); + mDialog = new AlertDialog.Builder(mContext) + .setView(createView()) + .setTitle(R.string.group_mms_pref_title) + .setNegativeButton(android.R.string.cancel, null) + .show(); + } + + private void changeGroupMmsSettings(final boolean enable) { + Assert.notNull(mDialog); + BuglePrefs.getSubscriptionPrefs(mSubId).putBoolean( + mContext.getString(R.string.group_mms_pref_key), enable); + mDialog.dismiss(); + } + + private View createView() { + final LayoutInflater inflater = (LayoutInflater) mContext + .getSystemService(Context.LAYOUT_INFLATER_SERVICE); + final View rootView = inflater.inflate(R.layout.group_mms_setting_dialog, null, false); + final RadioButton disableButton = (RadioButton) + rootView.findViewById(R.id.disable_group_mms_button); + final RadioButton enableButton = (RadioButton) + rootView.findViewById(R.id.enable_group_mms_button); + disableButton.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View view) { + changeGroupMmsSettings(false); + } + }); + enableButton.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View view) { + changeGroupMmsSettings(true); + } + }); + final boolean mmsEnabled = BuglePrefs.getSubscriptionPrefs(mSubId).getBoolean( + mContext.getString(R.string.group_mms_pref_key), + mContext.getResources().getBoolean(R.bool.group_mms_pref_default)); + enableButton.setChecked(mmsEnabled); + disableButton.setChecked(!mmsEnabled); + return rootView; + } +} diff --git a/src/com/android/messaging/ui/appsettings/PerSubscriptionSettingsActivity.java b/src/com/android/messaging/ui/appsettings/PerSubscriptionSettingsActivity.java new file mode 100644 index 0000000..e02823f --- /dev/null +++ b/src/com/android/messaging/ui/appsettings/PerSubscriptionSettingsActivity.java @@ -0,0 +1,246 @@ +/* + * 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.ui.appsettings; + +import android.app.FragmentTransaction; +import android.content.ActivityNotFoundException; +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.SharedPreferences.OnSharedPreferenceChangeListener; +import android.content.pm.PackageManager; +import android.os.Bundle; +import android.preference.Preference; +import android.preference.Preference.OnPreferenceClickListener; +import android.preference.PreferenceCategory; +import android.preference.PreferenceFragment; +import android.preference.PreferenceScreen; +import android.support.v4.app.NavUtils; +import android.text.TextUtils; +import android.view.MenuItem; + +import com.android.messaging.Factory; +import com.android.messaging.R; +import com.android.messaging.datamodel.ParticipantRefresh; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.sms.ApnDatabase; +import com.android.messaging.sms.MmsConfig; +import com.android.messaging.sms.MmsUtils; +import com.android.messaging.ui.BugleActionBarActivity; +import com.android.messaging.ui.UIIntents; +import com.android.messaging.util.Assert; +import com.android.messaging.util.BuglePrefs; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.PhoneUtils; + +public class PerSubscriptionSettingsActivity extends BugleActionBarActivity { + @Override + protected void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + final String title = getIntent().getStringExtra( + UIIntents.UI_INTENT_EXTRA_PER_SUBSCRIPTION_SETTING_TITLE); + if (!TextUtils.isEmpty(title)) { + getSupportActionBar().setTitle(title); + } else { + // This will fall back to the default title, i.e. "Messaging settings," so No-op. + } + + final FragmentTransaction ft = getFragmentManager().beginTransaction(); + final PerSubscriptionSettingsFragment fragment = new PerSubscriptionSettingsFragment(); + ft.replace(android.R.id.content, fragment); + ft.commit(); + } + + @Override + public boolean onOptionsItemSelected(final MenuItem item) { + switch (item.getItemId()) { + case android.R.id.home: + NavUtils.navigateUpFromSameTask(this); + return true; + } + return super.onOptionsItemSelected(item); + } + + public static class PerSubscriptionSettingsFragment extends PreferenceFragment + implements OnSharedPreferenceChangeListener { + private PhoneNumberPreference mPhoneNumberPreference; + private Preference mGroupMmsPreference; + private String mGroupMmsPrefKey; + private String mPhoneNumberKey; + private int mSubId; + + public PerSubscriptionSettingsFragment() { + // Required empty constructor + } + + @Override + public void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + // Get sub id from launch intent + final Intent intent = getActivity().getIntent(); + Assert.notNull(intent); + mSubId = (intent != null) ? intent.getIntExtra(UIIntents.UI_INTENT_EXTRA_SUB_ID, + ParticipantData.DEFAULT_SELF_SUB_ID) : ParticipantData.DEFAULT_SELF_SUB_ID; + + final BuglePrefs subPrefs = Factory.get().getSubscriptionPrefs(mSubId); + getPreferenceManager().setSharedPreferencesName(subPrefs.getSharedPreferencesName()); + addPreferencesFromResource(R.xml.preferences_per_subscription); + + mPhoneNumberKey = getString(R.string.mms_phone_number_pref_key); + mPhoneNumberPreference = (PhoneNumberPreference) findPreference(mPhoneNumberKey); + final PreferenceCategory advancedCategory = (PreferenceCategory) + findPreference(getString(R.string.advanced_category_pref_key)); + final PreferenceCategory mmsCategory = (PreferenceCategory) + findPreference(getString(R.string.mms_messaging_category_pref_key)); + + mPhoneNumberPreference.setDefaultPhoneNumber( + PhoneUtils.get(mSubId).getCanonicalForSelf(false/*allowOverride*/), mSubId); + + mGroupMmsPrefKey = getString(R.string.group_mms_pref_key); + mGroupMmsPreference = findPreference(mGroupMmsPrefKey); + if (!MmsConfig.get(mSubId).getGroupMmsEnabled()) { + // Always show group messaging setting even if the SIM has no number + // If broadcast sms is selected, the SIM number is not needed + // If group mms is selected, the phone number dialog will popup when message + // is being sent, making sure we will have a self number for group mms. + mmsCategory.removePreference(mGroupMmsPreference); + } else { + mGroupMmsPreference.setOnPreferenceClickListener(new OnPreferenceClickListener() { + @Override + public boolean onPreferenceClick(Preference pref) { + GroupMmsSettingDialog.showDialog(getActivity(), mSubId); + return true; + } + }); + updateGroupMmsPrefSummary(); + } + + if (!MmsConfig.get(mSubId).getSMSDeliveryReportsEnabled()) { + final Preference deliveryReportsPref = findPreference( + getString(R.string.delivery_reports_pref_key)); + mmsCategory.removePreference(deliveryReportsPref); + } + final Preference wirelessAlertPref = findPreference(getString( + R.string.wireless_alerts_key)); + if (!isCellBroadcastAppLinkEnabled()) { + advancedCategory.removePreference(wirelessAlertPref); + } else { + wirelessAlertPref.setOnPreferenceClickListener( + new Preference.OnPreferenceClickListener() { + @Override + public boolean onPreferenceClick(final Preference preference) { + try { + startActivity(UIIntents.get().getWirelessAlertsIntent()); + } catch (final ActivityNotFoundException e) { + // Handle so we shouldn't crash if the wireless alerts + // implementation is broken. + LogUtil.e(LogUtil.BUGLE_TAG, + "Failed to launch wireless alerts activity", e); + } + return true; + } + }); + } + + // Access Point Names (APNs) + final Preference apnsPref = findPreference(getString(R.string.sms_apns_key)); + + if (MmsUtils.useSystemApnTable() && !ApnDatabase.doesDatabaseExist()) { + // Don't remove the ability to edit the local APN prefs if this device lets us + // access the system APN, but we can't find the MCC/MNC in the APN table and we + // created the local APN table in case the MCC/MNC was in there. In other words, + // if the local APN table exists, let the user edit it. + advancedCategory.removePreference(apnsPref); + } else { + final PreferenceScreen apnsScreen = (PreferenceScreen) findPreference( + getString(R.string.sms_apns_key)); + apnsScreen.setIntent(UIIntents.get() + .getApnSettingsIntent(getPreferenceScreen().getContext(), mSubId)); + } + + // We want to disable preferences if we are not the default app, but we do all of the + // above first so that the user sees the correct information on the screen + if (!PhoneUtils.getDefault().isDefaultSmsApp()) { + mGroupMmsPreference.setEnabled(false); + final Preference autoRetrieveMmsPreference = + findPreference(getString(R.string.auto_retrieve_mms_pref_key)); + autoRetrieveMmsPreference.setEnabled(false); + final Preference deliveryReportsPreference = + findPreference(getString(R.string.delivery_reports_pref_key)); + deliveryReportsPreference.setEnabled(false); + } + } + + private boolean isCellBroadcastAppLinkEnabled() { + if (!MmsConfig.get(mSubId).getShowCellBroadcast()) { + return false; + } + try { + final PackageManager pm = getActivity().getPackageManager(); + return pm.getApplicationEnabledSetting(UIIntents.CMAS_COMPONENT) + != PackageManager.COMPONENT_ENABLED_STATE_DISABLED; + } catch (final IllegalArgumentException ignored) { + // CMAS app not installed. + } + return false; + } + + private void updateGroupMmsPrefSummary() { + final boolean groupMmsEnabled = getPreferenceScreen().getSharedPreferences().getBoolean( + mGroupMmsPrefKey, getResources().getBoolean(R.bool.group_mms_pref_default)); + mGroupMmsPreference.setSummary(groupMmsEnabled ? + R.string.enable_group_mms : R.string.disable_group_mms); + } + + @Override + public void onResume() { + super.onResume(); + getPreferenceScreen().getSharedPreferences() + .registerOnSharedPreferenceChangeListener(this); + } + + @Override + public void onSharedPreferenceChanged(final SharedPreferences sharedPreferences, + final String key) { + if (key.equals(mGroupMmsPrefKey)) { + updateGroupMmsPrefSummary(); + } else if (key.equals(mPhoneNumberKey)) { + // Save the changed phone number in preferences specific to the sub id + final String newPhoneNumber = mPhoneNumberPreference.getText(); + final BuglePrefs subPrefs = BuglePrefs.getSubscriptionPrefs(mSubId); + if (TextUtils.isEmpty(newPhoneNumber)) { + subPrefs.remove(mPhoneNumberKey); + } else { + subPrefs.putString(getString(R.string.mms_phone_number_pref_key), + newPhoneNumber); + } + // Update the self participants so the new phone number will be reflected + // everywhere in the UI. + ParticipantRefresh.refreshSelfParticipants(); + } + } + + @Override + public void onPause() { + super.onPause(); + getPreferenceScreen().getSharedPreferences() + .unregisterOnSharedPreferenceChangeListener(this); + } + } +} diff --git a/src/com/android/messaging/ui/appsettings/PhoneNumberPreference.java b/src/com/android/messaging/ui/appsettings/PhoneNumberPreference.java new file mode 100644 index 0000000..0c9c018 --- /dev/null +++ b/src/com/android/messaging/ui/appsettings/PhoneNumberPreference.java @@ -0,0 +1,116 @@ +/* + * 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.ui.appsettings; + +import android.content.Context; +import android.preference.EditTextPreference; +import android.support.v4.text.BidiFormatter; +import android.support.v4.text.TextDirectionHeuristicsCompat; +import android.text.InputType; +import android.text.TextUtils; +import android.util.AttributeSet; +import android.view.View; + +import com.android.messaging.R; +import com.android.messaging.util.PhoneUtils; + +/** + * Preference that displays a phone number and allows editing via a dialog. + * <p> + * A default number can be assigned, which is shown in the preference view and + * used to populate the dialog editor when the preference value is not set. If + * the user sets the preference to a number equivalent to the default, the + * underlying preference is cleared. + */ +public class PhoneNumberPreference extends EditTextPreference { + + private String mDefaultPhoneNumber; + private int mSubId; + + public PhoneNumberPreference(final Context context, final AttributeSet attrs) { + super(context, attrs); + mDefaultPhoneNumber = ""; + } + + public void setDefaultPhoneNumber(final String phoneNumber, final int subscriptionId) { + mDefaultPhoneNumber = phoneNumber; + mSubId = subscriptionId; + } + + @Override + protected void onBindView(final View view) { + // Show the preference value if it's set, or the default number if not. + // If we don't have a default, fall back to a static string (e.g. Unknown). + String value = getText(); + if (TextUtils.isEmpty(value)) { + value = mDefaultPhoneNumber; + } + final String displayValue = (!TextUtils.isEmpty(value)) + ? PhoneUtils.get(mSubId).formatForDisplay(value) + : getContext().getString(R.string.unknown_phone_number_pref_display_value); + final BidiFormatter bidiFormatter = BidiFormatter.getInstance(); + final String phoneNumber = bidiFormatter.unicodeWrap + (displayValue, TextDirectionHeuristicsCompat.LTR); + // Set the value as the summary and let the superclass populate the views + setSummary(phoneNumber); + super.onBindView(view); + } + + @Override + protected void onBindDialogView(final View view) { + super.onBindDialogView(view); + + final String value = getText(); + + // If the preference is empty, populate the EditText with the default number instead. + if (TextUtils.isEmpty(value) && !TextUtils.isEmpty(mDefaultPhoneNumber)) { + final BidiFormatter bidiFormatter = BidiFormatter.getInstance(); + final String phoneNumber = bidiFormatter.unicodeWrap + (PhoneUtils.get(mSubId).getCanonicalBySystemLocale(mDefaultPhoneNumber), + TextDirectionHeuristicsCompat.LTR); + getEditText().setText(phoneNumber); + } + getEditText().setInputType(InputType.TYPE_CLASS_PHONE); + } + + @Override + protected void onDialogClosed(final boolean positiveResult) { + if (positiveResult && mDefaultPhoneNumber != null) { + final String value = getEditText().getText().toString(); + final PhoneUtils phoneUtils = PhoneUtils.get(mSubId); + final String phoneNumber = phoneUtils.getCanonicalBySystemLocale(value); + final String defaultPhoneNumber = phoneUtils.getCanonicalBySystemLocale( + mDefaultPhoneNumber); + + // If the new value is the default, clear the preference. + if (phoneNumber.equals(defaultPhoneNumber)) { + setText(""); + return; + } + } + super.onDialogClosed(positiveResult); + } + + @Override + public void setText(final String text) { + super.setText(text); + + // EditTextPreference doesn't show the value on the preference view, but we do. + // We thus need to force a rebind of the view when a new value is set. + notifyChanged(); + } +} diff --git a/src/com/android/messaging/ui/appsettings/SettingsActivity.java b/src/com/android/messaging/ui/appsettings/SettingsActivity.java new file mode 100644 index 0000000..75266d8 --- /dev/null +++ b/src/com/android/messaging/ui/appsettings/SettingsActivity.java @@ -0,0 +1,178 @@ +/* + * 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.ui.appsettings; + +import android.app.Fragment; +import android.content.Context; +import android.os.Bundle; +import android.support.v4.app.NavUtils; +import android.text.TextUtils; +import android.view.LayoutInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.View.OnClickListener; +import android.view.ViewGroup; +import android.widget.ArrayAdapter; +import android.widget.ListView; +import android.widget.TextView; + +import com.android.messaging.R; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.binding.Binding; +import com.android.messaging.datamodel.binding.BindingBase; +import com.android.messaging.datamodel.data.SettingsData; +import com.android.messaging.datamodel.data.SettingsData.SettingsDataListener; +import com.android.messaging.datamodel.data.SettingsData.SettingsItem; +import com.android.messaging.ui.BugleActionBarActivity; +import com.android.messaging.ui.UIIntents; +import com.android.messaging.util.Assert; +import com.android.messaging.util.PhoneUtils; + +import java.util.ArrayList; +import java.util.List; + +/** + * Shows the "master" settings activity that contains two parts, one for application-wide settings + * (dubbed "General settings"), and one or more for per-subscription settings (dubbed "Messaging + * settings" for single-SIM, and the actual SIM name for multi-SIM). Clicking on either item + * (e.g. "General settings") will open the detail settings activity (ApplicationSettingsActivity + * in this case). + */ +public class SettingsActivity extends BugleActionBarActivity { + @Override + protected void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + + // Directly open the detailed settings page as the top-level settings activity if this is + // not a multi-SIM device. + if (PhoneUtils.getDefault().getActiveSubscriptionCount() <= 1) { + UIIntents.get().launchApplicationSettingsActivity(this, true /* topLevel */); + finish(); + } else { + getFragmentManager().beginTransaction() + .replace(android.R.id.content, new SettingsFragment()) + .commit(); + } + } + + @Override + public boolean onOptionsItemSelected(final MenuItem item) { + switch (item.getItemId()) { + case android.R.id.home: + NavUtils.navigateUpFromSameTask(this); + return true; + } + return super.onOptionsItemSelected(item); + } + + public static class SettingsFragment extends Fragment implements SettingsDataListener { + private ListView mListView; + private SettingsListAdapter mAdapter; + private final Binding<SettingsData> mBinding = BindingBase.createBinding(this); + + @Override + public void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + mBinding.bind(DataModel.get().createSettingsData(getActivity(), this)); + mBinding.getData().init(getLoaderManager(), mBinding); + } + + @Override + public View onCreateView(final LayoutInflater inflater, final ViewGroup container, + final Bundle savedInstanceState) { + final View view = inflater.inflate(R.layout.settings_fragment, container, false); + mListView = (ListView) view.findViewById(android.R.id.list); + mAdapter = new SettingsListAdapter(getActivity()); + mListView.setAdapter(mAdapter); + return view; + } + + @Override + public void onDestroy() { + super.onDestroy(); + mBinding.unbind(); + } + + @Override + public void onSelfParticipantDataLoaded(SettingsData data) { + mBinding.ensureBound(data); + mAdapter.setSettingsItems(data.getSettingsItems()); + } + + /** + * An adapter that displays a list of SettingsItem. + */ + private class SettingsListAdapter extends ArrayAdapter<SettingsItem> { + public SettingsListAdapter(final Context context) { + super(context, R.layout.settings_item_view, new ArrayList<SettingsItem>()); + } + + public void setSettingsItems(final List<SettingsItem> newList) { + clear(); + addAll(newList); + notifyDataSetChanged(); + } + + @Override + public View getView(final int position, final View convertView, + final ViewGroup parent) { + View itemView; + if (convertView != null) { + itemView = convertView; + } else { + final LayoutInflater inflater = (LayoutInflater) getContext() + .getSystemService(Context.LAYOUT_INFLATER_SERVICE); + itemView = inflater.inflate( + R.layout.settings_item_view, parent, false); + } + final SettingsItem item = getItem(position); + final TextView titleTextView = (TextView) itemView.findViewById(R.id.title); + final TextView subtitleTextView = (TextView) itemView.findViewById(R.id.subtitle); + final String summaryText = item.getDisplayDetail(); + titleTextView.setText(item.getDisplayName()); + if (!TextUtils.isEmpty(summaryText)) { + subtitleTextView.setText(summaryText); + subtitleTextView.setVisibility(View.VISIBLE); + } else { + subtitleTextView.setVisibility(View.GONE); + } + itemView.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View view) { + switch (item.getType()) { + case SettingsItem.TYPE_GENERAL_SETTINGS: + UIIntents.get().launchApplicationSettingsActivity(getActivity(), + false /* topLevel */); + break; + + case SettingsItem.TYPE_PER_SUBSCRIPTION_SETTINGS: + UIIntents.get().launchPerSubscriptionSettingsActivity(getActivity(), + item.getSubId(), item.getActivityTitle()); + break; + + default: + Assert.fail("unrecognized setting type!"); + break; + } + } + }); + return itemView; + } + } + } +} diff --git a/src/com/android/messaging/ui/attachmentchooser/AttachmentChooserActivity.java b/src/com/android/messaging/ui/attachmentchooser/AttachmentChooserActivity.java new file mode 100644 index 0000000..a540597 --- /dev/null +++ b/src/com/android/messaging/ui/attachmentchooser/AttachmentChooserActivity.java @@ -0,0 +1,56 @@ +/* + * 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.ui.attachmentchooser; + +import android.app.Fragment; +import android.os.Bundle; + +import com.android.messaging.R; +import com.android.messaging.ui.BugleActionBarActivity; +import com.android.messaging.ui.UIIntents; +import com.android.messaging.ui.attachmentchooser.AttachmentChooserFragment.AttachmentChooserFragmentHost; +import com.android.messaging.util.Assert; + +public class AttachmentChooserActivity extends BugleActionBarActivity implements + AttachmentChooserFragmentHost { + + @Override + protected void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.attachment_chooser_activity); + getSupportActionBar().setDisplayHomeAsUpEnabled(false); + } + + @Override + public void onAttachFragment(final Fragment fragment) { + if (fragment instanceof AttachmentChooserFragment) { + final String conversationId = + getIntent().getStringExtra(UIIntents.UI_INTENT_EXTRA_CONVERSATION_ID); + Assert.notNull(conversationId); + final AttachmentChooserFragment chooserFragment = + (AttachmentChooserFragment) fragment; + chooserFragment.setConversationId(conversationId); + chooserFragment.setHost(this); + } + } + + @Override + public void onConfirmSelection() { + setResult(RESULT_OK); + finish(); + } +} diff --git a/src/com/android/messaging/ui/attachmentchooser/AttachmentChooserFragment.java b/src/com/android/messaging/ui/attachmentchooser/AttachmentChooserFragment.java new file mode 100644 index 0000000..b39dc3e --- /dev/null +++ b/src/com/android/messaging/ui/attachmentchooser/AttachmentChooserFragment.java @@ -0,0 +1,183 @@ +/* + * 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.ui.attachmentchooser; + +import android.app.Fragment; +import android.content.Context; +import android.graphics.Rect; +import android.net.Uri; +import android.os.Bundle; +import android.support.v7.app.ActionBar; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ArrayAdapter; + +import com.android.messaging.R; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.MessagingContentProvider; +import com.android.messaging.datamodel.binding.Binding; +import com.android.messaging.datamodel.binding.BindingBase; +import com.android.messaging.datamodel.data.DraftMessageData; +import com.android.messaging.datamodel.data.DraftMessageData.DraftMessageDataListener; +import com.android.messaging.datamodel.data.MessagePartData; +import com.android.messaging.ui.BugleActionBarActivity; +import com.android.messaging.ui.UIIntents; +import com.android.messaging.ui.attachmentchooser.AttachmentGridView.AttachmentGridHost; +import com.google.common.annotations.VisibleForTesting; + +import java.util.ArrayList; +import java.util.List; + +public class AttachmentChooserFragment extends Fragment implements DraftMessageDataListener, + AttachmentGridHost { + public interface AttachmentChooserFragmentHost { + void onConfirmSelection(); + } + + private AttachmentGridView mAttachmentGridView; + private AttachmentGridAdapter mAdapter; + private AttachmentChooserFragmentHost mHost; + + @VisibleForTesting + Binding<DraftMessageData> mBinding = BindingBase.createBinding(this); + + @Override + public View onCreateView(final LayoutInflater inflater, final ViewGroup container, + final Bundle savedInstanceState) { + final View view = inflater.inflate(R.layout.attachment_chooser_fragment, container, false); + mAttachmentGridView = (AttachmentGridView) view.findViewById(R.id.grid); + mAdapter = new AttachmentGridAdapter(getActivity()); + mAttachmentGridView.setAdapter(mAdapter); + mAttachmentGridView.setHost(this); + setHasOptionsMenu(true); + return view; + } + + @Override + public void onDestroy() { + super.onDestroy(); + mBinding.unbind(); + } + + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { + super.onCreateOptionsMenu(menu, inflater); + inflater.inflate(R.menu.attachment_chooser_menu, menu); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + switch (item.getItemId()) { + case R.id.action_confirm_selection: + confirmSelection(); + return true; + + default: + return super.onOptionsItemSelected(item); + } + } + + @VisibleForTesting + void confirmSelection() { + if (mBinding.isBound()) { + mBinding.getData().removeExistingAttachments( + mAttachmentGridView.getUnselectedAttachments()); + mBinding.getData().saveToStorage(mBinding); + mHost.onConfirmSelection(); + } + } + + public void setConversationId(final String conversationId) { + mBinding.bind(DataModel.get().createDraftMessageData(conversationId)); + mBinding.getData().addListener(this); + mBinding.getData().loadFromStorage(mBinding, null, false); + } + + public void setHost(final AttachmentChooserFragmentHost host) { + mHost = host; + } + + @Override + public void onDraftChanged(final DraftMessageData data, final int changeFlags) { + mBinding.ensureBound(data); + if ((changeFlags & DraftMessageData.ATTACHMENTS_CHANGED) == + DraftMessageData.ATTACHMENTS_CHANGED) { + mAdapter.onAttachmentsLoaded(data.getReadOnlyAttachments()); + } + } + + @Override + public void onDraftAttachmentLimitReached(final DraftMessageData data) { + // Do nothing since the user is in the process of unselecting attachments. + } + + @Override + public void onDraftAttachmentLoadFailed() { + // Do nothing since the user is in the process of unselecting attachments. + } + + @Override + public void displayPhoto(final Rect viewRect, final Uri photoUri) { + final Uri imagesUri = MessagingContentProvider.buildDraftImagesUri( + mBinding.getData().getConversationId()); + UIIntents.get().launchFullScreenPhotoViewer( + getActivity(), photoUri, viewRect, imagesUri); + } + + @Override + public void updateSelectionCount(int count) { + if (getActivity() instanceof BugleActionBarActivity) { + final ActionBar actionBar = ((BugleActionBarActivity) getActivity()) + .getSupportActionBar(); + if (actionBar != null) { + actionBar.setTitle(getResources().getString( + R.string.attachment_chooser_selection, count)); + } + } + } + + class AttachmentGridAdapter extends ArrayAdapter<MessagePartData> { + public AttachmentGridAdapter(final Context context) { + super(context, R.layout.attachment_grid_item_view, new ArrayList<MessagePartData>()); + } + + public void onAttachmentsLoaded(final List<MessagePartData> attachments) { + clear(); + addAll(attachments); + notifyDataSetChanged(); + } + + @Override + public View getView(final int position, final View convertView, final ViewGroup parent) { + AttachmentGridItemView itemView; + final MessagePartData item = getItem(position); + if (convertView != null && convertView instanceof AttachmentGridItemView) { + itemView = (AttachmentGridItemView) convertView; + } else { + final LayoutInflater inflater = (LayoutInflater) getContext() + .getSystemService(Context.LAYOUT_INFLATER_SERVICE); + itemView = (AttachmentGridItemView) inflater.inflate( + R.layout.attachment_grid_item_view, parent, false); + } + itemView.bind(item, mAttachmentGridView); + return itemView; + } + } +} diff --git a/src/com/android/messaging/ui/attachmentchooser/AttachmentGridItemView.java b/src/com/android/messaging/ui/attachmentchooser/AttachmentGridItemView.java new file mode 100644 index 0000000..8bb7356 --- /dev/null +++ b/src/com/android/messaging/ui/attachmentchooser/AttachmentGridItemView.java @@ -0,0 +1,119 @@ +/* + * 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.ui.attachmentchooser; + +import android.content.Context; +import android.graphics.Rect; +import android.util.AttributeSet; +import android.view.LayoutInflater; +import android.view.TouchDelegate; +import android.view.View; +import android.widget.CheckBox; +import android.widget.FrameLayout; + +import com.android.messaging.R; +import com.android.messaging.datamodel.data.MessagePartData; +import com.android.messaging.ui.AttachmentPreviewFactory; +import com.android.messaging.util.Assert; +import com.google.common.annotations.VisibleForTesting; + +/** + * Shows an item in the attachment picker grid. + */ +public class AttachmentGridItemView extends FrameLayout { + public interface HostInterface { + boolean isItemSelected(MessagePartData attachment); + void onItemCheckedChanged(AttachmentGridItemView view, MessagePartData attachment); + void onItemClicked(AttachmentGridItemView view, MessagePartData attachment); + } + + @VisibleForTesting + MessagePartData mAttachmentData; + private FrameLayout mAttachmentViewContainer; + private CheckBox mCheckBox; + private HostInterface mHostInterface; + + public AttachmentGridItemView(final Context context, final AttributeSet attrs) { + super(context, attrs); + } + + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + mAttachmentViewContainer = (FrameLayout) findViewById(R.id.attachment_container); + mCheckBox = (CheckBox) findViewById(R.id.checkbox); + mCheckBox.setOnClickListener(new OnClickListener() { + @Override + public void onClick(final View v) { + mHostInterface.onItemCheckedChanged(AttachmentGridItemView.this, mAttachmentData); + } + }); + setOnClickListener(new OnClickListener() { + @Override + public void onClick(final View v) { + mHostInterface.onItemClicked(AttachmentGridItemView.this, mAttachmentData); + } + }); + addOnLayoutChangeListener(new OnLayoutChangeListener() { + @Override + public void onLayoutChange(View v, int left, int top, int right, int bottom, + int oldLeft, int oldTop, int oldRight, int oldBottom) { + // Enlarge the clickable region for the checkbox. + final int touchAreaIncrease = getResources().getDimensionPixelOffset( + R.dimen.attachment_grid_checkbox_area_increase); + final Rect region = new Rect(); + mCheckBox.getHitRect(region); + region.inset(-touchAreaIncrease, -touchAreaIncrease); + setTouchDelegate(new TouchDelegate(region, mCheckBox)); + } + }); + } + + @Override + protected void onMeasure(final int widthMeasureSpec, final int heightMeasureSpec) { + // The grid view auto-fits the columns, so we want to let the height match the width + // to make the attachment preview square. + super.onMeasure(widthMeasureSpec, widthMeasureSpec); + } + + public void bind(final MessagePartData attachment, final HostInterface hostInterface) { + Assert.isTrue(attachment.isAttachment()); + mHostInterface = hostInterface; + updateSelectedState(); + if (mAttachmentData == null || !mAttachmentData.equals(attachment)) { + mAttachmentData = attachment; + updateAttachmentView(); + } + } + + @VisibleForTesting + HostInterface testGetHostInterface() { + return mHostInterface; + } + + public void updateSelectedState() { + mCheckBox.setChecked(mHostInterface.isItemSelected(mAttachmentData)); + } + + private void updateAttachmentView() { + mAttachmentViewContainer.removeAllViews(); + final LayoutInflater inflater = LayoutInflater.from(getContext()); + final View attachmentView = AttachmentPreviewFactory.createAttachmentPreview(inflater, + mAttachmentData, mAttachmentViewContainer, + AttachmentPreviewFactory.TYPE_CHOOSER_GRID, true /* startImageRequest */, null); + mAttachmentViewContainer.addView(attachmentView); + } +} diff --git a/src/com/android/messaging/ui/attachmentchooser/AttachmentGridView.java b/src/com/android/messaging/ui/attachmentchooser/AttachmentGridView.java new file mode 100644 index 0000000..abf61dc --- /dev/null +++ b/src/com/android/messaging/ui/attachmentchooser/AttachmentGridView.java @@ -0,0 +1,172 @@ +/* + * 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.ui.attachmentchooser; + +import android.content.Context; +import android.graphics.Rect; +import android.net.Uri; +import android.os.Parcel; +import android.os.Parcelable; +import android.util.AttributeSet; +import android.widget.GridView; + +import com.android.messaging.datamodel.data.MessagePartData; +import com.android.messaging.ui.attachmentchooser.AttachmentChooserFragment.AttachmentGridAdapter; +import com.android.messaging.util.Assert; +import com.android.messaging.util.UiUtils; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +/** + * Displays a grid of attachment previews for the user to choose which to select/unselect + */ +public class AttachmentGridView extends GridView implements + AttachmentGridItemView.HostInterface { + public interface AttachmentGridHost { + void displayPhoto(final Rect viewRect, final Uri photoUri); + void updateSelectionCount(final int count); + } + + // By default everything is selected so only need to keep track of the unselected set. + private final Set<MessagePartData> mUnselectedSet; + private AttachmentGridHost mHost; + + public AttachmentGridView(final Context context, final AttributeSet attrs) { + super(context, attrs); + mUnselectedSet = new HashSet<>(); + } + + public void setHost(final AttachmentGridHost host) { + mHost = host; + } + + @Override + public boolean isItemSelected(final MessagePartData attachment) { + return !mUnselectedSet.contains(attachment); + } + + @Override + public void onItemClicked(final AttachmentGridItemView view, final MessagePartData attachment) { + // If the item is an image, show the photo viewer. All the other types (video, audio, + // vcard) have internal click handling for showing previews so we don't need to handle them + if (attachment.isImage()) { + mHost.displayPhoto(UiUtils.getMeasuredBoundsOnScreen(view), attachment.getContentUri()); + } + } + + @Override + public void onItemCheckedChanged(AttachmentGridItemView view, MessagePartData attachment) { + // Toggle selection. + if (isItemSelected(attachment)) { + mUnselectedSet.add(attachment); + } else { + mUnselectedSet.remove(attachment); + } + view.updateSelectedState(); + updateSelectionCount(); + } + + public Set<MessagePartData> getUnselectedAttachments() { + return Collections.unmodifiableSet(mUnselectedSet); + } + + private void updateSelectionCount() { + final int count = getAdapter().getCount() - mUnselectedSet.size(); + Assert.isTrue(count >= 0); + mHost.updateSelectionCount(count); + } + + private void refreshViews() { + if (getAdapter() instanceof AttachmentGridAdapter) { + ((AttachmentGridAdapter) getAdapter()).notifyDataSetChanged(); + } + } + + @Override + public Parcelable onSaveInstanceState() { + final Parcelable superState = super.onSaveInstanceState(); + final SavedState savedState = new SavedState(superState); + savedState.unselectedParts = mUnselectedSet + .toArray(new MessagePartData[mUnselectedSet.size()]); + return savedState; + } + + @Override + public void onRestoreInstanceState(final Parcelable state) { + if (!(state instanceof SavedState)) { + super.onRestoreInstanceState(state); + return; + } + + final SavedState savedState = (SavedState) state; + super.onRestoreInstanceState(savedState.getSuperState()); + mUnselectedSet.clear(); + for (int i = 0; i < savedState.unselectedParts.length; i++) { + final MessagePartData unselectedPart = savedState.unselectedParts[i]; + mUnselectedSet.add(unselectedPart); + } + refreshViews(); + } + + /** + * Persists the item selection state to saved instance state so we can restore on activity + * recreation + */ + public static class SavedState extends BaseSavedState { + MessagePartData[] unselectedParts; + + SavedState(final Parcelable superState) { + super(superState); + } + + private SavedState(final Parcel in) { + super(in); + + // Read parts + final int partCount = in.readInt(); + unselectedParts = new MessagePartData[partCount]; + for (int i = 0; i < partCount; i++) { + unselectedParts[i] = ((MessagePartData) in.readParcelable( + MessagePartData.class.getClassLoader())); + } + } + + @Override + public void writeToParcel(final Parcel out, final int flags) { + super.writeToParcel(out, flags); + + // Write parts + out.writeInt(unselectedParts.length); + for (final MessagePartData image : unselectedParts) { + out.writeParcelable(image, flags); + } + } + + public static final Parcelable.Creator<SavedState> CREATOR = + new Parcelable.Creator<SavedState>() { + @Override + public SavedState createFromParcel(final Parcel in) { + return new SavedState(in); + } + @Override + public SavedState[] newArray(final int size) { + return new SavedState[size]; + } + }; + } +} diff --git a/src/com/android/messaging/ui/contact/AddContactsConfirmationDialog.java b/src/com/android/messaging/ui/contact/AddContactsConfirmationDialog.java new file mode 100644 index 0000000..9c1393d --- /dev/null +++ b/src/com/android/messaging/ui/contact/AddContactsConfirmationDialog.java @@ -0,0 +1,85 @@ +/* + * 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.ui.contact; + +import android.app.AlertDialog; +import android.content.Context; +import android.content.DialogInterface; +import android.content.res.Resources; +import android.net.Uri; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.Button; +import android.widget.TextView; + +import com.android.messaging.R; +import com.android.messaging.ui.ContactIconView; +import com.android.messaging.ui.UIIntents; +import com.android.messaging.util.AccessibilityUtil; + +public class AddContactsConfirmationDialog implements DialogInterface.OnClickListener { + private final Context mContext; + private final Uri mAvatarUri; + private final String mNormalizedDestination; + + public AddContactsConfirmationDialog(final Context context, final Uri avatarUri, + final String normalizedDestination) { + mContext = context; + mAvatarUri = avatarUri; + mNormalizedDestination = normalizedDestination; + } + + public void show() { + final int confirmAddContactStringId = R.string.add_contact_confirmation; + final int cancelStringId = android.R.string.cancel; + final AlertDialog alertDialog = new AlertDialog.Builder(mContext) + .setTitle(R.string.add_contact_confirmation_dialog_title) + .setView(createBodyView()) + .setPositiveButton(confirmAddContactStringId, this) + .setNegativeButton(cancelStringId, null) + .create(); + alertDialog.show(); + final Resources resources = mContext.getResources(); + final Button cancelButton = alertDialog.getButton(DialogInterface.BUTTON_NEGATIVE); + if (cancelButton != null) { + cancelButton.setTextColor(resources.getColor(R.color.contact_picker_button_text_color)); + } + final Button addButton = alertDialog.getButton(DialogInterface.BUTTON_POSITIVE); + if (addButton != null) { + addButton.setTextColor(resources.getColor(R.color.contact_picker_button_text_color)); + } + } + + @Override + public void onClick(final DialogInterface dialog, final int which) { + UIIntents.get().launchAddContactActivity(mContext, mNormalizedDestination); + } + + private View createBodyView() { + final View view = LayoutInflater.from(mContext).inflate( + R.layout.add_contacts_confirmation_dialog_body, null); + final ContactIconView iconView = (ContactIconView) view.findViewById(R.id.contact_icon); + iconView.setImageResourceUri(mAvatarUri); + final TextView textView = (TextView) view.findViewById(R.id.participant_name); + textView.setText(mNormalizedDestination); + // Accessibility reason : in case phone numbers are mixed in the display name, + // we need to vocalize it for talkback. + final String vocalizedDisplayName = AccessibilityUtil.getVocalizedPhoneNumber( + mContext.getResources(), mNormalizedDestination); + textView.setContentDescription(vocalizedDisplayName); + return view; + } +} diff --git a/src/com/android/messaging/ui/contact/AllContactsListViewHolder.java b/src/com/android/messaging/ui/contact/AllContactsListViewHolder.java new file mode 100644 index 0000000..7263c54 --- /dev/null +++ b/src/com/android/messaging/ui/contact/AllContactsListViewHolder.java @@ -0,0 +1,62 @@ +/* + * 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.ui.contact; + +import android.content.Context; + +import com.android.messaging.R; +import com.android.messaging.ui.CustomHeaderPagerListViewHolder; +import com.android.messaging.ui.contact.ContactListItemView.HostInterface; + +/** + * Holds the all contacts view for the contact picker's view pager. + */ +public class AllContactsListViewHolder extends CustomHeaderPagerListViewHolder { + public AllContactsListViewHolder(final Context context, final HostInterface clivHostInterface) { + super(context, new ContactListAdapter(context, null, clivHostInterface, + true /* needAlphabetHeader */)); + } + + @Override + protected int getLayoutResId() { + return R.layout.all_contacts_list_view; + } + + @Override + protected int getPageTitleResId() { + return R.string.contact_picker_all_contacts_tab_title; + } + + @Override + protected int getEmptyViewResId() { + return R.id.empty_view; + } + + @Override + protected int getListViewResId() { + return R.id.all_contacts_list; + } + + @Override + protected int getEmptyViewTitleResId() { + return R.string.contact_list_empty_text; + } + + @Override + protected int getEmptyViewImageResId() { + return R.drawable.ic_oobe_freq_list; + } +} diff --git a/src/com/android/messaging/ui/contact/ContactDropdownLayouter.java b/src/com/android/messaging/ui/contact/ContactDropdownLayouter.java new file mode 100644 index 0000000..7df62de --- /dev/null +++ b/src/com/android/messaging/ui/contact/ContactDropdownLayouter.java @@ -0,0 +1,138 @@ +/* + * 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.ui.contact; + +import android.content.Context; +import android.graphics.drawable.StateListDrawable; +import android.net.Uri; +import android.support.v4.text.BidiFormatter; +import android.support.v4.text.TextDirectionHeuristicsCompat; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; + +import com.android.ex.chips.DropdownChipLayouter; +import com.android.ex.chips.RecipientEntry; +import com.android.messaging.R; +import com.android.messaging.datamodel.data.ContactListItemData; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.ui.ContactIconView; +import com.android.messaging.util.Assert; +import com.android.messaging.util.AvatarUriUtil; +import com.android.messaging.util.ContactRecipientEntryUtils; + +/** + * An implementation for {@link DropdownChipLayouter}. Layouts the dropdown + * list in the ContactRecipientAutoCompleteView in Material style. + */ +public class ContactDropdownLayouter extends DropdownChipLayouter { + private final ContactListItemView.HostInterface mClivHostInterface; + + public ContactDropdownLayouter(final LayoutInflater inflater, final Context context, + final ContactListItemView.HostInterface clivHostInterface) { + super(inflater, context); + mClivHostInterface = new ContactListItemView.HostInterface() { + + @Override + public void onContactListItemClicked(final ContactListItemData item, + final ContactListItemView view) { + // The chips UI will handle auto-complete item click events, so No-op here. + } + + @Override + public boolean isContactSelected(final ContactListItemData item) { + // In chips drop down we don't show any selected checkmark per design. + return false; + } + }; + } + + /** + * Bind a drop down view to a RecipientEntry. We'd like regular dropdown items (BASE_RECIPIENT) + * to behave the same as regular ContactListItemViews, while using the chips library's + * item styling for alternates dropdown items (happens when you click on a chip). + */ + @Override + public View bindView(final View convertView, final ViewGroup parent, final RecipientEntry entry, + final int position, AdapterType type, final String substring, + final StateListDrawable deleteDrawable) { + if (type != AdapterType.BASE_RECIPIENT) { + if (type == AdapterType.SINGLE_RECIPIENT) { + // Treat single recipients the same way as alternates. The base implementation of + // single recipients would try to simplify the destination by tokenizing. We'd + // like to always show the full destination address per design request. + type = AdapterType.RECIPIENT_ALTERNATES; + } + return super.bindView(convertView, parent, entry, position, type, substring, + deleteDrawable); + } + + // Default to show all the information + // RTL : To format contact name and detail if they happen to be phone numbers. + final BidiFormatter bidiFormatter = BidiFormatter.getInstance(); + final String displayName = bidiFormatter.unicodeWrap( + ContactRecipientEntryUtils.getDisplayNameForContactList(entry), + TextDirectionHeuristicsCompat.LTR); + final String destination = bidiFormatter.unicodeWrap( + ContactRecipientEntryUtils.formatDestination(entry), + TextDirectionHeuristicsCompat.LTR); + final View itemView = reuseOrInflateView(convertView, parent, type); + + // Bold the string that is matched. + final CharSequence[] styledResults = + getStyledResults(substring, displayName, destination); + + Assert.isTrue(itemView instanceof ContactListItemView); + final ContactListItemView contactListItemView = (ContactListItemView) itemView; + contactListItemView.setImageClickHandlerDisabled(true); + contactListItemView.bind(entry, styledResults[0], styledResults[1], + mClivHostInterface, (type == AdapterType.SINGLE_RECIPIENT)); + return itemView; + } + + @Override + protected void bindIconToView(boolean showImage, RecipientEntry entry, ImageView view, + AdapterType type) { + if (showImage && view instanceof ContactIconView) { + final ContactIconView contactView = (ContactIconView) view; + // These show contact cards by default, but that isn't what we want here + contactView.setImageClickHandlerDisabled(true); + final Uri avatarUri = AvatarUriUtil.createAvatarUri( + ParticipantData.getFromRecipientEntry(entry)); + contactView.setImageResourceUri(avatarUri); + } else { + super.bindIconToView(showImage, entry, view, type); + } + } + + @Override + protected int getItemLayoutResId(AdapterType type) { + switch (type) { + case BASE_RECIPIENT: + return R.layout.contact_list_item_view; + case RECIPIENT_ALTERNATES: + return R.layout.chips_alternates_dropdown_item; + default: + return R.layout.chips_alternates_dropdown_item; + } + } + + @Override + protected int getAlternateItemLayoutResId(AdapterType type) { + return getItemLayoutResId(type); + } +} diff --git a/src/com/android/messaging/ui/contact/ContactListAdapter.java b/src/com/android/messaging/ui/contact/ContactListAdapter.java new file mode 100644 index 0000000..d466b61 --- /dev/null +++ b/src/com/android/messaging/ui/contact/ContactListAdapter.java @@ -0,0 +1,86 @@ +/* + * 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.ui.contact; + +import android.content.Context; +import android.database.Cursor; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.CursorAdapter; +import android.widget.SectionIndexer; + +import com.android.messaging.R; +import com.android.messaging.util.Assert; + +public class ContactListAdapter extends CursorAdapter implements SectionIndexer { + private final ContactListItemView.HostInterface mClivHostInterface; + private final boolean mNeedAlphabetHeader; + private ContactSectionIndexer mSectionIndexer; + + public ContactListAdapter(final Context context, final Cursor cursor, + final ContactListItemView.HostInterface clivHostInterface, + final boolean needAlphabetHeader) { + super(context, cursor, 0); + mClivHostInterface = clivHostInterface; + mNeedAlphabetHeader = needAlphabetHeader; + mSectionIndexer = new ContactSectionIndexer(cursor); + } + + @Override + public void bindView(final View view, final Context context, final Cursor cursor) { + Assert.isTrue(view instanceof ContactListItemView); + final ContactListItemView contactListItemView = (ContactListItemView) view; + String alphabetHeader = null; + if (mNeedAlphabetHeader) { + final int position = cursor.getPosition(); + final int section = mSectionIndexer.getSectionForPosition(position); + // Check if the position is the first in the section. + if (mSectionIndexer.getPositionForSection(section) == position) { + alphabetHeader = (String) mSectionIndexer.getSections()[section]; + } + } + contactListItemView.bind(cursor, mClivHostInterface, mNeedAlphabetHeader, alphabetHeader); + } + + @Override + public View newView(final Context context, final Cursor cursor, final ViewGroup parent) { + final LayoutInflater layoutInflater = LayoutInflater.from(context); + return layoutInflater.inflate(R.layout.contact_list_item_view, parent, false); + } + + @Override + public Cursor swapCursor(final Cursor newCursor) { + mSectionIndexer = new ContactSectionIndexer(newCursor); + return super.swapCursor(newCursor); + } + + @Override + public Object[] getSections() { + return mSectionIndexer.getSections(); + } + + @Override + public int getPositionForSection(final int sectionIndex) { + return mSectionIndexer.getPositionForSection(sectionIndex); + } + + @Override + public int getSectionForPosition(final int position) { + return mSectionIndexer.getSectionForPosition(position); + } +} diff --git a/src/com/android/messaging/ui/contact/ContactListItemView.java b/src/com/android/messaging/ui/contact/ContactListItemView.java new file mode 100644 index 0000000..6904da6 --- /dev/null +++ b/src/com/android/messaging/ui/contact/ContactListItemView.java @@ -0,0 +1,177 @@ +/* + * 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.ui.contact; + +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.provider.ContactsContract.CommonDataKinds.Phone; +import android.util.AttributeSet; +import android.view.View; +import android.view.View.OnClickListener; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.TextView; + +import com.android.ex.chips.RecipientEntry; +import com.android.messaging.R; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.data.ContactListItemData; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.ui.ContactIconView; +import com.android.messaging.util.Assert; +import com.android.messaging.util.AvatarUriUtil; +import com.google.common.annotations.VisibleForTesting; + +/** + * The view for a single entry in a contact list. + */ +public class ContactListItemView extends LinearLayout implements OnClickListener { + public interface HostInterface { + void onContactListItemClicked(ContactListItemData item, ContactListItemView view); + boolean isContactSelected(ContactListItemData item); + } + + @VisibleForTesting + final ContactListItemData mData; + private TextView mContactNameTextView; + private TextView mContactDetailsTextView; + private TextView mContactDetailTypeTextView; + private TextView mAlphabetHeaderTextView; + private ContactIconView mContactIconView; + private ImageView mContactCheckmarkView; + private HostInterface mHostInterface; + private boolean mShouldShowAlphabetHeader; + + public ContactListItemView(final Context context, final AttributeSet attrs) { + super(context, attrs); + mData = DataModel.get().createContactListItemData(); + } + + @Override + protected void onFinishInflate () { + mContactNameTextView = (TextView) findViewById(R.id.contact_name); + mContactDetailsTextView = (TextView) findViewById(R.id.contact_details); + mContactDetailTypeTextView = (TextView) findViewById(R.id.contact_detail_type); + mAlphabetHeaderTextView = (TextView) findViewById(R.id.alphabet_header); + mContactIconView = (ContactIconView) findViewById(R.id.contact_icon); + mContactCheckmarkView = (ImageView) findViewById(R.id.contact_checkmark); + } + + /** + * Fills in the data associated with this view by binding to a contact cursor provided by + * ContactUtil. + * @param cursor the contact cursor. + * @param hostInterface host interface to this view. + * @param shouldShowAlphabetHeader whether an alphabetical header should shown on the side + * of this view. If {@code headerLabel} is empty, we will still leave space for it. + * @param headerLabel the alphabetical header on the side of this view, if it should be shown. + */ + public void bind(final Cursor cursor, final HostInterface hostInterface, + final boolean shouldShowAlphabetHeader, final String headerLabel) { + mData.bind(cursor, headerLabel); + mHostInterface = hostInterface; + mShouldShowAlphabetHeader = shouldShowAlphabetHeader; + setOnClickListener(this); + updateViewAppearance(); + } + + /** + * Binds a RecipientEntry. This is used by the chips text view's dropdown layout. + * @param recipientEntry the source RecipientEntry provided by ContactDropdownLayouter, which + * was in turn directly from one of the existing chips, or from filtered results + * generated by ContactRecipientAdapter. + * @param styledName display name where the portion that matches the search text is bold. + * @param styledDestination number where the portion that matches the search text is bold. + * @param hostInterface host interface to this view. + * @param isSingleRecipient whether this item is shown as the only line item in the single + * recipient drop down from the chips view. If this is the case, we always show the + * contact avatar even if it's not a first-level entry. + */ + public void bind(final RecipientEntry recipientEntry, final CharSequence styledName, + final CharSequence styledDestination, final HostInterface hostInterface, + final boolean isSingleRecipient) { + mData.bind(recipientEntry, styledName, styledDestination, isSingleRecipient); + mHostInterface = hostInterface; + mShouldShowAlphabetHeader = false; + updateViewAppearance(); + } + + private void updateViewAppearance() { + mContactNameTextView.setText(mData.getDisplayName()); + mContactDetailsTextView.setText(mData.getDestination()); + mContactDetailTypeTextView.setText(Phone.getTypeLabel(getResources(), + mData.getDestinationType(), mData.getDestinationLabel())); + final RecipientEntry recipientEntry = mData.getRecipientEntry(); + final String destinationString = String.valueOf(mData.getDestination()); + if (mData.getIsSimpleContactItem()) { + // This is a special number-with-avatar type of contact (for unknown contact chips + // and for direct "send to destination" item). In this case, make sure we only show + // the display name (phone number) and the avatar and hide everything else. + final Uri avatarUri = AvatarUriUtil.createAvatarUri( + ParticipantData.getFromRecipientEntry(recipientEntry)); + mContactIconView.setImageResourceUri(avatarUri, mData.getContactId(), + mData.getLookupKey(), destinationString); + mContactIconView.setVisibility(VISIBLE); + mContactCheckmarkView.setVisibility(GONE); + mContactDetailTypeTextView.setVisibility(GONE); + mContactDetailsTextView.setVisibility(GONE); + mContactNameTextView.setVisibility(VISIBLE); + } else if (mData.getIsFirstLevel()) { + final Uri avatarUri = AvatarUriUtil.createAvatarUri( + ParticipantData.getFromRecipientEntry(recipientEntry)); + mContactIconView.setImageResourceUri(avatarUri, mData.getContactId(), + mData.getLookupKey(), destinationString); + mContactIconView.setVisibility(VISIBLE); + mContactNameTextView.setVisibility(VISIBLE); + final boolean isSelected = mHostInterface.isContactSelected(mData); + setSelected(isSelected); + mContactCheckmarkView.setVisibility(isSelected ? VISIBLE : GONE); + mContactDetailsTextView.setVisibility(VISIBLE); + mContactDetailTypeTextView.setVisibility(VISIBLE); + } else { + mContactIconView.setImageResourceUri(null); + mContactIconView.setVisibility(INVISIBLE); + mContactNameTextView.setVisibility(GONE); + final boolean isSelected = mHostInterface.isContactSelected(mData); + setSelected(isSelected); + mContactCheckmarkView.setVisibility(isSelected ? VISIBLE : GONE); + mContactDetailsTextView.setVisibility(VISIBLE); + mContactDetailTypeTextView.setVisibility(VISIBLE); + } + + if (mShouldShowAlphabetHeader) { + mAlphabetHeaderTextView.setVisibility(VISIBLE); + mAlphabetHeaderTextView.setText(mData.getAlphabetHeader()); + } else { + mAlphabetHeaderTextView.setVisibility(GONE); + } + } + + /** + * {@inheritDoc} from OnClickListener + */ + @Override + public void onClick(final View v) { + Assert.isTrue(v == this); + Assert.isTrue(mHostInterface != null); + mHostInterface.onContactListItemClicked(mData, this); + } + + public void setImageClickHandlerDisabled(final boolean isHandlerDisabled) { + mContactIconView.setImageClickHandlerDisabled(isHandlerDisabled); + } +} diff --git a/src/com/android/messaging/ui/contact/ContactPickerFragment.java b/src/com/android/messaging/ui/contact/ContactPickerFragment.java new file mode 100644 index 0000000..d803087 --- /dev/null +++ b/src/com/android/messaging/ui/contact/ContactPickerFragment.java @@ -0,0 +1,607 @@ +/* + * 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.ui.contact; + +import android.app.Activity; +import android.app.Fragment; +import android.database.Cursor; +import android.graphics.Rect; +import android.os.Bundle; +import android.support.v7.app.ActionBar; +import android.support.v7.widget.Toolbar; +import android.support.v7.widget.Toolbar.OnMenuItemClickListener; +import android.text.Editable; +import android.text.InputType; +import android.text.TextUtils; +import android.text.TextWatcher; +import android.transition.Explode; +import android.transition.Transition; +import android.transition.Transition.EpicenterCallback; +import android.transition.TransitionManager; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.view.View.OnClickListener; +import android.view.ViewGroup; + +import com.android.messaging.R; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.action.ActionMonitor; +import com.android.messaging.datamodel.action.GetOrCreateConversationAction; +import com.android.messaging.datamodel.action.GetOrCreateConversationAction.GetOrCreateConversationActionListener; +import com.android.messaging.datamodel.action.GetOrCreateConversationAction.GetOrCreateConversationActionMonitor; +import com.android.messaging.datamodel.binding.Binding; +import com.android.messaging.datamodel.binding.BindingBase; +import com.android.messaging.datamodel.data.ContactListItemData; +import com.android.messaging.datamodel.data.ContactPickerData; +import com.android.messaging.datamodel.data.ContactPickerData.ContactPickerDataListener; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.ui.CustomHeaderPagerViewHolder; +import com.android.messaging.ui.CustomHeaderViewPager; +import com.android.messaging.ui.animation.ViewGroupItemVerticalExplodeAnimation; +import com.android.messaging.ui.contact.ContactRecipientAutoCompleteView.ContactChipsChangeListener; +import com.android.messaging.util.Assert; +import com.android.messaging.util.Assert.RunsOnMainThread; +import com.android.messaging.util.ContactUtil; +import com.android.messaging.util.ImeUtil; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.OsUtil; +import com.android.messaging.util.PhoneUtils; +import com.android.messaging.util.UiUtils; +import com.google.common.annotations.VisibleForTesting; + +import java.util.ArrayList; +import java.util.Set; + + +/** + * Shows lists of contacts to start conversations with. + */ +public class ContactPickerFragment extends Fragment implements ContactPickerDataListener, + ContactListItemView.HostInterface, ContactChipsChangeListener, OnMenuItemClickListener, + GetOrCreateConversationActionListener { + public static final String FRAGMENT_TAG = "contactpicker"; + + // Undefined contact picker mode. We should never be in this state after the host activity has + // been created. + public static final int MODE_UNDEFINED = 0; + + // The initial contact picker mode for starting a new conversation with one contact. + public static final int MODE_PICK_INITIAL_CONTACT = 1; + + // The contact picker mode where one initial contact has been picked and we are showing + // only the chips edit box. + public static final int MODE_CHIPS_ONLY = 2; + + // The contact picker mode for picking more contacts after starting the initial 1-1. + public static final int MODE_PICK_MORE_CONTACTS = 3; + + // The contact picker mode when max number of participants is reached. + public static final int MODE_PICK_MAX_PARTICIPANTS = 4; + + public interface ContactPickerFragmentHost { + void onGetOrCreateNewConversation(String conversationId); + void onBackButtonPressed(); + void onInitiateAddMoreParticipants(); + void onParticipantCountChanged(boolean canAddMoreParticipants); + void invalidateActionBar(); + } + + @VisibleForTesting + final Binding<ContactPickerData> mBinding = BindingBase.createBinding(this); + + private ContactPickerFragmentHost mHost; + private ContactRecipientAutoCompleteView mRecipientTextView; + private CustomHeaderViewPager mCustomHeaderViewPager; + private AllContactsListViewHolder mAllContactsListViewHolder; + private FrequentContactsListViewHolder mFrequentContactsListViewHolder; + private View mRootView; + private View mPendingExplodeView; + private View mComposeDivider; + private Toolbar mToolbar; + private int mContactPickingMode = MODE_UNDEFINED; + + // Keeps track of the currently selected phone numbers in the chips view to enable fast lookup. + private Set<String> mSelectedPhoneNumbers = null; + + /** + * {@inheritDoc} from Fragment + */ + @Override + public void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + mAllContactsListViewHolder = new AllContactsListViewHolder(getActivity(), this); + mFrequentContactsListViewHolder = new FrequentContactsListViewHolder(getActivity(), this); + + if (ContactUtil.hasReadContactsPermission()) { + mBinding.bind(DataModel.get().createContactPickerData(getActivity(), this)); + mBinding.getData().init(getLoaderManager(), mBinding); + } + } + + /** + * {@inheritDoc} from Fragment + */ + @Override + public View onCreateView(final LayoutInflater inflater, final ViewGroup container, + final Bundle savedInstanceState) { + final View view = inflater.inflate(R.layout.contact_picker_fragment, container, false); + mRecipientTextView = (ContactRecipientAutoCompleteView) + view.findViewById(R.id.recipient_text_view); + mRecipientTextView.setThreshold(0); + mRecipientTextView.setDropDownAnchor(R.id.compose_contact_divider); + + mRecipientTextView.setContactChipsListener(this); + mRecipientTextView.setDropdownChipLayouter(new ContactDropdownLayouter(inflater, + getActivity(), this)); + mRecipientTextView.setAdapter(new ContactRecipientAdapter(getActivity(), this)); + mRecipientTextView.addTextChangedListener(new TextWatcher() { + @Override + public void onTextChanged(final CharSequence s, final int start, final int before, + final int count) { + } + + @Override + public void beforeTextChanged(final CharSequence s, final int start, final int count, + final int after) { + } + + @Override + public void afterTextChanged(final Editable s) { + updateTextInputButtonsVisibility(); + } + }); + + final CustomHeaderPagerViewHolder[] viewHolders = { + mFrequentContactsListViewHolder, + mAllContactsListViewHolder }; + + mCustomHeaderViewPager = (CustomHeaderViewPager) view.findViewById(R.id.contact_pager); + mCustomHeaderViewPager.setViewHolders(viewHolders); + mCustomHeaderViewPager.setViewPagerTabHeight(CustomHeaderViewPager.DEFAULT_TAB_STRIP_SIZE); + mCustomHeaderViewPager.setBackgroundColor(getResources() + .getColor(R.color.contact_picker_background)); + + // The view pager defaults to the frequent contacts page. + mCustomHeaderViewPager.setCurrentItem(0); + + mToolbar = (Toolbar) view.findViewById(R.id.toolbar); + mToolbar.setNavigationIcon(R.drawable.ic_arrow_back_light); + mToolbar.setNavigationContentDescription(R.string.back); + mToolbar.setNavigationOnClickListener(new OnClickListener() { + @Override + public void onClick(final View v) { + mHost.onBackButtonPressed(); + } + }); + + mToolbar.inflateMenu(R.menu.compose_menu); + mToolbar.setOnMenuItemClickListener(this); + + mComposeDivider = view.findViewById(R.id.compose_contact_divider); + mRootView = view; + return view; + } + + /** + * {@inheritDoc} + * + * Called when the host activity has been created. At this point, the host activity should + * have set the contact picking mode for us so that we may update our visuals. + */ + @Override + public void onActivityCreated(final Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + Assert.isTrue(mContactPickingMode != MODE_UNDEFINED); + updateVisualsForContactPickingMode(false /* animate */); + mHost.invalidateActionBar(); + } + + @Override + public void onDestroy() { + super.onDestroy(); + // We could not have bound to the data if the permission was denied. + if (mBinding.isBound()) { + mBinding.unbind(); + } + + if (mMonitor != null) { + mMonitor.unregister(); + } + mMonitor = null; + } + + @Override + public boolean onMenuItemClick(final MenuItem menuItem) { + switch (menuItem.getItemId()) { + case R.id.action_ime_dialpad_toggle: + final int baseInputType = InputType.TYPE_TEXT_FLAG_MULTI_LINE; + if ((mRecipientTextView.getInputType() & InputType.TYPE_CLASS_PHONE) != + InputType.TYPE_CLASS_PHONE) { + mRecipientTextView.setInputType(baseInputType | InputType.TYPE_CLASS_PHONE); + menuItem.setIcon(R.drawable.ic_ime_light); + } else { + mRecipientTextView.setInputType(baseInputType | InputType.TYPE_CLASS_TEXT); + menuItem.setIcon(R.drawable.ic_numeric_dialpad); + } + ImeUtil.get().showImeKeyboard(getActivity(), mRecipientTextView); + return true; + + case R.id.action_add_more_participants: + mHost.onInitiateAddMoreParticipants(); + return true; + + case R.id.action_confirm_participants: + maybeGetOrCreateConversation(); + return true; + + case R.id.action_delete_text: + Assert.equals(MODE_PICK_INITIAL_CONTACT, mContactPickingMode); + mRecipientTextView.setText(""); + return true; + } + return false; + } + + @Override // From ContactPickerDataListener + public void onAllContactsCursorUpdated(final Cursor data) { + mBinding.ensureBound(); + mAllContactsListViewHolder.onContactsCursorUpdated(data); + } + + @Override // From ContactPickerDataListener + public void onFrequentContactsCursorUpdated(final Cursor data) { + mBinding.ensureBound(); + mFrequentContactsListViewHolder.onContactsCursorUpdated(data); + if (data != null && data.getCount() == 0) { + // Show the all contacts list when there's no frequents. + mCustomHeaderViewPager.setCurrentItem(1); + } + } + + @Override // From ContactListItemView.HostInterface + public void onContactListItemClicked(final ContactListItemData item, + final ContactListItemView view) { + if (!isContactSelected(item)) { + if (mContactPickingMode == MODE_PICK_INITIAL_CONTACT) { + mPendingExplodeView = view; + } + mRecipientTextView.appendRecipientEntry(item.getRecipientEntry()); + } else if (mContactPickingMode != MODE_PICK_INITIAL_CONTACT) { + mRecipientTextView.removeRecipientEntry(item.getRecipientEntry()); + } + } + + @Override // From ContactListItemView.HostInterface + public boolean isContactSelected(final ContactListItemData item) { + return mSelectedPhoneNumbers != null && + mSelectedPhoneNumbers.contains(PhoneUtils.getDefault().getCanonicalBySystemLocale( + item.getRecipientEntry().getDestination())); + } + + /** + * Call this immediately after attaching the fragment, or when there's a ui state change that + * changes our host (i.e. restore from saved instance state). + */ + public void setHost(final ContactPickerFragmentHost host) { + mHost = host; + } + + public void setContactPickingMode(final int mode, final boolean animate) { + if (mContactPickingMode != mode) { + // Guard against impossible transitions. + Assert.isTrue( + // We may start from undefined mode to any mode when we are restoring state. + (mContactPickingMode == MODE_UNDEFINED) || + (mContactPickingMode == MODE_PICK_INITIAL_CONTACT && mode == MODE_CHIPS_ONLY) || + (mContactPickingMode == MODE_CHIPS_ONLY && mode == MODE_PICK_MORE_CONTACTS) || + (mContactPickingMode == MODE_PICK_MORE_CONTACTS + && mode == MODE_PICK_MAX_PARTICIPANTS) || + (mContactPickingMode == MODE_PICK_MAX_PARTICIPANTS + && mode == MODE_PICK_MORE_CONTACTS)); + + mContactPickingMode = mode; + updateVisualsForContactPickingMode(animate); + } + } + + private void showImeKeyboard() { + Assert.notNull(mRecipientTextView); + mRecipientTextView.requestFocus(); + + // showImeKeyboard() won't work until the layout is ready, so wait until layout is complete + // before showing the soft keyboard. + UiUtils.doOnceAfterLayoutChange(mRootView, new Runnable() { + @Override + public void run() { + final Activity activity = getActivity(); + if (activity != null) { + ImeUtil.get().showImeKeyboard(activity, mRecipientTextView); + } + } + }); + mRecipientTextView.invalidate(); + } + + private void updateVisualsForContactPickingMode(final boolean animate) { + // Don't update visuals if the visuals haven't been inflated yet. + if (mRootView != null) { + final Menu menu = mToolbar.getMenu(); + final MenuItem addMoreParticipantsItem = menu.findItem( + R.id.action_add_more_participants); + final MenuItem confirmParticipantsItem = menu.findItem( + R.id.action_confirm_participants); + switch (mContactPickingMode) { + case MODE_PICK_INITIAL_CONTACT: + addMoreParticipantsItem.setVisible(false); + confirmParticipantsItem.setVisible(false); + mCustomHeaderViewPager.setVisibility(View.VISIBLE); + mComposeDivider.setVisibility(View.INVISIBLE); + mRecipientTextView.setEnabled(true); + showImeKeyboard(); + break; + + case MODE_CHIPS_ONLY: + if (animate) { + if (mPendingExplodeView == null) { + // The user didn't click on any contact item, so use the toolbar as + // the view to "explode." + mPendingExplodeView = mToolbar; + } + startExplodeTransitionForContactLists(false /* show */); + + ViewGroupItemVerticalExplodeAnimation.startAnimationForView( + mCustomHeaderViewPager, mPendingExplodeView, mRootView, + true /* snapshotView */, UiUtils.COMPOSE_TRANSITION_DURATION); + showHideContactPagerWithAnimation(false /* show */); + } else { + mCustomHeaderViewPager.setVisibility(View.GONE); + } + + addMoreParticipantsItem.setVisible(true); + confirmParticipantsItem.setVisible(false); + mComposeDivider.setVisibility(View.VISIBLE); + mRecipientTextView.setEnabled(true); + break; + + case MODE_PICK_MORE_CONTACTS: + if (animate) { + // Correctly set the start visibility state for the view pager and + // individual list items (hidden initially), so that the transition + // manager can properly track the visibility change for the explode. + mCustomHeaderViewPager.setVisibility(View.VISIBLE); + toggleContactListItemsVisibilityForPendingTransition(false /* show */); + startExplodeTransitionForContactLists(true /* show */); + } + addMoreParticipantsItem.setVisible(false); + confirmParticipantsItem.setVisible(true); + mCustomHeaderViewPager.setVisibility(View.VISIBLE); + mComposeDivider.setVisibility(View.INVISIBLE); + mRecipientTextView.setEnabled(true); + showImeKeyboard(); + break; + + case MODE_PICK_MAX_PARTICIPANTS: + addMoreParticipantsItem.setVisible(false); + confirmParticipantsItem.setVisible(true); + mCustomHeaderViewPager.setVisibility(View.VISIBLE); + mComposeDivider.setVisibility(View.INVISIBLE); + // TODO: Verify that this is okay for accessibility + mRecipientTextView.setEnabled(false); + break; + + default: + Assert.fail("Unsupported contact picker mode!"); + break; + } + updateTextInputButtonsVisibility(); + } + } + + private void updateTextInputButtonsVisibility() { + final Menu menu = mToolbar.getMenu(); + final MenuItem keypadToggleItem = menu.findItem(R.id.action_ime_dialpad_toggle); + final MenuItem deleteTextItem = menu.findItem(R.id.action_delete_text); + if (mContactPickingMode == MODE_PICK_INITIAL_CONTACT) { + if (TextUtils.isEmpty(mRecipientTextView.getText())) { + deleteTextItem.setVisible(false); + keypadToggleItem.setVisible(true); + } else { + deleteTextItem.setVisible(true); + keypadToggleItem.setVisible(false); + } + } else { + deleteTextItem.setVisible(false); + keypadToggleItem.setVisible(false); + } + } + + private void maybeGetOrCreateConversation() { + final ArrayList<ParticipantData> participants = + mRecipientTextView.getRecipientParticipantDataForConversationCreation(); + if (ContactPickerData.isTooManyParticipants(participants.size())) { + UiUtils.showToast(R.string.too_many_participants); + } else if (participants.size() > 0 && mMonitor == null) { + mMonitor = GetOrCreateConversationAction.getOrCreateConversation(participants, + null, this); + } + } + + /** + * Watches changes in contact chips to determine possible state transitions (e.g. creating + * the initial conversation, adding more participants or finish the current conversation) + */ + @Override + public void onContactChipsChanged(final int oldCount, final int newCount) { + Assert.isTrue(oldCount != newCount); + if (mContactPickingMode == MODE_PICK_INITIAL_CONTACT) { + // Initial picking mode. Start a conversation once a recipient has been picked. + maybeGetOrCreateConversation(); + } else if (mContactPickingMode == MODE_CHIPS_ONLY) { + // oldCount == 0 means we are restoring from savedInstanceState to add the existing + // chips, don't switch to "add more participants" mode in this case. + if (oldCount > 0 && mRecipientTextView.isFocused()) { + // Chips only mode. The user may have picked an additional contact or deleted the + // only existing contact. Either way, switch to picking more participants mode. + mHost.onInitiateAddMoreParticipants(); + } + } + mHost.onParticipantCountChanged(ContactPickerData.getCanAddMoreParticipants(newCount)); + + // Refresh our local copy of the selected chips set to keep it up-to-date. + mSelectedPhoneNumbers = mRecipientTextView.getSelectedDestinations(); + invalidateContactLists(); + } + + /** + * Listens for notification that invalid contacts have been removed during resolving them. + * These contacts were not local contacts, valid email, or valid phone numbers + */ + @Override + public void onInvalidContactChipsPruned(final int prunedCount) { + Assert.isTrue(prunedCount > 0); + UiUtils.showToast(R.plurals.add_invalid_contact_error, prunedCount); + } + + /** + * Listens for notification that the user has pressed enter/done on the keyboard with all + * contacts in place and we should create or go to the existing conversation now + */ + @Override + public void onEntryComplete() { + if (mContactPickingMode == MODE_PICK_INITIAL_CONTACT || + mContactPickingMode == MODE_PICK_MORE_CONTACTS || + mContactPickingMode == MODE_PICK_MAX_PARTICIPANTS) { + // Avoid multiple calls to create in race cases (hit done right after selecting contact) + maybeGetOrCreateConversation(); + } + } + + private void invalidateContactLists() { + mAllContactsListViewHolder.invalidateList(); + mFrequentContactsListViewHolder.invalidateList(); + } + + /** + * Kicks off a scene transition that animates visibility changes of individual contact list + * items via explode animation. + * @param show whether the contact lists are to be shown or hidden. + */ + private void startExplodeTransitionForContactLists(final boolean show) { + if (!OsUtil.isAtLeastL()) { + // Explode animation is not supported pre-L. + return; + } + final Explode transition = new Explode(); + final Rect epicenter = mPendingExplodeView == null ? null : + UiUtils.getMeasuredBoundsOnScreen(mPendingExplodeView); + transition.setDuration(UiUtils.COMPOSE_TRANSITION_DURATION); + transition.setInterpolator(UiUtils.EASE_IN_INTERPOLATOR); + transition.setEpicenterCallback(new EpicenterCallback() { + @Override + public Rect onGetEpicenter(final Transition transition) { + return epicenter; + } + }); + + // Kick off the delayed scene explode transition. Anything happens after this line in this + // method before the next frame will be tracked by the transition manager for visibility + // changes and animated accordingly. + TransitionManager.beginDelayedTransition(mCustomHeaderViewPager, + transition); + + toggleContactListItemsVisibilityForPendingTransition(show); + } + + /** + * Toggle the visibility of contact list items in the contact lists for them to be tracked by + * the transition manager for pending explode transition. + */ + private void toggleContactListItemsVisibilityForPendingTransition(final boolean show) { + if (!OsUtil.isAtLeastL()) { + // Explode animation is not supported pre-L. + return; + } + mAllContactsListViewHolder.toggleVisibilityForPendingTransition(show, mPendingExplodeView); + mFrequentContactsListViewHolder.toggleVisibilityForPendingTransition(show, + mPendingExplodeView); + } + + private void showHideContactPagerWithAnimation(final boolean show) { + final boolean isPagerVisible = (mCustomHeaderViewPager.getVisibility() == View.VISIBLE); + if (show == isPagerVisible) { + return; + } + + mCustomHeaderViewPager.animate().alpha(show ? 1F : 0F) + .setStartDelay(!show ? UiUtils.COMPOSE_TRANSITION_DURATION : 0) + .withStartAction(new Runnable() { + @Override + public void run() { + mCustomHeaderViewPager.setVisibility(View.VISIBLE); + mCustomHeaderViewPager.setAlpha(show ? 0F : 1F); + } + }) + .withEndAction(new Runnable() { + @Override + public void run() { + mCustomHeaderViewPager.setVisibility(show ? View.VISIBLE : View.GONE); + mCustomHeaderViewPager.setAlpha(1F); + } + }); + } + + @Override + public void onContactCustomColorLoaded(final ContactPickerData data) { + mBinding.ensureBound(data); + invalidateContactLists(); + } + + public void updateActionBar(final ActionBar actionBar) { + // Hide the action bar for contact picker mode. The custom ToolBar containing chips UI + // etc. will take the spot of the action bar. + actionBar.hide(); + UiUtils.setStatusBarColor(getActivity(), + getResources().getColor(R.color.compose_notification_bar_background)); + } + + private GetOrCreateConversationActionMonitor mMonitor; + + @Override + @RunsOnMainThread + public void onGetOrCreateConversationSucceeded(final ActionMonitor monitor, + final Object data, final String conversationId) { + Assert.isTrue(monitor == mMonitor); + Assert.isTrue(conversationId != null); + + mRecipientTextView.setInputType(InputType.TYPE_TEXT_FLAG_MULTI_LINE | + InputType.TYPE_CLASS_TEXT); + mHost.onGetOrCreateNewConversation(conversationId); + + mMonitor = null; + } + + @Override + @RunsOnMainThread + public void onGetOrCreateConversationFailed(final ActionMonitor monitor, + final Object data) { + Assert.isTrue(monitor == mMonitor); + LogUtil.e(LogUtil.BUGLE_TAG, "onGetOrCreateConversationFailed"); + mMonitor = null; + } +} diff --git a/src/com/android/messaging/ui/contact/ContactRecipientAdapter.java b/src/com/android/messaging/ui/contact/ContactRecipientAdapter.java new file mode 100644 index 0000000..25f422e --- /dev/null +++ b/src/com/android/messaging/ui/contact/ContactRecipientAdapter.java @@ -0,0 +1,286 @@ +/* + * 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.ui.contact; + +import android.content.Context; +import android.database.Cursor; +import android.database.MergeCursor; +import android.support.v4.util.Pair; +import android.text.TextUtils; +import android.text.util.Rfc822Token; +import android.text.util.Rfc822Tokenizer; +import android.widget.Filter; + +import com.android.ex.chips.BaseRecipientAdapter; +import com.android.ex.chips.RecipientAlternatesAdapter; +import com.android.ex.chips.RecipientAlternatesAdapter.RecipientMatchCallback; +import com.android.ex.chips.RecipientEntry; +import com.android.messaging.util.Assert; +import com.android.messaging.util.Assert.DoesNotRunOnMainThread; +import com.android.messaging.util.BugleGservices; +import com.android.messaging.util.BugleGservicesKeys; +import com.android.messaging.util.ContactRecipientEntryUtils; +import com.android.messaging.util.ContactUtil; +import com.android.messaging.util.PhoneUtils; + +import java.text.Collator; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +/** + * An extension on the base {@link BaseRecipientAdapter} that uses data layer from Bugle, + * such as the ContactRecipientPhotoManager that uses our own MediaResourceManager, and + * contact lookup that relies on ContactUtil. It provides data source and filtering ability + * for {@link ContactRecipientAutoCompleteView} + */ +public final class ContactRecipientAdapter extends BaseRecipientAdapter { + public ContactRecipientAdapter(final Context context, + final ContactListItemView.HostInterface clivHost) { + this(context, Integer.MAX_VALUE, QUERY_TYPE_PHONE, clivHost); + } + + public ContactRecipientAdapter(final Context context, final int preferredMaxResultCount, + final int queryMode, final ContactListItemView.HostInterface clivHost) { + super(context, preferredMaxResultCount, queryMode); + setPhotoManager(new ContactRecipientPhotoManager(context, clivHost)); + } + + @Override + public boolean forceShowAddress() { + // We should always use the SingleRecipientAddressAdapter + // And never use the RecipientAlternatesAdapter + return true; + } + + @Override + public Filter getFilter() { + return new ContactFilter(); + } + + /** + * A Filter for a RecipientEditTextView that queries Bugle's ContactUtil for auto-complete + * results. + */ + public class ContactFilter extends Filter { + // Used to sort filtered contacts when it has combined results from email and phone. + private final RecipientEntryComparator mComparator = new RecipientEntryComparator(); + + /** + * Returns a cursor containing the filtered results in contacts given the search text, + * and a boolean indicating whether the results are sorted. + * + * The queries are synchronously performed since this is not run on the main thread. + * + * Some locales (e.g. JPN) expect email addresses to be auto-completed for MMS. + * If this is the case, perform two queries on phone number followed by email and + * return the merged results. + */ + @DoesNotRunOnMainThread + private Pair<Cursor, Boolean> getFilteredResultsCursor(final Context context, + final String searchText) { + Assert.isNotMainThread(); + if (BugleGservices.get().getBoolean( + BugleGservicesKeys.ALWAYS_AUTOCOMPLETE_EMAIL_ADDRESS, + BugleGservicesKeys.ALWAYS_AUTOCOMPLETE_EMAIL_ADDRESS_DEFAULT)) { + return Pair.create((Cursor) new MergeCursor(new Cursor[] { + ContactUtil.filterPhones(getContext(), searchText) + .performSynchronousQuery(), + ContactUtil.filterEmails(getContext(), searchText) + .performSynchronousQuery() + }), false /* the merged cursor is not sorted */); + } else { + return Pair.create(ContactUtil.filterDestination(getContext(), searchText) + .performSynchronousQuery(), true); + } + } + + @Override + protected FilterResults performFiltering(final CharSequence constraint) { + Assert.isNotMainThread(); + final FilterResults results = new FilterResults(); + + // No query, return empty results. + if (TextUtils.isEmpty(constraint)) { + clearTempEntries(); + return results; + } + + final String searchText = constraint.toString(); + + // Query for auto-complete results, since performFiltering() is not done on the + // main thread, perform the cursor loader queries directly. + final Pair<Cursor, Boolean> filteredResults = getFilteredResultsCursor(getContext(), + searchText); + final Cursor cursor = filteredResults.first; + final boolean sorted = filteredResults.second; + if (cursor != null) { + try { + final List<RecipientEntry> entries = new ArrayList<RecipientEntry>(); + + // First check if the constraint is a valid SMS destination. If so, add the + // destination as a suggestion item to the drop down. + if (PhoneUtils.isValidSmsMmsDestination(searchText)) { + entries.add(ContactRecipientEntryUtils + .constructSendToDestinationEntry(searchText)); + } + + HashSet<Long> existingContactIds = new HashSet<Long>(); + while (cursor.moveToNext()) { + // Make sure there's only one first-level contact (i.e. contact for which + // we show the avatar picture and name) for every contact id. + final long contactId = cursor.getLong(ContactUtil.INDEX_CONTACT_ID); + final boolean isFirstLevel = !existingContactIds.contains(contactId); + if (isFirstLevel) { + existingContactIds.add(contactId); + } + entries.add(ContactUtil.createRecipientEntryForPhoneQuery(cursor, + isFirstLevel)); + } + + if (!sorted) { + Collections.sort(entries, mComparator); + } + results.values = entries; + results.count = 1; + + } finally { + cursor.close(); + } + } + return results; + } + + @Override + protected void publishResults(final CharSequence constraint, final FilterResults results) { + mCurrentConstraint = constraint; + clearTempEntries(); + + if (results.values != null) { + @SuppressWarnings("unchecked") + final List<RecipientEntry> entries = (List<RecipientEntry>) results.values; + updateEntries(entries); + } else { + updateEntries(Collections.<RecipientEntry>emptyList()); + } + } + + private class RecipientEntryComparator implements Comparator<RecipientEntry> { + private final Collator mCollator; + + public RecipientEntryComparator() { + mCollator = Collator.getInstance(Locale.getDefault()); + mCollator.setStrength(Collator.PRIMARY); + } + + /** + * Compare two RecipientEntry's, first by locale-aware display name comparison, then by + * contact id comparison, finally by first-level-ness comparison. + */ + @Override + public int compare(RecipientEntry lhs, RecipientEntry rhs) { + // Send-to-destinations always appear before everything else. + final boolean sendToLhs = ContactRecipientEntryUtils + .isSendToDestinationContact(lhs); + final boolean sendToRhs = ContactRecipientEntryUtils + .isSendToDestinationContact(lhs); + if (sendToLhs != sendToRhs) { + if (sendToLhs) { + return -1; + } else if (sendToRhs) { + return 1; + } + } + + final int displayNameCompare = mCollator.compare(lhs.getDisplayName(), + rhs.getDisplayName()); + if (displayNameCompare != 0) { + return displayNameCompare; + } + + // Long.compare could accomplish the following three lines, but this is only + // available in API 19+ + final long lhsContactId = lhs.getContactId(); + final long rhsContactId = rhs.getContactId(); + final int contactCompare = lhsContactId < rhsContactId ? -1 : + (lhsContactId == rhsContactId ? 0 : 1); + if (contactCompare != 0) { + return contactCompare; + } + + // These are the same contact. Make sure first-level contacts always + // appear at the front. + if (lhs.isFirstLevel()) { + return -1; + } else if (rhs.isFirstLevel()) { + return 1; + } else { + return 0; + } + } + } + } + + /** + * Called when we need to substitute temporary recipient chips with better alternatives. + * For example, if a list of comma-delimited phone numbers are pasted into the edit box, + * we want to be able to look up in the ContactUtil for exact matches and get contact + * details such as name and photo thumbnail for the contact to display a better chip. + */ + @Override + public void getMatchingRecipients(final ArrayList<String> inAddresses, + final RecipientMatchCallback callback) { + final int addressesSize = Math.min( + RecipientAlternatesAdapter.MAX_LOOKUPS, inAddresses.size()); + final HashSet<String> addresses = new HashSet<String>(); + for (int i = 0; i < addressesSize; i++) { + final Rfc822Token[] tokens = Rfc822Tokenizer.tokenize(inAddresses.get(i).toLowerCase()); + addresses.add(tokens.length > 0 ? tokens[0].getAddress() : inAddresses.get(i)); + } + + final Map<String, RecipientEntry> recipientEntries = + new HashMap<String, RecipientEntry>(); + // query for each address + for (final String address : addresses) { + final Cursor cursor = ContactUtil.lookupDestination(getContext(), address) + .performSynchronousQuery(); + if (cursor != null) { + try { + if (cursor.moveToNext()) { + // There may be multiple matches to the same number, always take the + // first match. + // TODO: May need to consider if there's an existing conversation + // that matches this particular contact and prioritize that contact. + final RecipientEntry entry = + ContactUtil.createRecipientEntryForPhoneQuery(cursor, true); + recipientEntries.put(address, entry); + } + + } finally { + cursor.close(); + } + } + } + + // report matches + callback.matchesFound(recipientEntries); + } +} diff --git a/src/com/android/messaging/ui/contact/ContactRecipientAutoCompleteView.java b/src/com/android/messaging/ui/contact/ContactRecipientAutoCompleteView.java new file mode 100644 index 0000000..c7c2731 --- /dev/null +++ b/src/com/android/messaging/ui/contact/ContactRecipientAutoCompleteView.java @@ -0,0 +1,289 @@ +/* + * 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.ui.contact; + +import android.content.Context; +import android.database.Cursor; +import android.graphics.Rect; +import android.os.AsyncTask; +import android.support.v7.appcompat.R; +import android.text.Editable; +import android.text.TextPaint; +import android.text.TextWatcher; +import android.text.util.Rfc822Tokenizer; +import android.util.AttributeSet; +import android.view.ContextThemeWrapper; +import android.view.KeyEvent; +import android.view.inputmethod.EditorInfo; +import android.widget.TextView; + +import com.android.ex.chips.RecipientEditTextView; +import com.android.ex.chips.RecipientEntry; +import com.android.ex.chips.recipientchip.DrawableRecipientChip; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.util.ContactRecipientEntryUtils; +import com.android.messaging.util.ContactUtil; +import com.android.messaging.util.PhoneUtils; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; + +/** + * An extension for {@link RecipientEditTextView} which shows a list of Materialized contact chips. + * It uses Bugle's ContactUtil to perform contact lookup, and is able to return the list of + * recipients in the form of a ParticipantData list. + */ +public class ContactRecipientAutoCompleteView extends RecipientEditTextView { + public interface ContactChipsChangeListener { + void onContactChipsChanged(int oldCount, int newCount); + void onInvalidContactChipsPruned(int prunedCount); + void onEntryComplete(); + } + + private final int mTextHeight; + private ContactChipsChangeListener mChipsChangeListener; + + /** + * Watches changes in contact chips to determine possible state transitions. + */ + private class ContactChipsWatcher implements TextWatcher { + /** + * Tracks the old chips count before text changes. Note that we currently don't compare + * the entire chip sets but just the cheaper-to-do before and after counts, because + * the chips view don't allow for replacing chips. + */ + private int mLastChipsCount = 0; + + @Override + public void onTextChanged(final CharSequence s, final int start, final int before, + final int count) { + } + + @Override + public void beforeTextChanged(final CharSequence s, final int start, final int count, + final int after) { + // We don't take mLastChipsCount from here but from the last afterTextChanged() run. + // The reason is because at this point, any chip spans to be removed is already removed + // from s in the chips text view. + } + + @Override + public void afterTextChanged(final Editable s) { + final int currentChipsCount = s.getSpans(0, s.length(), + DrawableRecipientChip.class).length; + if (currentChipsCount != mLastChipsCount) { + // When a sanitizing task is running, we don't want to notify any chips count + // change, but we do want to track the last chip count. + if (mChipsChangeListener != null && mCurrentSanitizeTask == null) { + mChipsChangeListener.onContactChipsChanged(mLastChipsCount, currentChipsCount); + } + mLastChipsCount = currentChipsCount; + } + } + } + + private static final String TEXT_HEIGHT_SAMPLE = "a"; + + public ContactRecipientAutoCompleteView(final Context context, final AttributeSet attrs) { + super(new ContextThemeWrapper(context, R.style.ColorAccentGrayOverrideStyle), attrs); + + // Get the height of the text, given the currently set font face and size. + final Rect textBounds = new Rect(0, 0, 0, 0); + final TextPaint paint = getPaint(); + paint.getTextBounds(TEXT_HEIGHT_SAMPLE, 0, TEXT_HEIGHT_SAMPLE.length(), textBounds); + mTextHeight = textBounds.height(); + + setTokenizer(new Rfc822Tokenizer()); + addTextChangedListener(new ContactChipsWatcher()); + setOnFocusListShrinkRecipients(false); + + setBackground(context.getResources().getDrawable( + R.drawable.abc_textfield_search_default_mtrl_alpha)); + } + + public void setContactChipsListener(final ContactChipsChangeListener listener) { + mChipsChangeListener = listener; + } + + /** + * A tuple of chips which AsyncContactChipSanitizeTask reports as progress to have the + * chip actually replaced/removed on the UI thread. + */ + private class ChipReplacementTuple { + public final DrawableRecipientChip removedChip; + public final RecipientEntry replacedChipEntry; + + public ChipReplacementTuple(final DrawableRecipientChip removedChip, + final RecipientEntry replacedChipEntry) { + this.removedChip = removedChip; + this.replacedChipEntry = replacedChipEntry; + } + } + + /** + * An AsyncTask that cleans up contact chips on every chips commit (i.e. get or create a new + * conversation with the given chips). + */ + private class AsyncContactChipSanitizeTask extends + AsyncTask<Void, ChipReplacementTuple, Integer> { + + @Override + protected Integer doInBackground(final Void... params) { + final DrawableRecipientChip[] recips = getText() + .getSpans(0, getText().length(), DrawableRecipientChip.class); + int invalidChipsRemoved = 0; + for (final DrawableRecipientChip recipient : recips) { + final RecipientEntry entry = recipient.getEntry(); + if (entry != null) { + if (entry.isValid()) { + if (RecipientEntry.isCreatedRecipient(entry.getContactId()) || + ContactRecipientEntryUtils.isSendToDestinationContact(entry)) { + // This is a generated/send-to contact chip, try to look it up and + // display a chip for the corresponding local contact. + final Cursor lookupResult = ContactUtil.lookupDestination(getContext(), + entry.getDestination()).performSynchronousQuery(); + if (lookupResult != null && lookupResult.moveToNext()) { + // Found a match, remove the generated entry and replace with + // a better local entry. + publishProgress(new ChipReplacementTuple(recipient, + ContactUtil.createRecipientEntryForPhoneQuery( + lookupResult, true))); + } else if (PhoneUtils.isValidSmsMmsDestination( + entry.getDestination())){ + // No match was found, but we have a valid destination so let's at + // least create an entry that shows an avatar. + publishProgress(new ChipReplacementTuple(recipient, + ContactRecipientEntryUtils.constructNumberWithAvatarEntry( + entry.getDestination()))); + } else { + // Not a valid contact. Remove and show an error. + publishProgress(new ChipReplacementTuple(recipient, null)); + invalidChipsRemoved++; + } + } + } else { + publishProgress(new ChipReplacementTuple(recipient, null)); + invalidChipsRemoved++; + } + } + } + return invalidChipsRemoved; + } + + @Override + protected void onProgressUpdate(final ChipReplacementTuple... values) { + for (final ChipReplacementTuple tuple : values) { + if (tuple.removedChip != null) { + final Editable text = getText(); + final int chipStart = text.getSpanStart(tuple.removedChip); + final int chipEnd = text.getSpanEnd(tuple.removedChip); + if (chipStart >= 0 && chipEnd >= 0) { + text.delete(chipStart, chipEnd); + } + + if (tuple.replacedChipEntry != null) { + appendRecipientEntry(tuple.replacedChipEntry); + } + } + } + } + + @Override + protected void onPostExecute(final Integer invalidChipsRemoved) { + mCurrentSanitizeTask = null; + if (invalidChipsRemoved > 0) { + mChipsChangeListener.onInvalidContactChipsPruned(invalidChipsRemoved); + } + } + } + + /** + * We don't use SafeAsyncTask but instead use a single threaded executor to ensure that + * all sanitization tasks are serially executed so as not to interfere with each other. + */ + private static final Executor SANITIZE_EXECUTOR = Executors.newSingleThreadExecutor(); + + private AsyncContactChipSanitizeTask mCurrentSanitizeTask; + + /** + * Whenever the caller wants to start a new conversation with the list of chips we have, + * make sure we asynchronously: + * 1. Remove invalid chips. + * 2. Attempt to resolve unknown contacts to known local contacts. + * 3. Convert still unknown chips to chips with generated avatar. + * + * Note that we don't need to perform this synchronously since we can + * resolve any unknown contacts to local contacts when needed. + */ + private void sanitizeContactChips() { + if (mCurrentSanitizeTask != null && !mCurrentSanitizeTask.isCancelled()) { + mCurrentSanitizeTask.cancel(false); + mCurrentSanitizeTask = null; + } + mCurrentSanitizeTask = new AsyncContactChipSanitizeTask(); + mCurrentSanitizeTask.executeOnExecutor(SANITIZE_EXECUTOR); + } + + /** + * Returns a list of ParticipantData from the entered chips in order to create + * new conversation. + */ + public ArrayList<ParticipantData> getRecipientParticipantDataForConversationCreation() { + final DrawableRecipientChip[] recips = getText() + .getSpans(0, getText().length(), DrawableRecipientChip.class); + final ArrayList<ParticipantData> contacts = + new ArrayList<ParticipantData>(recips.length); + for (final DrawableRecipientChip recipient : recips) { + final RecipientEntry entry = recipient.getEntry(); + if (entry != null && entry.isValid() && entry.getDestination() != null && + PhoneUtils.isValidSmsMmsDestination(entry.getDestination())) { + contacts.add(ParticipantData.getFromRecipientEntry(recipient.getEntry())); + } + } + sanitizeContactChips(); + return contacts; + } + + /**c + * Gets a set of currently selected chips' emails/phone numbers. This will facilitate the + * consumer with determining quickly whether a contact is currently selected. + */ + public Set<String> getSelectedDestinations() { + Set<String> set = new HashSet<String>(); + final DrawableRecipientChip[] recips = getText() + .getSpans(0, getText().length(), DrawableRecipientChip.class); + + for (final DrawableRecipientChip recipient : recips) { + final RecipientEntry entry = recipient.getEntry(); + if (entry != null && entry.isValid() && entry.getDestination() != null) { + set.add(PhoneUtils.getDefault().getCanonicalBySystemLocale( + entry.getDestination())); + } + } + return set; + } + + @Override + public boolean onEditorAction(final TextView view, final int actionId, final KeyEvent event) { + if (actionId == EditorInfo.IME_ACTION_DONE) { + mChipsChangeListener.onEntryComplete(); + } + return super.onEditorAction(view, actionId, event); + } +} diff --git a/src/com/android/messaging/ui/contact/ContactRecipientPhotoManager.java b/src/com/android/messaging/ui/contact/ContactRecipientPhotoManager.java new file mode 100644 index 0000000..d69ba64 --- /dev/null +++ b/src/com/android/messaging/ui/contact/ContactRecipientPhotoManager.java @@ -0,0 +1,96 @@ +/* + * 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.ui.contact; + +import android.content.Context; +import android.net.Uri; + +import com.android.ex.chips.PhotoManager; +import com.android.ex.chips.RecipientEntry; +import com.android.messaging.Factory; +import com.android.messaging.R; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.datamodel.media.AvatarRequestDescriptor; +import com.android.messaging.datamodel.media.BindableMediaRequest; +import com.android.messaging.datamodel.media.ImageResource; +import com.android.messaging.datamodel.media.MediaRequest; +import com.android.messaging.datamodel.media.MediaResourceManager; +import com.android.messaging.datamodel.media.MediaResourceManager.MediaResourceLoadListener; +import com.android.messaging.util.AvatarUriUtil; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.ThreadUtil; + +/** + * An implementation of {@link PhotoManager} that hooks up the chips UI's photos with our own + * {@link MediaResourceManager} for retrieving and caching contact avatars. + */ +public class ContactRecipientPhotoManager implements PhotoManager { + private static final String IMAGE_BYTES_REQUEST_STATIC_BINDING_ID = "imagebytes"; + private final Context mContext; + private final int mIconSize; + private final ContactListItemView.HostInterface mClivHostInterface; + + public ContactRecipientPhotoManager(final Context context, + final ContactListItemView.HostInterface clivHostInterface) { + mContext = context; + mIconSize = context.getResources().getDimensionPixelSize( + R.dimen.compose_message_chip_height) - context.getResources().getDimensionPixelSize( + R.dimen.compose_message_chip_padding) * 2; + mClivHostInterface = clivHostInterface; + } + + /** + * {@inheritDoc} + */ + @Override + public void populatePhotoBytesAsync(final RecipientEntry entry, + final PhotoManagerCallback callback) { + // Post all media resource request to the main thread. + ThreadUtil.getMainThreadHandler().post(new Runnable() { + @Override + public void run() { + final Uri avatarUri = AvatarUriUtil.createAvatarUri( + ParticipantData.getFromRecipientEntry(entry)); + final AvatarRequestDescriptor descriptor = + new AvatarRequestDescriptor(avatarUri, mIconSize, mIconSize); + final BindableMediaRequest<ImageResource> req = descriptor.buildAsyncMediaRequest( + mContext, + new MediaResourceLoadListener<ImageResource>() { + @Override + public void onMediaResourceLoaded(final MediaRequest<ImageResource> request, + final ImageResource resource, final boolean isCached) { + entry.setPhotoBytes(resource.getBytes()); + callback.onPhotoBytesAsynchronouslyPopulated(); + } + + @Override + public void onMediaResourceLoadError(final MediaRequest<ImageResource> request, + final Exception exception) { + LogUtil.e(LogUtil.BUGLE_TAG, "Photo bytes loading failed due to " + + exception + " request key=" + request.getKey()); + + // Fall back to the default avatar image. + callback.onPhotoBytesAsyncLoadFailed(); + }}); + + // Statically bind the request since it's not bound to any specific piece of UI. + req.bind(IMAGE_BYTES_REQUEST_STATIC_BINDING_ID); + + Factory.get().getMediaResourceManager().requestMediaResourceAsync(req); + } + }); + } +} diff --git a/src/com/android/messaging/ui/contact/ContactSectionIndexer.java b/src/com/android/messaging/ui/contact/ContactSectionIndexer.java new file mode 100644 index 0000000..1d5abf3 --- /dev/null +++ b/src/com/android/messaging/ui/contact/ContactSectionIndexer.java @@ -0,0 +1,169 @@ +/* + * 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.ui.contact; + +import android.database.Cursor; +import android.os.Bundle; +import android.provider.ContactsContract.Contacts; +import android.text.TextUtils; +import android.widget.SectionIndexer; + +import com.android.messaging.util.Assert; +import com.android.messaging.util.ContactUtil; +import com.android.messaging.util.LogUtil; + +import java.util.ArrayList; + +/** + * Indexes contact alphabetical sections so we can report to the fast scrolling list view + * where we are in the list when the user scrolls through the contact list, allowing us to show + * alphabetical indicators for the fast scroller as well as list section headers. + */ +public class ContactSectionIndexer implements SectionIndexer { + private String[] mSections; + private ArrayList<Integer> mSectionStartingPositions; + private static final String BLANK_HEADER_STRING = " "; + + public ContactSectionIndexer(final Cursor contactsCursor) { + buildIndexer(contactsCursor); + } + + @Override + public Object[] getSections() { + return mSections; + } + + @Override + public int getPositionForSection(final int sectionIndex) { + if (mSectionStartingPositions.isEmpty()) { + return 0; + } + // Clamp to the bounds of the section position array per Android API doc. + return mSectionStartingPositions.get( + Math.max(Math.min(sectionIndex, mSectionStartingPositions.size() - 1), 0)); + } + + @Override + public int getSectionForPosition(final int position) { + if (mSectionStartingPositions.isEmpty()) { + return 0; + } + + // Perform a binary search on the starting positions of the sections to the find the + // section for the position. + int left = 0; + int right = mSectionStartingPositions.size() - 1; + + // According to getSectionForPosition()'s doc, we should always clamp the value when the + // position is out of bound. + if (position <= mSectionStartingPositions.get(left)) { + return left; + } else if (position >= mSectionStartingPositions.get(right)) { + return right; + } + + while (left <= right) { + final int mid = (left + right) / 2; + final int startingPos = mSectionStartingPositions.get(mid); + final int nextStartingPos = mSectionStartingPositions.get(mid + 1); + if (position >= startingPos && position < nextStartingPos) { + return mid; + } else if (position < startingPos) { + right = mid - 1; + } else if (position >= nextStartingPos) { + left = mid + 1; + } + } + Assert.fail("Invalid section indexer state: couldn't find section for pos " + position); + return -1; + } + + private boolean buildIndexerFromCursorExtras(final Cursor cursor) { + if (cursor == null) { + return false; + } + final Bundle cursorExtras = cursor.getExtras(); + if (cursorExtras == null) { + return false; + } + final String[] sections = cursorExtras.getStringArray( + Contacts.EXTRA_ADDRESS_BOOK_INDEX_TITLES); + final int[] counts = cursorExtras.getIntArray(Contacts.EXTRA_ADDRESS_BOOK_INDEX_COUNTS); + if (sections == null || counts == null) { + return false; + } + + if (sections.length != counts.length) { + return false; + } + + this.mSections = sections; + mSectionStartingPositions = new ArrayList<Integer>(counts.length); + int position = 0; + for (int i = 0; i < counts.length; i++) { + if (TextUtils.isEmpty(mSections[i])) { + mSections[i] = BLANK_HEADER_STRING; + } else if (!mSections[i].equals(BLANK_HEADER_STRING)) { + mSections[i] = mSections[i].trim(); + } + + mSectionStartingPositions.add(position); + position += counts[i]; + } + return true; + } + + private void buildIndexerFromDisplayNames(final Cursor cursor) { + // Loop through the contact cursor and get the starting position for each first character. + // The result is stored into two arrays, one for the section header (i.e. the first + // character), and one for the starting position, which is guaranteed to be sorted in + // ascending order. + final ArrayList<String> sections = new ArrayList<String>(); + mSectionStartingPositions = new ArrayList<Integer>(); + if (cursor != null) { + cursor.moveToPosition(-1); + int currentPosition = 0; + while (cursor.moveToNext()) { + // The sort key is typically the contact's display name, so for example, a contact + // named "Bob" will go into section "B". The Contacts provider generally uses a + // a slightly more sophisticated heuristic, but as a fallback this is good enough. + final String sortKey = cursor.getString(ContactUtil.INDEX_SORT_KEY); + final String section = TextUtils.isEmpty(sortKey) ? BLANK_HEADER_STRING : + sortKey.substring(0, 1).toUpperCase(); + + final int lastIndex = sections.size() - 1; + final String currentSection = lastIndex >= 0 ? sections.get(lastIndex) : null; + if (!TextUtils.equals(currentSection, section)) { + sections.add(section); + mSectionStartingPositions.add(currentPosition); + } + currentPosition++; + } + } + mSections = new String[sections.size()]; + sections.toArray(mSections); + } + + private void buildIndexer(final Cursor cursor) { + // First check if we get indexer label extras from the contact provider; if not, fall back + // to building from display names. + if (!buildIndexerFromCursorExtras(cursor)) { + LogUtil.w(LogUtil.BUGLE_TAG, "contact provider didn't provide contact label " + + "information, fall back to using display name!"); + buildIndexerFromDisplayNames(cursor); + } + } +} diff --git a/src/com/android/messaging/ui/contact/FrequentContactsListViewHolder.java b/src/com/android/messaging/ui/contact/FrequentContactsListViewHolder.java new file mode 100644 index 0000000..1f3c795 --- /dev/null +++ b/src/com/android/messaging/ui/contact/FrequentContactsListViewHolder.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.ui.contact; + +import android.content.Context; + +import com.android.messaging.R; +import com.android.messaging.ui.CustomHeaderPagerListViewHolder; +import com.android.messaging.ui.contact.ContactListItemView.HostInterface; + +/** + * Holds the frequent contacts view for the contact picker's view pager. + */ +public class FrequentContactsListViewHolder extends CustomHeaderPagerListViewHolder { + public FrequentContactsListViewHolder(final Context context, + final HostInterface clivHostInterface) { + super(context, new ContactListAdapter(context, null, clivHostInterface, + false /* needAlphabetHeader */)); + } + + @Override + protected int getLayoutResId() { + return R.layout.frequent_contacts_list_view; + } + + @Override + protected int getPageTitleResId() { + return R.string.contact_picker_frequents_tab_title; + } + + @Override + protected int getEmptyViewResId() { + return R.id.empty_view; + } + + @Override + protected int getListViewResId() { + return R.id.frequent_contacts_list; + } + + @Override + protected int getEmptyViewTitleResId() { + return R.string.contact_list_empty_text; + } + + @Override + protected int getEmptyViewImageResId() { + return R.drawable.ic_oobe_freq_list; + } +} diff --git a/src/com/android/messaging/ui/conversation/ComposeMessageView.java b/src/com/android/messaging/ui/conversation/ComposeMessageView.java new file mode 100644 index 0000000..17f8f74 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/ComposeMessageView.java @@ -0,0 +1,962 @@ +/* + * 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.ui.conversation; + +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Rect; +import android.net.Uri; +import android.os.Bundle; +import android.support.v7.app.ActionBar; +import android.text.Editable; +import android.text.Html; +import android.text.InputFilter; +import android.text.InputFilter.LengthFilter; +import android.text.TextUtils; +import android.text.TextWatcher; +import android.util.AttributeSet; +import android.view.ContextThemeWrapper; +import android.view.KeyEvent; +import android.view.View; +import android.view.accessibility.AccessibilityEvent; +import android.view.inputmethod.EditorInfo; +import android.widget.ImageButton; +import android.widget.LinearLayout; +import android.widget.TextView; + +import com.android.messaging.Factory; +import com.android.messaging.R; +import com.android.messaging.datamodel.binding.Binding; +import com.android.messaging.datamodel.binding.BindingBase; +import com.android.messaging.datamodel.binding.ImmutableBindingRef; +import com.android.messaging.datamodel.data.ConversationData; +import com.android.messaging.datamodel.data.ConversationData.ConversationDataListener; +import com.android.messaging.datamodel.data.ConversationData.SimpleConversationDataListener; +import com.android.messaging.datamodel.data.DraftMessageData; +import com.android.messaging.datamodel.data.DraftMessageData.CheckDraftForSendTask; +import com.android.messaging.datamodel.data.DraftMessageData.CheckDraftTaskCallback; +import com.android.messaging.datamodel.data.DraftMessageData.DraftMessageDataListener; +import com.android.messaging.datamodel.data.MessageData; +import com.android.messaging.datamodel.data.MessagePartData; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.datamodel.data.PendingAttachmentData; +import com.android.messaging.datamodel.data.SubscriptionListData.SubscriptionListEntry; +import com.android.messaging.sms.MmsConfig; +import com.android.messaging.ui.AttachmentPreview; +import com.android.messaging.ui.BugleActionBarActivity; +import com.android.messaging.ui.PlainTextEditText; +import com.android.messaging.ui.conversation.ConversationInputManager.ConversationInputSink; +import com.android.messaging.util.AccessibilityUtil; +import com.android.messaging.util.Assert; +import com.android.messaging.util.AvatarUriUtil; +import com.android.messaging.util.BuglePrefs; +import com.android.messaging.util.ContentType; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.MediaUtil; +import com.android.messaging.util.OsUtil; +import com.android.messaging.util.UiUtils; + +import java.util.Collection; +import java.util.List; + +/** + * This view contains the UI required to generate and send messages. + */ +public class ComposeMessageView extends LinearLayout + implements TextView.OnEditorActionListener, DraftMessageDataListener, TextWatcher, + ConversationInputSink { + + public interface IComposeMessageViewHost extends + DraftMessageData.DraftMessageSubscriptionDataProvider { + void sendMessage(MessageData message); + void onComposeEditTextFocused(); + void onAttachmentsCleared(); + void onAttachmentsChanged(final boolean haveAttachments); + void displayPhoto(Uri photoUri, Rect imageBounds, boolean isDraft); + void promptForSelfPhoneNumber(); + boolean isReadyForAction(); + void warnOfMissingActionConditions(final boolean sending, + final Runnable commandToRunAfterActionConditionResolved); + void warnOfExceedingMessageLimit(final boolean showAttachmentChooser, + boolean tooManyVideos); + void notifyOfAttachmentLoadFailed(); + void showAttachmentChooser(); + boolean shouldShowSubjectEditor(); + boolean shouldHideAttachmentsWhenSimSelectorShown(); + Uri getSelfSendButtonIconUri(); + int overrideCounterColor(); + int getAttachmentsClearedFlags(); + } + + public static final int CODEPOINTS_REMAINING_BEFORE_COUNTER_SHOWN = 10; + + // There is no draft and there is no need for the SIM selector + private static final int SEND_WIDGET_MODE_SELF_AVATAR = 1; + // There is no draft but we need to show the SIM selector + private static final int SEND_WIDGET_MODE_SIM_SELECTOR = 2; + // There is a draft + private static final int SEND_WIDGET_MODE_SEND_BUTTON = 3; + + private PlainTextEditText mComposeEditText; + private PlainTextEditText mComposeSubjectText; + private TextView mCharCounter; + private TextView mMmsIndicator; + private SimIconView mSelfSendIcon; + private ImageButton mSendButton; + private View mSubjectView; + private ImageButton mDeleteSubjectButton; + private AttachmentPreview mAttachmentPreview; + private ImageButton mAttachMediaButton; + + private final Binding<DraftMessageData> mBinding; + private IComposeMessageViewHost mHost; + private final Context mOriginalContext; + private int mSendWidgetMode = SEND_WIDGET_MODE_SELF_AVATAR; + + // Shared data model object binding from the conversation. + private ImmutableBindingRef<ConversationData> mConversationDataModel; + + // Centrally manages all the mutual exclusive UI components accepting user input, i.e. + // media picker, IME keyboard and SIM selector. + private ConversationInputManager mInputManager; + + private final ConversationDataListener mDataListener = new SimpleConversationDataListener() { + @Override + public void onConversationMetadataUpdated(ConversationData data) { + mConversationDataModel.ensureBound(data); + updateVisualsOnDraftChanged(); + } + + @Override + public void onConversationParticipantDataLoaded(ConversationData data) { + mConversationDataModel.ensureBound(data); + updateVisualsOnDraftChanged(); + } + + @Override + public void onSubscriptionListDataLoaded(ConversationData data) { + mConversationDataModel.ensureBound(data); + updateOnSelfSubscriptionChange(); + updateVisualsOnDraftChanged(); + } + }; + + public ComposeMessageView(final Context context, final AttributeSet attrs) { + super(new ContextThemeWrapper(context, R.style.ColorAccentBlueOverrideStyle), attrs); + mOriginalContext = context; + mBinding = BindingBase.createBinding(this); + } + + /** + * Host calls this to bind view to DraftMessageData object + */ + public void bind(final DraftMessageData data, final IComposeMessageViewHost host) { + mHost = host; + mBinding.bind(data); + data.addListener(this); + data.setSubscriptionDataProvider(host); + + final int counterColor = mHost.overrideCounterColor(); + if (counterColor != -1) { + mCharCounter.setTextColor(counterColor); + } + } + + /** + * Host calls this to unbind view + */ + public void unbind() { + mBinding.unbind(); + mHost = null; + mInputManager.onDetach(); + } + + @Override + protected void onFinishInflate() { + mComposeEditText = (PlainTextEditText) findViewById( + R.id.compose_message_text); + mComposeEditText.setOnEditorActionListener(this); + mComposeEditText.addTextChangedListener(this); + mComposeEditText.setOnFocusChangeListener(new OnFocusChangeListener() { + @Override + public void onFocusChange(final View v, final boolean hasFocus) { + if (v == mComposeEditText && hasFocus) { + mHost.onComposeEditTextFocused(); + } + } + }); + mComposeEditText.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View arg0) { + if (mHost.shouldHideAttachmentsWhenSimSelectorShown()) { + hideSimSelector(); + } + } + }); + + // onFinishInflate() is called before self is loaded from db. We set the default text + // limit here, and apply the real limit later in updateOnSelfSubscriptionChange(). + mComposeEditText.setFilters(new InputFilter[] { + new LengthFilter(MmsConfig.get(ParticipantData.DEFAULT_SELF_SUB_ID) + .getMaxTextLimit()) }); + + mSelfSendIcon = (SimIconView) findViewById(R.id.self_send_icon); + mSelfSendIcon.setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + boolean shown = mInputManager.toggleSimSelector(true /* animate */, + getSelfSubscriptionListEntry()); + hideAttachmentsWhenShowingSims(shown); + } + }); + mSelfSendIcon.setOnLongClickListener(new OnLongClickListener() { + @Override + public boolean onLongClick(final View v) { + if (mHost.shouldShowSubjectEditor()) { + showSubjectEditor(); + } else { + boolean shown = mInputManager.toggleSimSelector(true /* animate */, + getSelfSubscriptionListEntry()); + hideAttachmentsWhenShowingSims(shown); + } + return true; + } + }); + + mComposeSubjectText = (PlainTextEditText) findViewById( + R.id.compose_subject_text); + // We need the listener to change the avatar to the send button when the user starts + // typing a subject without a message. + mComposeSubjectText.addTextChangedListener(this); + // onFinishInflate() is called before self is loaded from db. We set the default text + // limit here, and apply the real limit later in updateOnSelfSubscriptionChange(). + mComposeSubjectText.setFilters(new InputFilter[] { + new LengthFilter(MmsConfig.get(ParticipantData.DEFAULT_SELF_SUB_ID) + .getMaxSubjectLength())}); + + mDeleteSubjectButton = (ImageButton) findViewById(R.id.delete_subject_button); + mDeleteSubjectButton.setOnClickListener(new OnClickListener() { + @Override + public void onClick(final View clickView) { + hideSubjectEditor(); + mComposeSubjectText.setText(null); + mBinding.getData().setMessageSubject(null); + } + }); + + mSubjectView = findViewById(R.id.subject_view); + + mSendButton = (ImageButton) findViewById(R.id.send_message_button); + mSendButton.setOnClickListener(new OnClickListener() { + @Override + public void onClick(final View clickView) { + sendMessageInternal(true /* checkMessageSize */); + } + }); + mSendButton.setOnLongClickListener(new OnLongClickListener() { + @Override + public boolean onLongClick(final View arg0) { + boolean shown = mInputManager.toggleSimSelector(true /* animate */, + getSelfSubscriptionListEntry()); + hideAttachmentsWhenShowingSims(shown); + if (mHost.shouldShowSubjectEditor()) { + showSubjectEditor(); + } + return true; + } + }); + mSendButton.setAccessibilityDelegate(new AccessibilityDelegate() { + @Override + public void onPopulateAccessibilityEvent(View host, AccessibilityEvent event) { + super.onPopulateAccessibilityEvent(host, event); + // When the send button is long clicked, we want TalkBack to announce the real + // action (select SIM or edit subject), as opposed to "long press send button." + if (event.getEventType() == AccessibilityEvent.TYPE_VIEW_LONG_CLICKED) { + event.getText().clear(); + event.getText().add(getResources() + .getText(shouldShowSimSelector(mConversationDataModel.getData()) ? + R.string.send_button_long_click_description_with_sim_selector : + R.string.send_button_long_click_description_no_sim_selector)); + // Make this an announcement so TalkBack will read our custom message. + event.setEventType(AccessibilityEvent.TYPE_ANNOUNCEMENT); + } + } + }); + + mAttachMediaButton = + (ImageButton) findViewById(R.id.attach_media_button); + mAttachMediaButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(final View clickView) { + // Showing the media picker is treated as starting to compose the message. + mInputManager.showHideMediaPicker(true /* show */, true /* animate */); + } + }); + + mAttachmentPreview = (AttachmentPreview) findViewById(R.id.attachment_draft_view); + mAttachmentPreview.setComposeMessageView(this); + + mCharCounter = (TextView) findViewById(R.id.char_counter); + mMmsIndicator = (TextView) findViewById(R.id.mms_indicator); + } + + private void hideAttachmentsWhenShowingSims(final boolean simPickerVisible) { + if (!mHost.shouldHideAttachmentsWhenSimSelectorShown()) { + return; + } + final boolean haveAttachments = mBinding.getData().hasAttachments(); + if (simPickerVisible && haveAttachments) { + mHost.onAttachmentsChanged(false); + mAttachmentPreview.hideAttachmentPreview(); + } else { + mHost.onAttachmentsChanged(haveAttachments); + mAttachmentPreview.onAttachmentsChanged(mBinding.getData()); + } + } + + public void setInputManager(final ConversationInputManager inputManager) { + mInputManager = inputManager; + } + + public void setConversationDataModel(final ImmutableBindingRef<ConversationData> refDataModel) { + mConversationDataModel = refDataModel; + mConversationDataModel.getData().addConversationDataListener(mDataListener); + } + + ImmutableBindingRef<DraftMessageData> getDraftDataModel() { + return BindingBase.createBindingReference(mBinding); + } + + // returns true if it actually shows the subject editor and false if already showing + private boolean showSubjectEditor() { + // show the subject editor + if (mSubjectView.getVisibility() == View.GONE) { + mSubjectView.setVisibility(View.VISIBLE); + mSubjectView.requestFocus(); + return true; + } + return false; + } + + private void hideSubjectEditor() { + mSubjectView.setVisibility(View.GONE); + mComposeEditText.requestFocus(); + } + + /** + * {@inheritDoc} from TextView.OnEditorActionListener + */ + @Override // TextView.OnEditorActionListener.onEditorAction + public boolean onEditorAction(final TextView view, final int actionId, final KeyEvent event) { + if (actionId == EditorInfo.IME_ACTION_SEND) { + sendMessageInternal(true /* checkMessageSize */); + return true; + } + return false; + } + + private void sendMessageInternal(final boolean checkMessageSize) { + LogUtil.i(LogUtil.BUGLE_TAG, "UI initiated message sending in conversation " + + mBinding.getData().getConversationId()); + if (mBinding.getData().isCheckingDraft()) { + // Don't send message if we are currently checking draft for sending. + LogUtil.w(LogUtil.BUGLE_TAG, "Message can't be sent: still checking draft"); + return; + } + // Check the host for pre-conditions about any action. + if (mHost.isReadyForAction()) { + mInputManager.showHideSimSelector(false /* show */, true /* animate */); + final String messageToSend = mComposeEditText.getText().toString(); + mBinding.getData().setMessageText(messageToSend); + final String subject = mComposeSubjectText.getText().toString(); + mBinding.getData().setMessageSubject(subject); + // Asynchronously check the draft against various requirements before sending. + mBinding.getData().checkDraftForAction(checkMessageSize, + mHost.getConversationSelfSubId(), new CheckDraftTaskCallback() { + @Override + public void onDraftChecked(DraftMessageData data, int result) { + mBinding.ensureBound(data); + switch (result) { + case CheckDraftForSendTask.RESULT_PASSED: + // Continue sending after check succeeded. + final MessageData message = mBinding.getData() + .prepareMessageForSending(mBinding); + if (message != null && message.hasContent()) { + playSentSound(); + mHost.sendMessage(message); + hideSubjectEditor(); + if (AccessibilityUtil.isTouchExplorationEnabled(getContext())) { + AccessibilityUtil.announceForAccessibilityCompat( + ComposeMessageView.this, null, + R.string.sending_message); + } + } + break; + + case CheckDraftForSendTask.RESULT_HAS_PENDING_ATTACHMENTS: + // Cannot send while there's still attachment(s) being loaded. + UiUtils.showToastAtBottom( + R.string.cant_send_message_while_loading_attachments); + break; + + case CheckDraftForSendTask.RESULT_NO_SELF_PHONE_NUMBER_IN_GROUP_MMS: + mHost.promptForSelfPhoneNumber(); + break; + + case CheckDraftForSendTask.RESULT_MESSAGE_OVER_LIMIT: + Assert.isTrue(checkMessageSize); + mHost.warnOfExceedingMessageLimit( + true /*sending*/, false /* tooManyVideos */); + break; + + case CheckDraftForSendTask.RESULT_VIDEO_ATTACHMENT_LIMIT_EXCEEDED: + Assert.isTrue(checkMessageSize); + mHost.warnOfExceedingMessageLimit( + true /*sending*/, true /* tooManyVideos */); + break; + + case CheckDraftForSendTask.RESULT_SIM_NOT_READY: + // Cannot send if there is no active subscription + UiUtils.showToastAtBottom( + R.string.cant_send_message_without_active_subscription); + break; + + default: + break; + } + } + }, mBinding); + } else { + mHost.warnOfMissingActionConditions(true /*sending*/, + new Runnable() { + @Override + public void run() { + sendMessageInternal(checkMessageSize); + } + + }); + } + } + + public static void playSentSound() { + // Check if this setting is enabled before playing + final BuglePrefs prefs = BuglePrefs.getApplicationPrefs(); + final Context context = Factory.get().getApplicationContext(); + final String prefKey = context.getString(R.string.send_sound_pref_key); + final boolean defaultValue = context.getResources().getBoolean( + R.bool.send_sound_pref_default); + if (!prefs.getBoolean(prefKey, defaultValue)) { + return; + } + MediaUtil.get().playSound(context, R.raw.message_sent, null /* completionListener */); + } + + /** + * {@inheritDoc} from DraftMessageDataListener + */ + @Override // From DraftMessageDataListener + public void onDraftChanged(final DraftMessageData data, final int changeFlags) { + // As this is called asynchronously when message read check bound before updating text + mBinding.ensureBound(data); + + // We have to cache the values of the DraftMessageData because when we set + // mComposeEditText, its onTextChanged calls updateVisualsOnDraftChanged, + // which immediately reloads the text from the subject and message fields and replaces + // what's in the DraftMessageData. + + final String subject = data.getMessageSubject(); + final String message = data.getMessageText(); + + if ((changeFlags & DraftMessageData.MESSAGE_SUBJECT_CHANGED) == + DraftMessageData.MESSAGE_SUBJECT_CHANGED) { + mComposeSubjectText.setText(subject); + + // Set the cursor selection to the end since setText resets it to the start + mComposeSubjectText.setSelection(mComposeSubjectText.getText().length()); + } + + if ((changeFlags & DraftMessageData.MESSAGE_TEXT_CHANGED) == + DraftMessageData.MESSAGE_TEXT_CHANGED) { + mComposeEditText.setText(message); + + // Set the cursor selection to the end since setText resets it to the start + mComposeEditText.setSelection(mComposeEditText.getText().length()); + } + + if ((changeFlags & DraftMessageData.ATTACHMENTS_CHANGED) == + DraftMessageData.ATTACHMENTS_CHANGED) { + final boolean haveAttachments = mAttachmentPreview.onAttachmentsChanged(data); + mHost.onAttachmentsChanged(haveAttachments); + } + + if ((changeFlags & DraftMessageData.SELF_CHANGED) == DraftMessageData.SELF_CHANGED) { + updateOnSelfSubscriptionChange(); + } + updateVisualsOnDraftChanged(); + } + + @Override // From DraftMessageDataListener + public void onDraftAttachmentLimitReached(final DraftMessageData data) { + mBinding.ensureBound(data); + mHost.warnOfExceedingMessageLimit(false /* sending */, false /* tooManyVideos */); + } + + private void updateOnSelfSubscriptionChange() { + // Refresh the length filters according to the selected self's MmsConfig. + mComposeEditText.setFilters(new InputFilter[] { + new LengthFilter(MmsConfig.get(mBinding.getData().getSelfSubId()) + .getMaxTextLimit()) }); + mComposeSubjectText.setFilters(new InputFilter[] { + new LengthFilter(MmsConfig.get(mBinding.getData().getSelfSubId()) + .getMaxSubjectLength())}); + } + + @Override + public void onMediaItemsSelected(final Collection<MessagePartData> items) { + mBinding.getData().addAttachments(items); + announceMediaItemState(true /*isSelected*/); + } + + @Override + public void onMediaItemsUnselected(final MessagePartData item) { + mBinding.getData().removeAttachment(item); + announceMediaItemState(false /*isSelected*/); + } + + @Override + public void onPendingAttachmentAdded(final PendingAttachmentData pendingItem) { + mBinding.getData().addPendingAttachment(pendingItem, mBinding); + resumeComposeMessage(); + } + + private void announceMediaItemState(final boolean isSelected) { + final Resources res = getContext().getResources(); + final String announcement = isSelected ? res.getString( + R.string.mediapicker_gallery_item_selected_content_description) : + res.getString(R.string.mediapicker_gallery_item_unselected_content_description); + AccessibilityUtil.announceForAccessibilityCompat( + this, null, announcement); + } + + private void announceAttachmentState() { + if (AccessibilityUtil.isTouchExplorationEnabled(getContext())) { + int attachmentCount = mBinding.getData().getReadOnlyAttachments().size() + + mBinding.getData().getReadOnlyPendingAttachments().size(); + final String announcement = getContext().getResources().getQuantityString( + R.plurals.attachment_changed_accessibility_announcement, + attachmentCount, attachmentCount); + AccessibilityUtil.announceForAccessibilityCompat( + this, null, announcement); + } + } + + @Override + public void resumeComposeMessage() { + mComposeEditText.requestFocus(); + mInputManager.showHideImeKeyboard(true, true); + announceAttachmentState(); + } + + public void clearAttachments() { + mBinding.getData().clearAttachments(mHost.getAttachmentsClearedFlags()); + mHost.onAttachmentsCleared(); + } + + public void requestDraftMessage(boolean clearLocalDraft) { + mBinding.getData().loadFromStorage(mBinding, null, clearLocalDraft); + } + + public void setDraftMessage(final MessageData message) { + mBinding.getData().loadFromStorage(mBinding, message, false); + } + + public void writeDraftMessage() { + final String messageText = mComposeEditText.getText().toString(); + mBinding.getData().setMessageText(messageText); + + final String subject = mComposeSubjectText.getText().toString(); + mBinding.getData().setMessageSubject(subject); + + mBinding.getData().saveToStorage(mBinding); + } + + private void updateConversationSelfId(final String selfId, final boolean notify) { + mBinding.getData().setSelfId(selfId, notify); + } + + private Uri getSelfSendButtonIconUri() { + final Uri overridenSelfUri = mHost.getSelfSendButtonIconUri(); + if (overridenSelfUri != null) { + return overridenSelfUri; + } + final SubscriptionListEntry subscriptionListEntry = getSelfSubscriptionListEntry(); + + if (subscriptionListEntry != null) { + return subscriptionListEntry.selectedIconUri; + } + + // Fall back to default self-avatar in the base case. + final ParticipantData self = mConversationDataModel.getData().getDefaultSelfParticipant(); + return self == null ? null : AvatarUriUtil.createAvatarUri(self); + } + + private SubscriptionListEntry getSelfSubscriptionListEntry() { + return mConversationDataModel.getData().getSubscriptionEntryForSelfParticipant( + mBinding.getData().getSelfId(), false /* excludeDefault */); + } + + private boolean isDataLoadedForMessageSend() { + // Check data loading prerequisites for sending a message. + return mConversationDataModel != null && mConversationDataModel.isBound() && + mConversationDataModel.getData().getParticipantsLoaded(); + } + + private void updateVisualsOnDraftChanged() { + final String messageText = mComposeEditText.getText().toString(); + final DraftMessageData draftMessageData = mBinding.getData(); + draftMessageData.setMessageText(messageText); + + final String subject = mComposeSubjectText.getText().toString(); + draftMessageData.setMessageSubject(subject); + if (!TextUtils.isEmpty(subject)) { + mSubjectView.setVisibility(View.VISIBLE); + } + + final boolean hasMessageText = (TextUtils.getTrimmedLength(messageText) > 0); + final boolean hasSubject = (TextUtils.getTrimmedLength(subject) > 0); + final boolean hasWorkingDraft = hasMessageText || hasSubject || + mBinding.getData().hasAttachments(); + + // Update the SMS text counter. + final int messageCount = draftMessageData.getNumMessagesToBeSent(); + final int codePointsRemaining = draftMessageData.getCodePointsRemainingInCurrentMessage(); + // Show the counter only if: + // - We are not in MMS mode + // - We are going to send more than one message OR we are getting close + boolean showCounter = false; + if (!draftMessageData.getIsMms() && (messageCount > 1 || + codePointsRemaining <= CODEPOINTS_REMAINING_BEFORE_COUNTER_SHOWN)) { + showCounter = true; + } + + if (showCounter) { + // Update the remaining characters and number of messages required. + final String counterText = messageCount > 1 ? codePointsRemaining + " / " + + messageCount : String.valueOf(codePointsRemaining); + mCharCounter.setText(counterText); + mCharCounter.setVisibility(View.VISIBLE); + } else { + mCharCounter.setVisibility(View.INVISIBLE); + } + + // Update the send message button. Self icon uri might be null if self participant data + // and/or conversation metadata hasn't been loaded by the host. + final Uri selfSendButtonUri = getSelfSendButtonIconUri(); + int sendWidgetMode = SEND_WIDGET_MODE_SELF_AVATAR; + if (selfSendButtonUri != null) { + if (hasWorkingDraft && isDataLoadedForMessageSend()) { + UiUtils.revealOrHideViewWithAnimation(mSendButton, VISIBLE, null); + if (isOverriddenAvatarAGroup()) { + // If the host has overriden the avatar to show a group avatar where the + // send button sits, we have to hide the group avatar because it can be larger + // than the send button and pieces of the avatar will stick out from behind + // the send button. + UiUtils.revealOrHideViewWithAnimation(mSelfSendIcon, GONE, null); + } + mMmsIndicator.setVisibility(draftMessageData.getIsMms() ? VISIBLE : INVISIBLE); + sendWidgetMode = SEND_WIDGET_MODE_SEND_BUTTON; + } else { + mSelfSendIcon.setImageResourceUri(selfSendButtonUri); + if (isOverriddenAvatarAGroup()) { + UiUtils.revealOrHideViewWithAnimation(mSelfSendIcon, VISIBLE, null); + } + UiUtils.revealOrHideViewWithAnimation(mSendButton, GONE, null); + mMmsIndicator.setVisibility(INVISIBLE); + if (shouldShowSimSelector(mConversationDataModel.getData())) { + sendWidgetMode = SEND_WIDGET_MODE_SIM_SELECTOR; + } + } + } else { + mSelfSendIcon.setImageResourceUri(null); + } + + if (mSendWidgetMode != sendWidgetMode || sendWidgetMode == SEND_WIDGET_MODE_SIM_SELECTOR) { + setSendButtonAccessibility(sendWidgetMode); + mSendWidgetMode = sendWidgetMode; + } + + // Update the text hint on the message box depending on the attachment type. + final List<MessagePartData> attachments = draftMessageData.getReadOnlyAttachments(); + final int attachmentCount = attachments.size(); + if (attachmentCount == 0) { + final SubscriptionListEntry subscriptionListEntry = + mConversationDataModel.getData().getSubscriptionEntryForSelfParticipant( + mBinding.getData().getSelfId(), false /* excludeDefault */); + if (subscriptionListEntry == null) { + mComposeEditText.setHint(R.string.compose_message_view_hint_text); + } else { + mComposeEditText.setHint(Html.fromHtml(getResources().getString( + R.string.compose_message_view_hint_text_multi_sim, + subscriptionListEntry.displayName))); + } + } else { + int type = -1; + for (final MessagePartData attachment : attachments) { + int newType; + if (attachment.isImage()) { + newType = ContentType.TYPE_IMAGE; + } else if (attachment.isAudio()) { + newType = ContentType.TYPE_AUDIO; + } else if (attachment.isVideo()) { + newType = ContentType.TYPE_VIDEO; + } else if (attachment.isVCard()) { + newType = ContentType.TYPE_VCARD; + } else { + newType = ContentType.TYPE_OTHER; + } + + if (type == -1) { + type = newType; + } else if (type != newType || type == ContentType.TYPE_OTHER) { + type = ContentType.TYPE_OTHER; + break; + } + } + + switch (type) { + case ContentType.TYPE_IMAGE: + mComposeEditText.setHint(getResources().getQuantityString( + R.plurals.compose_message_view_hint_text_photo, attachmentCount)); + break; + + case ContentType.TYPE_AUDIO: + mComposeEditText.setHint(getResources().getQuantityString( + R.plurals.compose_message_view_hint_text_audio, attachmentCount)); + break; + + case ContentType.TYPE_VIDEO: + mComposeEditText.setHint(getResources().getQuantityString( + R.plurals.compose_message_view_hint_text_video, attachmentCount)); + break; + + case ContentType.TYPE_VCARD: + mComposeEditText.setHint(getResources().getQuantityString( + R.plurals.compose_message_view_hint_text_vcard, attachmentCount)); + break; + + case ContentType.TYPE_OTHER: + mComposeEditText.setHint(getResources().getQuantityString( + R.plurals.compose_message_view_hint_text_attachments, attachmentCount)); + break; + + default: + Assert.fail("Unsupported attachment type!"); + break; + } + } + } + + private void setSendButtonAccessibility(final int sendWidgetMode) { + switch (sendWidgetMode) { + case SEND_WIDGET_MODE_SELF_AVATAR: + // No send button and no SIM selector; the self send button is no longer + // important for accessibility. + mSelfSendIcon.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO); + mSelfSendIcon.setContentDescription(null); + mSendButton.setVisibility(View.GONE); + setSendWidgetAccessibilityTraversalOrder(SEND_WIDGET_MODE_SELF_AVATAR); + break; + + case SEND_WIDGET_MODE_SIM_SELECTOR: + mSelfSendIcon.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_YES); + mSelfSendIcon.setContentDescription(getSimContentDescription()); + setSendWidgetAccessibilityTraversalOrder(SEND_WIDGET_MODE_SIM_SELECTOR); + break; + + case SEND_WIDGET_MODE_SEND_BUTTON: + mMmsIndicator.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO); + mMmsIndicator.setContentDescription(null); + setSendWidgetAccessibilityTraversalOrder(SEND_WIDGET_MODE_SEND_BUTTON); + break; + } + } + + private String getSimContentDescription() { + final SubscriptionListEntry sub = getSelfSubscriptionListEntry(); + if (sub != null) { + return getResources().getString( + R.string.sim_selector_button_content_description_with_selection, + sub.displayName); + } else { + return getResources().getString( + R.string.sim_selector_button_content_description); + } + } + + // Set accessibility traversal order of the components in the send widget. + private void setSendWidgetAccessibilityTraversalOrder(final int mode) { + if (OsUtil.isAtLeastL_MR1()) { + mAttachMediaButton.setAccessibilityTraversalBefore(R.id.compose_message_text); + switch (mode) { + case SEND_WIDGET_MODE_SIM_SELECTOR: + mComposeEditText.setAccessibilityTraversalBefore(R.id.self_send_icon); + break; + case SEND_WIDGET_MODE_SEND_BUTTON: + mComposeEditText.setAccessibilityTraversalBefore(R.id.send_message_button); + break; + default: + break; + } + } + } + + @Override + public void afterTextChanged(final Editable editable) { + } + + @Override + public void beforeTextChanged(final CharSequence s, final int start, final int count, + final int after) { + if (mHost.shouldHideAttachmentsWhenSimSelectorShown()) { + hideSimSelector(); + } + } + + private void hideSimSelector() { + if (mInputManager.showHideSimSelector(false /* show */, true /* animate */)) { + // Now that the sim selector has been hidden, reshow the attachments if they + // have been hidden. + hideAttachmentsWhenShowingSims(false /*simPickerVisible*/); + } + } + + @Override + public void onTextChanged(final CharSequence s, final int start, final int before, + final int count) { + final BugleActionBarActivity activity = (mOriginalContext instanceof BugleActionBarActivity) + ? (BugleActionBarActivity) mOriginalContext : null; + if (activity != null && activity.getIsDestroyed()) { + LogUtil.v(LogUtil.BUGLE_TAG, "got onTextChanged after onDestroy"); + + // if we get onTextChanged after the activity is destroyed then, ah, wtf + // b/18176615 + // This appears to have occurred as the result of orientation change. + return; + } + + mBinding.ensureBound(); + updateVisualsOnDraftChanged(); + } + + @Override + public PlainTextEditText getComposeEditText() { + return mComposeEditText; + } + + public void displayPhoto(final Uri photoUri, final Rect imageBounds) { + mHost.displayPhoto(photoUri, imageBounds, true /* isDraft */); + } + + public void updateConversationSelfIdOnExternalChange(final String selfId) { + updateConversationSelfId(selfId, true /* notify */); + } + + /** + * The selfId of the conversation. As soon as the DraftMessageData successfully loads (i.e. + * getSelfId() is non-null), the selfId in DraftMessageData is treated as the sole source + * of truth for conversation self id since it reflects any pending self id change the user + * makes in the UI. + */ + public String getConversationSelfId() { + return mBinding.getData().getSelfId(); + } + + public void selectSim(SubscriptionListEntry subscriptionData) { + final String oldSelfId = getConversationSelfId(); + final String newSelfId = subscriptionData.selfParticipantId; + Assert.notNull(newSelfId); + // Don't attempt to change self if self hasn't been loaded, or if self hasn't changed. + if (oldSelfId == null || TextUtils.equals(oldSelfId, newSelfId)) { + return; + } + updateConversationSelfId(newSelfId, true /* notify */); + } + + public void hideAllComposeInputs(final boolean animate) { + mInputManager.hideAllInputs(animate); + } + + public void saveInputState(final Bundle outState) { + mInputManager.onSaveInputState(outState); + } + + public void resetMediaPickerState() { + mInputManager.resetMediaPickerState(); + } + + public boolean onBackPressed() { + return mInputManager.onBackPressed(); + } + + public boolean onNavigationUpPressed() { + return mInputManager.onNavigationUpPressed(); + } + + public boolean updateActionBar(final ActionBar actionBar) { + return mInputManager != null ? mInputManager.updateActionBar(actionBar) : false; + } + + public static boolean shouldShowSimSelector(final ConversationData convData) { + return OsUtil.isAtLeastL_MR1() && + convData.getSelfParticipantsCountExcludingDefault(true /* activeOnly */) > 1; + } + + public void sendMessageIgnoreMessageSizeLimit() { + sendMessageInternal(false /* checkMessageSize */); + } + + public void onAttachmentPreviewLongClicked() { + mHost.showAttachmentChooser(); + } + + @Override + public void onDraftAttachmentLoadFailed() { + mHost.notifyOfAttachmentLoadFailed(); + } + + private boolean isOverriddenAvatarAGroup() { + final Uri overridenSelfUri = mHost.getSelfSendButtonIconUri(); + if (overridenSelfUri == null) { + return false; + } + return AvatarUriUtil.TYPE_GROUP_URI.equals(AvatarUriUtil.getAvatarType(overridenSelfUri)); + } + + @Override + public void setAccessibility(boolean enabled) { + if (enabled) { + mAttachMediaButton.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_YES); + mComposeEditText.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_YES); + mSendButton.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_YES); + setSendButtonAccessibility(mSendWidgetMode); + } else { + mSelfSendIcon.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO); + mComposeEditText.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO); + mSendButton.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO); + mAttachMediaButton.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO); + } + } +} diff --git a/src/com/android/messaging/ui/conversation/ConversationActivity.java b/src/com/android/messaging/ui/conversation/ConversationActivity.java new file mode 100644 index 0000000..66310ea --- /dev/null +++ b/src/com/android/messaging/ui/conversation/ConversationActivity.java @@ -0,0 +1,379 @@ +/* + * 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.ui.conversation; + +import android.app.FragmentManager; +import android.app.FragmentTransaction; +import android.content.Intent; +import android.graphics.Rect; +import android.net.Uri; +import android.os.Bundle; +import android.support.v7.app.ActionBar; +import android.text.TextUtils; +import android.view.MenuItem; + +import com.android.messaging.R; +import com.android.messaging.datamodel.MessagingContentProvider; +import com.android.messaging.datamodel.data.MessageData; +import com.android.messaging.ui.BugleActionBarActivity; +import com.android.messaging.ui.UIIntents; +import com.android.messaging.ui.contact.ContactPickerFragment; +import com.android.messaging.ui.contact.ContactPickerFragment.ContactPickerFragmentHost; +import com.android.messaging.ui.conversation.ConversationActivityUiState.ConversationActivityUiStateHost; +import com.android.messaging.ui.conversation.ConversationFragment.ConversationFragmentHost; +import com.android.messaging.ui.conversationlist.ConversationListActivity; +import com.android.messaging.util.Assert; +import com.android.messaging.util.ContentType; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.OsUtil; +import com.android.messaging.util.UiUtils; + +public class ConversationActivity extends BugleActionBarActivity + implements ContactPickerFragmentHost, ConversationFragmentHost, + ConversationActivityUiStateHost { + public static final int FINISH_RESULT_CODE = 1; + private static final String SAVED_INSTANCE_STATE_UI_STATE_KEY = "uistate"; + + private ConversationActivityUiState mUiState; + + // Fragment transactions cannot be performed after onSaveInstanceState() has been called since + // it will cause state loss. We don't want to call commitAllowingStateLoss() since it's + // dangerous. Therefore, we note when instance state is saved and avoid performing UI state + // updates concerning fragments past that point. + private boolean mInstanceStateSaved; + + // Tracks whether onPause is called. + private boolean mIsPaused; + + @Override + protected void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + setContentView(R.layout.conversation_activity); + + final Intent intent = getIntent(); + + // Do our best to restore UI state from saved instance state. + if (savedInstanceState != null) { + mUiState = savedInstanceState.getParcelable(SAVED_INSTANCE_STATE_UI_STATE_KEY); + } else { + if (intent. + getBooleanExtra(UIIntents.UI_INTENT_EXTRA_GOTO_CONVERSATION_LIST, false)) { + // See the comment in BugleWidgetService.getViewMoreConversationsView() why this + // is unfortunately necessary. The Bugle desktop widget can display a list of + // conversations. When there are more conversations that can be displayed in + // the widget, the last item is a "More conversations" item. The way widgets + // are built, the list items can only go to a single fill-in intent which points + // to this ConversationActivity. When the user taps on "More conversations", we + // really want to go to the ConversationList. This code makes that possible. + finish(); + final Intent convListIntent = new Intent(this, ConversationListActivity.class); + convListIntent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); + startActivity(convListIntent); + return; + } + } + + // If saved instance state doesn't offer a clue, get the info from the intent. + if (mUiState == null) { + final String conversationId = intent.getStringExtra( + UIIntents.UI_INTENT_EXTRA_CONVERSATION_ID); + mUiState = new ConversationActivityUiState(conversationId); + } + mUiState.setHost(this); + mInstanceStateSaved = false; + + // Don't animate UI state change for initial setup. + updateUiState(false /* animate */); + + // See if we're getting called from a widget to directly display an image or video + final String extraToDisplay = + intent.getStringExtra(UIIntents.UI_INTENT_EXTRA_ATTACHMENT_URI); + if (!TextUtils.isEmpty(extraToDisplay)) { + final String contentType = + intent.getStringExtra(UIIntents.UI_INTENT_EXTRA_ATTACHMENT_TYPE); + final Rect bounds = UiUtils.getMeasuredBoundsOnScreen( + findViewById(R.id.conversation_and_compose_container)); + if (ContentType.isImageType(contentType)) { + final Uri imagesUri = MessagingContentProvider.buildConversationImagesUri( + mUiState.getConversationId()); + UIIntents.get().launchFullScreenPhotoViewer( + this, Uri.parse(extraToDisplay), bounds, imagesUri); + } else if (ContentType.isVideoType(contentType)) { + UIIntents.get().launchFullScreenVideoViewer(this, Uri.parse(extraToDisplay)); + } + } + } + + @Override + protected void onSaveInstanceState(final Bundle outState) { + super.onSaveInstanceState(outState); + // After onSaveInstanceState() is called, future changes to mUiState won't update the UI + // anymore, because fragment transactions are not allowed past this point. + // For an activity recreation due to orientation change, the saved instance state keeps + // using the in-memory copy of the UI state instead of writing it to parcel as an + // optimization, so the UI state values may still change in response to, for example, + // focus change from the framework, making mUiState and actual UI inconsistent. + // Therefore, save an exact "snapshot" (clone) of the UI state object to make sure the + // restored UI state ALWAYS matches the actual restored UI components. + outState.putParcelable(SAVED_INSTANCE_STATE_UI_STATE_KEY, mUiState.clone()); + mInstanceStateSaved = true; + } + + @Override + protected void onResume() { + super.onResume(); + + // we need to reset the mInstanceStateSaved flag since we may have just been restored from + // a previous onStop() instead of an onDestroy(). + mInstanceStateSaved = false; + mIsPaused = false; + } + + @Override + protected void onPause() { + super.onPause(); + mIsPaused = true; + } + + @Override + public void onWindowFocusChanged(final boolean hasFocus) { + super.onWindowFocusChanged(hasFocus); + final ConversationFragment conversationFragment = getConversationFragment(); + // When the screen is turned on, the last used activity gets resumed, but it gets + // window focus only after the lock screen is unlocked. + if (hasFocus && conversationFragment != null) { + conversationFragment.setConversationFocus(); + } + } + + @Override + public void onDisplayHeightChanged(final int heightSpecification) { + super.onDisplayHeightChanged(heightSpecification); + invalidateActionBar(); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + if (mUiState != null) { + mUiState.setHost(null); + } + } + + @Override + public void updateActionBar(final ActionBar actionBar) { + super.updateActionBar(actionBar); + final ConversationFragment conversation = getConversationFragment(); + final ContactPickerFragment contactPicker = getContactPicker(); + if (contactPicker != null && mUiState.shouldShowContactPickerFragment()) { + contactPicker.updateActionBar(actionBar); + } else if (conversation != null && mUiState.shouldShowConversationFragment()) { + conversation.updateActionBar(actionBar); + } + } + + @Override + public boolean onOptionsItemSelected(final MenuItem menuItem) { + if (super.onOptionsItemSelected(menuItem)) { + return true; + } + if (menuItem.getItemId() == android.R.id.home) { + onNavigationUpPressed(); + return true; + } + return false; + } + + public void onNavigationUpPressed() { + // Let the conversation fragment handle the navigation up press. + final ConversationFragment conversationFragment = getConversationFragment(); + if (conversationFragment != null && conversationFragment.onNavigationUpPressed()) { + return; + } + onFinishCurrentConversation(); + } + + @Override + public void onBackPressed() { + // If action mode is active dismiss it + if (getActionMode() != null) { + dismissActionMode(); + return; + } + + // Let the conversation fragment handle the back press. + final ConversationFragment conversationFragment = getConversationFragment(); + if (conversationFragment != null && conversationFragment.onBackPressed()) { + return; + } + super.onBackPressed(); + } + + private ContactPickerFragment getContactPicker() { + return (ContactPickerFragment) getFragmentManager().findFragmentByTag( + ContactPickerFragment.FRAGMENT_TAG); + } + + private ConversationFragment getConversationFragment() { + return (ConversationFragment) getFragmentManager().findFragmentByTag( + ConversationFragment.FRAGMENT_TAG); + } + + @Override // From ContactPickerFragmentHost + public void onGetOrCreateNewConversation(final String conversationId) { + Assert.isTrue(conversationId != null); + mUiState.onGetOrCreateConversation(conversationId); + } + + @Override // From ContactPickerFragmentHost + public void onBackButtonPressed() { + onBackPressed(); + } + + @Override // From ContactPickerFragmentHost + public void onInitiateAddMoreParticipants() { + mUiState.onAddMoreParticipants(); + } + + + @Override + public void onParticipantCountChanged(final boolean canAddMoreParticipants) { + mUiState.onParticipantCountUpdated(canAddMoreParticipants); + } + + @Override // From ConversationFragmentHost + public void onStartComposeMessage() { + mUiState.onStartMessageCompose(); + } + + @Override // From ConversationFragmentHost + public void onConversationMetadataUpdated() { + invalidateActionBar(); + } + + @Override // From ConversationFragmentHost + public void onConversationMessagesUpdated(final int numberOfMessages) { + } + + @Override // From ConversationFragmentHost + public void onConversationParticipantDataLoaded(final int numberOfParticipants) { + } + + @Override // From ConversationFragmentHost + public boolean isActiveAndFocused() { + return !mIsPaused && hasWindowFocus(); + } + + @Override // From ConversationActivityUiStateListener + public void onConversationContactPickerUiStateChanged(final int oldState, final int newState, + final boolean animate) { + Assert.isTrue(oldState != newState); + updateUiState(animate); + } + + private void updateUiState(final boolean animate) { + if (mInstanceStateSaved || mIsPaused) { + return; + } + Assert.notNull(mUiState); + final Intent intent = getIntent(); + final String conversationId = mUiState.getConversationId(); + + final FragmentManager fragmentManager = getFragmentManager(); + final FragmentTransaction fragmentTransaction = fragmentManager.beginTransaction(); + + final boolean needConversationFragment = mUiState.shouldShowConversationFragment(); + final boolean needContactPickerFragment = mUiState.shouldShowContactPickerFragment(); + ConversationFragment conversationFragment = getConversationFragment(); + + // Set up the conversation fragment. + if (needConversationFragment) { + Assert.notNull(conversationId); + if (conversationFragment == null) { + conversationFragment = new ConversationFragment(); + fragmentTransaction.add(R.id.conversation_fragment_container, + conversationFragment, ConversationFragment.FRAGMENT_TAG); + } + final MessageData draftData = intent.getParcelableExtra( + UIIntents.UI_INTENT_EXTRA_DRAFT_DATA); + if (!needContactPickerFragment) { + // Once the user has committed the audience,remove the draft data from the + // intent to prevent reuse + intent.removeExtra(UIIntents.UI_INTENT_EXTRA_DRAFT_DATA); + } + conversationFragment.setHost(this); + conversationFragment.setConversationInfo(this, conversationId, draftData); + } else if (conversationFragment != null) { + // Don't save draft to DB when removing conversation fragment and switching to + // contact picking mode. The draft is intended for the new group. + conversationFragment.suppressWriteDraft(); + fragmentTransaction.remove(conversationFragment); + } + + // Set up the contact picker fragment. + ContactPickerFragment contactPickerFragment = getContactPicker(); + if (needContactPickerFragment) { + if (contactPickerFragment == null) { + contactPickerFragment = new ContactPickerFragment(); + fragmentTransaction.add(R.id.contact_picker_fragment_container, + contactPickerFragment, ContactPickerFragment.FRAGMENT_TAG); + } + contactPickerFragment.setHost(this); + contactPickerFragment.setContactPickingMode(mUiState.getDesiredContactPickingMode(), + animate); + } else if (contactPickerFragment != null) { + fragmentTransaction.remove(contactPickerFragment); + } + + fragmentTransaction.commit(); + invalidateActionBar(); + } + + @Override + public void onFinishCurrentConversation() { + // Simply finish the current activity. The current design is to leave any empty + // conversations as is. + if (OsUtil.isAtLeastL()) { + finishAfterTransition(); + } else { + finish(); + } + } + + @Override + public boolean shouldResumeComposeMessage() { + return mUiState.shouldResumeComposeMessage(); + } + + @Override + protected void onActivityResult(final int requestCode, final int resultCode, + final Intent data) { + if (requestCode == ConversationFragment.REQUEST_CHOOSE_ATTACHMENTS && + resultCode == RESULT_OK) { + final ConversationFragment conversationFragment = getConversationFragment(); + if (conversationFragment != null) { + conversationFragment.onAttachmentChoosen(); + } else { + LogUtil.e(LogUtil.BUGLE_TAG, "ConversationFragment is missing after launching " + + "AttachmentChooserActivity!"); + } + } else if (resultCode == FINISH_RESULT_CODE) { + finish(); + } + } +} diff --git a/src/com/android/messaging/ui/conversation/ConversationActivityUiState.java b/src/com/android/messaging/ui/conversation/ConversationActivityUiState.java new file mode 100644 index 0000000..1469c93 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/ConversationActivityUiState.java @@ -0,0 +1,306 @@ +/* + * 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.ui.conversation; + +import android.os.Parcel; +import android.os.Parcelable; + +import com.android.messaging.ui.contact.ContactPickerFragment; +import com.android.messaging.util.Assert; +import com.google.common.annotations.VisibleForTesting; + +/** + * Keeps track of the different UI states that the ConversationActivity may be in. This acts as + * a state machine which, based on different actions (e.g. onAddMoreParticipants), notifies the + * ConversationActivity about any state UI change so it can update the visuals. This class + * implements Parcelable and it's persisted across activity tear down and relaunch. + */ +public class ConversationActivityUiState implements Parcelable, Cloneable { + interface ConversationActivityUiStateHost { + void onConversationContactPickerUiStateChanged(int oldState, int newState, boolean animate); + } + + /*------ Overall UI states (conversation & contact picker) ------*/ + + /** Only a full screen conversation is showing. */ + public static final int STATE_CONVERSATION_ONLY = 1; + /** Only a full screen contact picker is showing asking user to pick the initial contact. */ + public static final int STATE_CONTACT_PICKER_ONLY_INITIAL_CONTACT = 2; + /** + * Only a full screen contact picker is showing asking user to pick more participants. This + * happens after the user picked the initial contact, and then decide to go back and add more. + */ + public static final int STATE_CONTACT_PICKER_ONLY_ADD_MORE_CONTACTS = 3; + /** + * Only a full screen contact picker is showing asking user to pick more participants. However + * user has reached max number of conversation participants and can add no more. + */ + public static final int STATE_CONTACT_PICKER_ONLY_MAX_PARTICIPANTS = 4; + /** + * A hybrid mode where the conversation view + contact chips view are showing. This happens + * right after the user picked the initial contact for which a 1-1 conversation is fetched or + * created. + */ + public static final int STATE_HYBRID_WITH_CONVERSATION_AND_CHIPS_VIEW = 5; + + // The overall UI state of the ConversationActivity. + private int mConversationContactUiState; + + // The currently displayed conversation (if any). + private String mConversationId; + + // Indicates whether we should put focus in the compose message view when the + // ConversationFragment is attached. This is a transient state that's not persisted as + // part of the parcelable. + private boolean mPendingResumeComposeMessage = false; + + // The owner ConversationActivity. This is not parceled since the instance always change upon + // object reuse. + private ConversationActivityUiStateHost mHost; + + // Indicates the owning ConverastionActivity is in the process of updating its UI presentation + // to be in sync with the UI states. Outside of the UI updates, the UI states here should + // ALWAYS be consistent with the actual states of the activity. + private int mUiUpdateCount; + + /** + * Create a new instance with an initial conversation id. + */ + ConversationActivityUiState(final String conversationId) { + // The conversation activity may be initialized with only one of two states: + // Conversation-only (when there's a conversation id) or picking initial contact + // (when no conversation id is given). + mConversationId = conversationId; + mConversationContactUiState = conversationId == null ? + STATE_CONTACT_PICKER_ONLY_INITIAL_CONTACT : STATE_CONVERSATION_ONLY; + } + + public void setHost(final ConversationActivityUiStateHost host) { + mHost = host; + } + + public boolean shouldShowConversationFragment() { + return mConversationContactUiState == STATE_HYBRID_WITH_CONVERSATION_AND_CHIPS_VIEW || + mConversationContactUiState == STATE_CONVERSATION_ONLY; + } + + public boolean shouldShowContactPickerFragment() { + return mConversationContactUiState == STATE_CONTACT_PICKER_ONLY_ADD_MORE_CONTACTS || + mConversationContactUiState == STATE_CONTACT_PICKER_ONLY_MAX_PARTICIPANTS || + mConversationContactUiState == STATE_CONTACT_PICKER_ONLY_INITIAL_CONTACT || + mConversationContactUiState == STATE_HYBRID_WITH_CONVERSATION_AND_CHIPS_VIEW; + } + + /** + * Returns whether there's a pending request to resume message compose (i.e. set focus to + * the compose message view and show the soft keyboard). If so, this request will be served + * when the conversation fragment get created and resumed. This happens when the user commits + * participant selection for a group conversation and goes back to the conversation fragment. + * Since conversation fragment creation happens asynchronously, we issue and track this + * pending request for it to be eventually fulfilled. + */ + public boolean shouldResumeComposeMessage() { + if (mPendingResumeComposeMessage) { + // This is a one-shot operation that just keeps track of the pending resume compose + // state. This is also a non-critical operation so we don't care about failure case. + mPendingResumeComposeMessage = false; + return true; + } + return false; + } + + public int getDesiredContactPickingMode() { + switch (mConversationContactUiState) { + case STATE_CONTACT_PICKER_ONLY_ADD_MORE_CONTACTS: + return ContactPickerFragment.MODE_PICK_MORE_CONTACTS; + case STATE_CONTACT_PICKER_ONLY_MAX_PARTICIPANTS: + return ContactPickerFragment.MODE_PICK_MAX_PARTICIPANTS; + case STATE_CONTACT_PICKER_ONLY_INITIAL_CONTACT: + return ContactPickerFragment.MODE_PICK_INITIAL_CONTACT; + case STATE_HYBRID_WITH_CONVERSATION_AND_CHIPS_VIEW: + return ContactPickerFragment.MODE_CHIPS_ONLY; + default: + Assert.fail("Invalid contact picking mode for ConversationActivity!"); + return ContactPickerFragment.MODE_UNDEFINED; + } + } + + public String getConversationId() { + return mConversationId; + } + + /** + * Called whenever the contact picker fragment successfully fetched or created a conversation. + */ + public void onGetOrCreateConversation(final String conversationId) { + int newState = STATE_CONVERSATION_ONLY; + if (mConversationContactUiState == STATE_CONTACT_PICKER_ONLY_INITIAL_CONTACT) { + newState = STATE_HYBRID_WITH_CONVERSATION_AND_CHIPS_VIEW; + } else if (mConversationContactUiState == STATE_CONTACT_PICKER_ONLY_ADD_MORE_CONTACTS || + mConversationContactUiState == STATE_CONTACT_PICKER_ONLY_MAX_PARTICIPANTS) { + newState = STATE_CONVERSATION_ONLY; + } else { + // New conversation should only be created when we are in one of the contact picking + // modes. + Assert.fail("Invalid conversation activity state: can't create conversation!"); + } + mConversationId = conversationId; + performUiStateUpdate(newState, true); + } + + /** + * Called when the user started composing message. If we are in the hybrid chips state, we + * should commit to enter the conversation only state. + */ + public void onStartMessageCompose() { + // This cannot happen when we are in one of the full-screen contact picking states. + Assert.isTrue(mConversationContactUiState != STATE_CONTACT_PICKER_ONLY_INITIAL_CONTACT && + mConversationContactUiState != STATE_CONTACT_PICKER_ONLY_ADD_MORE_CONTACTS && + mConversationContactUiState != STATE_CONTACT_PICKER_ONLY_MAX_PARTICIPANTS); + if (mConversationContactUiState == STATE_HYBRID_WITH_CONVERSATION_AND_CHIPS_VIEW) { + performUiStateUpdate(STATE_CONVERSATION_ONLY, true); + } + } + + /** + * Called when the user initiated an action to add more participants in the hybrid state, + * namely clicking on the "add more participants" button or entered a new contact chip via + * auto-complete. + */ + public void onAddMoreParticipants() { + if (mConversationContactUiState == STATE_HYBRID_WITH_CONVERSATION_AND_CHIPS_VIEW) { + mPendingResumeComposeMessage = true; + performUiStateUpdate(STATE_CONTACT_PICKER_ONLY_ADD_MORE_CONTACTS, true); + } else { + // This is only possible in the hybrid state. + Assert.fail("Invalid conversation activity state: can't add more participants!"); + } + } + + /** + * Called each time the number of participants is updated to check against the limit and + * update the ui state accordingly. + */ + public void onParticipantCountUpdated(final boolean canAddMoreParticipants) { + if (mConversationContactUiState == STATE_CONTACT_PICKER_ONLY_ADD_MORE_CONTACTS + && !canAddMoreParticipants) { + performUiStateUpdate(STATE_CONTACT_PICKER_ONLY_MAX_PARTICIPANTS, false); + } else if (mConversationContactUiState == STATE_CONTACT_PICKER_ONLY_MAX_PARTICIPANTS + && canAddMoreParticipants) { + performUiStateUpdate(STATE_CONTACT_PICKER_ONLY_ADD_MORE_CONTACTS, false); + } + } + + private void performUiStateUpdate(final int conversationContactState, final boolean animate) { + // This starts one UI update cycle, during which we allow the conversation activity's + // UI presentation to be temporarily out of sync with the states here. + beginUiUpdate(); + + if (conversationContactState != mConversationContactUiState) { + final int oldState = mConversationContactUiState; + mConversationContactUiState = conversationContactState; + notifyOnOverallUiStateChanged(oldState, mConversationContactUiState, animate); + } + endUiUpdate(); + } + + private void notifyOnOverallUiStateChanged( + final int oldState, final int newState, final boolean animate) { + // Always verify state validity whenever we have a state change. + assertValidState(); + Assert.isTrue(isUiUpdateInProgress()); + + // Only do this if we are still attached to the host. mHost can be null if the host + // activity is already destroyed, but due to timing the contained UI components may still + // receive events such as focus change and trigger a callback to the Ui state. We'd like + // to guard against those cases. + if (mHost != null) { + mHost.onConversationContactPickerUiStateChanged(oldState, newState, animate); + } + } + + private void assertValidState() { + // Conversation id may be null IF AND ONLY IF the user is picking the initial contact to + // start a conversation. + Assert.isTrue((mConversationContactUiState == STATE_CONTACT_PICKER_ONLY_INITIAL_CONTACT) == + (mConversationId == null)); + } + + private void beginUiUpdate() { + mUiUpdateCount++; + } + + private void endUiUpdate() { + if (--mUiUpdateCount < 0) { + Assert.fail("Unbalanced Ui updates!"); + } + } + + private boolean isUiUpdateInProgress() { + return mUiUpdateCount > 0; + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(final Parcel dest, final int flags) { + dest.writeInt(mConversationContactUiState); + dest.writeString(mConversationId); + } + + private ConversationActivityUiState(final Parcel in) { + mConversationContactUiState = in.readInt(); + mConversationId = in.readString(); + + // Always verify state validity whenever we initialize states. + assertValidState(); + } + + public static final Parcelable.Creator<ConversationActivityUiState> CREATOR + = new Parcelable.Creator<ConversationActivityUiState>() { + @Override + public ConversationActivityUiState createFromParcel(final Parcel in) { + return new ConversationActivityUiState(in); + } + + @Override + public ConversationActivityUiState[] newArray(final int size) { + return new ConversationActivityUiState[size]; + } + }; + + @Override + protected ConversationActivityUiState clone() { + try { + return (ConversationActivityUiState) super.clone(); + } catch (CloneNotSupportedException e) { + Assert.fail("ConversationActivityUiState: failed to clone(). Is there a mutable " + + "reference?"); + } + return null; + } + + /** + * allows for overridding the internal UI state. Should never be called except by test code. + */ + @VisibleForTesting + void testSetUiState(final int uiState) { + mConversationContactUiState = uiState; + } +} diff --git a/src/com/android/messaging/ui/conversation/ConversationFastScroller.java b/src/com/android/messaging/ui/conversation/ConversationFastScroller.java new file mode 100644 index 0000000..b15f05a --- /dev/null +++ b/src/com/android/messaging/ui/conversation/ConversationFastScroller.java @@ -0,0 +1,489 @@ +/* + * 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.ui.conversation; + +import android.animation.AnimatorSet; +import android.animation.ObjectAnimator; +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Rect; +import android.graphics.drawable.StateListDrawable; +import android.os.Handler; +import android.support.v7.widget.LinearLayoutManager; +import android.support.v7.widget.RecyclerView; +import android.support.v7.widget.RecyclerView.AdapterDataObserver; +import android.support.v7.widget.RecyclerView.ViewHolder; +import android.util.StateSet; +import android.view.LayoutInflater; +import android.view.MotionEvent; +import android.view.View; +import android.view.View.MeasureSpec; +import android.view.View.OnLayoutChangeListener; +import android.view.ViewGroupOverlay; +import android.widget.ImageView; +import android.widget.TextView; + +import com.android.messaging.R; +import com.android.messaging.datamodel.data.ConversationMessageData; +import com.android.messaging.ui.ConversationDrawables; +import com.android.messaging.util.Dates; +import com.android.messaging.util.OsUtil; + +/** + * Adds a "fast-scroll" bar to the conversation RecyclerView that shows the current position within + * the conversation and allows quickly moving to another position by dragging the scrollbar thumb + * up or down. As the thumb is dragged, we show a floating bubble alongside it that shows the + * date/time of the first visible message at the current position. + */ +public class ConversationFastScroller extends RecyclerView.OnScrollListener implements + OnLayoutChangeListener, RecyclerView.OnItemTouchListener { + + /** + * Creates a {@link ConversationFastScroller} instance, attached to the provided + * {@link RecyclerView}. + * + * @param rv the conversation RecyclerView + * @param position where the scrollbar should appear (either {@code POSITION_RIGHT_SIDE} or + * {@code POSITION_LEFT_SIDE}) + * @return a new ConversationFastScroller, or {@code null} if fast-scrolling is not supported + * (the feature requires Jellybean MR2 or newer) + */ + public static ConversationFastScroller addTo(RecyclerView rv, int position) { + if (OsUtil.isAtLeastJB_MR2()) { + return new ConversationFastScroller(rv, position); + } + return null; + } + + public static final int POSITION_RIGHT_SIDE = 0; + public static final int POSITION_LEFT_SIDE = 1; + + private static final int MIN_PAGES_TO_ENABLE = 7; + private static final int SHOW_ANIMATION_DURATION_MS = 150; + private static final int HIDE_ANIMATION_DURATION_MS = 300; + private static final int HIDE_DELAY_MS = 1500; + + private final Context mContext; + private final RecyclerView mRv; + private final ViewGroupOverlay mOverlay; + private final ImageView mTrackImageView; + private final ImageView mThumbImageView; + private final TextView mPreviewTextView; + + private final int mTrackWidth; + private final int mThumbHeight; + private final int mPreviewHeight; + private final int mPreviewMinWidth; + private final int mPreviewMarginTop; + private final int mPreviewMarginLeftRight; + private final int mTouchSlop; + + private final Rect mContainer = new Rect(); + private final Handler mHandler = new Handler(); + + // Whether to render the scrollbar on the right side (otherwise it'll be on the left). + private final boolean mPosRight; + + // Whether the scrollbar is currently visible (it may still be animating). + private boolean mVisible = false; + + // Whether we are waiting to hide the scrollbar (i.e. scrolling has stopped). + private boolean mPendingHide = false; + + // Whether the user is currently dragging the thumb up or down. + private boolean mDragging = false; + + // Animations responsible for hiding the scrollbar & preview. May be null. + private AnimatorSet mHideAnimation; + private ObjectAnimator mHidePreviewAnimation; + + private final Runnable mHideTrackRunnable = new Runnable() { + @Override + public void run() { + hide(true /* animate */); + mPendingHide = false; + } + }; + + private ConversationFastScroller(RecyclerView rv, int position) { + mContext = rv.getContext(); + mRv = rv; + mRv.addOnLayoutChangeListener(this); + mRv.addOnScrollListener(this); + mRv.addOnItemTouchListener(this); + mRv.getAdapter().registerAdapterDataObserver(new AdapterDataObserver() { + @Override + public void onChanged() { + updateScrollPos(); + } + }); + mPosRight = (position == POSITION_RIGHT_SIDE); + + // Cache the dimensions we'll need during layout + final Resources res = mContext.getResources(); + mTrackWidth = res.getDimensionPixelSize(R.dimen.fastscroll_track_width); + mThumbHeight = res.getDimensionPixelSize(R.dimen.fastscroll_thumb_height); + mPreviewHeight = res.getDimensionPixelSize(R.dimen.fastscroll_preview_height); + mPreviewMinWidth = res.getDimensionPixelSize(R.dimen.fastscroll_preview_min_width); + mPreviewMarginTop = res.getDimensionPixelOffset(R.dimen.fastscroll_preview_margin_top); + mPreviewMarginLeftRight = res.getDimensionPixelOffset( + R.dimen.fastscroll_preview_margin_left_right); + mTouchSlop = res.getDimensionPixelOffset(R.dimen.fastscroll_touch_slop); + + final LayoutInflater inflator = LayoutInflater.from(mContext); + mTrackImageView = (ImageView) inflator.inflate(R.layout.fastscroll_track, null); + mThumbImageView = (ImageView) inflator.inflate(R.layout.fastscroll_thumb, null); + mPreviewTextView = (TextView) inflator.inflate(R.layout.fastscroll_preview, null); + + refreshConversationThemeColor(); + + // Add the fast scroll views to the overlay, so they are rendered above the list + mOverlay = rv.getOverlay(); + mOverlay.add(mTrackImageView); + mOverlay.add(mThumbImageView); + mOverlay.add(mPreviewTextView); + + hide(false /* animate */); + mPreviewTextView.setAlpha(0f); + } + + public void refreshConversationThemeColor() { + mPreviewTextView.setBackground( + ConversationDrawables.get().getFastScrollPreviewDrawable(mPosRight)); + if (OsUtil.isAtLeastL()) { + final StateListDrawable drawable = new StateListDrawable(); + drawable.addState(new int[]{ android.R.attr.state_pressed }, + ConversationDrawables.get().getFastScrollThumbDrawable(true /* pressed */)); + drawable.addState(StateSet.WILD_CARD, + ConversationDrawables.get().getFastScrollThumbDrawable(false /* pressed */)); + mThumbImageView.setImageDrawable(drawable); + } else { + // Android pre-L doesn't seem to handle a StateListDrawable containing a tinted + // drawable (it's rendered in the filter base color, which is red), so fall back to + // just the regular (non-pressed) drawable. + mThumbImageView.setImageDrawable( + ConversationDrawables.get().getFastScrollThumbDrawable(false /* pressed */)); + } + } + + @Override + public void onScrollStateChanged(final RecyclerView view, final int newState) { + if (newState == RecyclerView.SCROLL_STATE_DRAGGING) { + // Only show the scrollbar once the user starts scrolling + if (!mVisible && isEnabled()) { + show(); + } + cancelAnyPendingHide(); + } else if (newState == RecyclerView.SCROLL_STATE_IDLE && !mDragging) { + // Hide the scrollbar again after scrolling stops + hideAfterDelay(); + } + } + + private boolean isEnabled() { + final int range = mRv.computeVerticalScrollRange(); + final int extent = mRv.computeVerticalScrollExtent(); + + if (range == 0 || extent == 0) { + return false; // Conversation isn't long enough to scroll + } + // Only enable scrollbars for conversations long enough that they would require several + // flings to scroll through. + final float pages = (float) range / extent; + return (pages > MIN_PAGES_TO_ENABLE); + } + + private void show() { + if (mHideAnimation != null && mHideAnimation.isRunning()) { + mHideAnimation.cancel(); + } + // Slide the scrollbar in from the side + ObjectAnimator trackSlide = ObjectAnimator.ofFloat(mTrackImageView, View.TRANSLATION_X, 0); + ObjectAnimator thumbSlide = ObjectAnimator.ofFloat(mThumbImageView, View.TRANSLATION_X, 0); + AnimatorSet animation = new AnimatorSet(); + animation.playTogether(trackSlide, thumbSlide); + animation.setDuration(SHOW_ANIMATION_DURATION_MS); + animation.start(); + + mVisible = true; + updateScrollPos(); + } + + private void hideAfterDelay() { + cancelAnyPendingHide(); + mHandler.postDelayed(mHideTrackRunnable, HIDE_DELAY_MS); + mPendingHide = true; + } + + private void cancelAnyPendingHide() { + if (mPendingHide) { + mHandler.removeCallbacks(mHideTrackRunnable); + } + } + + private void hide(boolean animate) { + final int hiddenTranslationX = mPosRight ? mTrackWidth : -mTrackWidth; + if (animate) { + // Slide the scrollbar off to the side + ObjectAnimator trackSlide = ObjectAnimator.ofFloat(mTrackImageView, View.TRANSLATION_X, + hiddenTranslationX); + ObjectAnimator thumbSlide = ObjectAnimator.ofFloat(mThumbImageView, View.TRANSLATION_X, + hiddenTranslationX); + mHideAnimation = new AnimatorSet(); + mHideAnimation.playTogether(trackSlide, thumbSlide); + mHideAnimation.setDuration(HIDE_ANIMATION_DURATION_MS); + mHideAnimation.start(); + } else { + mTrackImageView.setTranslationX(hiddenTranslationX); + mThumbImageView.setTranslationX(hiddenTranslationX); + } + + mVisible = false; + } + + private void showPreview() { + if (mHidePreviewAnimation != null && mHidePreviewAnimation.isRunning()) { + mHidePreviewAnimation.cancel(); + } + mPreviewTextView.setAlpha(1f); + } + + private void hidePreview() { + mHidePreviewAnimation = ObjectAnimator.ofFloat(mPreviewTextView, View.ALPHA, 0f); + mHidePreviewAnimation.setDuration(HIDE_ANIMATION_DURATION_MS); + mHidePreviewAnimation.start(); + } + + @Override + public void onScrolled(final RecyclerView view, final int dx, final int dy) { + updateScrollPos(); + } + + private void updateScrollPos() { + if (!mVisible) { + return; + } + final int verticalScrollLength = mContainer.height() - mThumbHeight; + final int verticalScrollStart = mContainer.top + mThumbHeight / 2; + + final float scrollRatio = computeScrollRatio(); + final int thumbCenterY = verticalScrollStart + (int)(verticalScrollLength * scrollRatio); + layoutThumb(thumbCenterY); + + if (mDragging) { + updatePreviewText(); + layoutPreview(thumbCenterY); + } + } + + /** + * Returns the current position in the conversation, as a value between 0 and 1, inclusive. + * The top of the conversation is 0, the bottom is 1, the exact middle is 0.5, and so on. + */ + private float computeScrollRatio() { + final int range = mRv.computeVerticalScrollRange(); + final int extent = mRv.computeVerticalScrollExtent(); + int offset = mRv.computeVerticalScrollOffset(); + + if (range == 0 || extent == 0) { + // If the conversation doesn't scroll, we're at the bottom. + return 1.0f; + } + final int scrollRange = range - extent; + offset = Math.min(offset, scrollRange); + return offset / (float) scrollRange; + } + + private void updatePreviewText() { + final LinearLayoutManager lm = (LinearLayoutManager) mRv.getLayoutManager(); + final int pos = lm.findFirstVisibleItemPosition(); + if (pos == RecyclerView.NO_POSITION) { + return; + } + final ViewHolder vh = mRv.findViewHolderForAdapterPosition(pos); + if (vh == null) { + // This can happen if the messages update while we're dragging the thumb. + return; + } + final ConversationMessageView messageView = (ConversationMessageView) vh.itemView; + final ConversationMessageData messageData = messageView.getData(); + final long timestamp = messageData.getReceivedTimeStamp(); + final CharSequence timestampText = Dates.getFastScrollPreviewTimeString(timestamp); + mPreviewTextView.setText(timestampText); + } + + @Override + public boolean onInterceptTouchEvent(RecyclerView rv, MotionEvent e) { + if (!mVisible) { + return false; + } + // If the user presses down on the scroll thumb, we'll start intercepting events from the + // RecyclerView so we can handle the move events while they're dragging it up/down. + final int action = e.getActionMasked(); + switch (action) { + case MotionEvent.ACTION_DOWN: + if (isInsideThumb(e.getX(), e.getY())) { + startDrag(); + return true; + } + break; + case MotionEvent.ACTION_MOVE: + if (mDragging) { + return true; + } + case MotionEvent.ACTION_CANCEL: + case MotionEvent.ACTION_UP: + if (mDragging) { + cancelDrag(); + } + return false; + } + return false; + } + + private boolean isInsideThumb(float x, float y) { + final int hitTargetLeft = mThumbImageView.getLeft() - mTouchSlop; + final int hitTargetRight = mThumbImageView.getRight() + mTouchSlop; + + if (x < hitTargetLeft || x > hitTargetRight) { + return false; + } + if (y < mThumbImageView.getTop() || y > mThumbImageView.getBottom()) { + return false; + } + return true; + } + + @Override + public void onTouchEvent(RecyclerView rv, MotionEvent e) { + if (!mDragging) { + return; + } + final int action = e.getActionMasked(); + switch (action) { + case MotionEvent.ACTION_MOVE: + handleDragMove(e.getY()); + break; + case MotionEvent.ACTION_CANCEL: + case MotionEvent.ACTION_UP: + cancelDrag(); + break; + } + } + + @Override + public void onRequestDisallowInterceptTouchEvent(boolean disallowIntercept) { + } + + private void startDrag() { + mDragging = true; + mThumbImageView.setPressed(true); + updateScrollPos(); + showPreview(); + cancelAnyPendingHide(); + } + + private void handleDragMove(float y) { + final int verticalScrollLength = mContainer.height() - mThumbHeight; + final int verticalScrollStart = mContainer.top + (mThumbHeight / 2); + + // Convert the desired position from px to a scroll position in the conversation. + float dragScrollRatio = (y - verticalScrollStart) / verticalScrollLength; + dragScrollRatio = Math.max(dragScrollRatio, 0.0f); + dragScrollRatio = Math.min(dragScrollRatio, 1.0f); + + // Scroll the RecyclerView to a new position. + final int itemCount = mRv.getAdapter().getItemCount(); + final int itemPos = (int)((itemCount - 1) * dragScrollRatio); + mRv.scrollToPosition(itemPos); + } + + private void cancelDrag() { + mDragging = false; + mThumbImageView.setPressed(false); + hidePreview(); + hideAfterDelay(); + } + + @Override + public void onLayoutChange(View v, int left, int top, int right, int bottom, + int oldLeft, int oldTop, int oldRight, int oldBottom) { + if (!mVisible) { + hide(false /* animate */); + } + // The container is the size of the RecyclerView that's visible on screen. We have to + // exclude the top padding, because it's usually hidden behind the conversation action bar. + mContainer.set(left, top + mRv.getPaddingTop(), right, bottom); + layoutTrack(); + updateScrollPos(); + } + + private void layoutTrack() { + int trackHeight = Math.max(0, mContainer.height()); + int widthMeasureSpec = MeasureSpec.makeMeasureSpec(mTrackWidth, MeasureSpec.EXACTLY); + int heightMeasureSpec = MeasureSpec.makeMeasureSpec(trackHeight, MeasureSpec.EXACTLY); + mTrackImageView.measure(widthMeasureSpec, heightMeasureSpec); + + int left = mPosRight ? (mContainer.right - mTrackWidth) : mContainer.left; + int top = mContainer.top; + int right = mPosRight ? mContainer.right : (mContainer.left + mTrackWidth); + int bottom = mContainer.bottom; + mTrackImageView.layout(left, top, right, bottom); + } + + private void layoutThumb(int centerY) { + int widthMeasureSpec = MeasureSpec.makeMeasureSpec(mTrackWidth, MeasureSpec.EXACTLY); + int heightMeasureSpec = MeasureSpec.makeMeasureSpec(mThumbHeight, MeasureSpec.EXACTLY); + mThumbImageView.measure(widthMeasureSpec, heightMeasureSpec); + + int left = mPosRight ? (mContainer.right - mTrackWidth) : mContainer.left; + int top = centerY - (mThumbImageView.getHeight() / 2); + int right = mPosRight ? mContainer.right : (mContainer.left + mTrackWidth); + int bottom = top + mThumbHeight; + mThumbImageView.layout(left, top, right, bottom); + } + + private void layoutPreview(int centerY) { + int widthMeasureSpec = MeasureSpec.makeMeasureSpec(mContainer.width(), MeasureSpec.AT_MOST); + int heightMeasureSpec = MeasureSpec.makeMeasureSpec(mPreviewHeight, MeasureSpec.EXACTLY); + mPreviewTextView.measure(widthMeasureSpec, heightMeasureSpec); + + // Ensure that the preview bubble is at least as wide as it is tall + if (mPreviewTextView.getMeasuredWidth() < mPreviewMinWidth) { + widthMeasureSpec = MeasureSpec.makeMeasureSpec(mPreviewMinWidth, MeasureSpec.EXACTLY); + mPreviewTextView.measure(widthMeasureSpec, heightMeasureSpec); + } + final int previewMinY = mContainer.top + mPreviewMarginTop; + + final int left, right; + if (mPosRight) { + right = mContainer.right - mTrackWidth - mPreviewMarginLeftRight; + left = right - mPreviewTextView.getMeasuredWidth(); + } else { + left = mContainer.left + mTrackWidth + mPreviewMarginLeftRight; + right = left + mPreviewTextView.getMeasuredWidth(); + } + + int bottom = centerY; + int top = bottom - mPreviewTextView.getMeasuredHeight(); + if (top < previewMinY) { + top = previewMinY; + bottom = top + mPreviewTextView.getMeasuredHeight(); + } + mPreviewTextView.layout(left, top, right, bottom); + } +} diff --git a/src/com/android/messaging/ui/conversation/ConversationFragment.java b/src/com/android/messaging/ui/conversation/ConversationFragment.java new file mode 100644 index 0000000..a6a191a --- /dev/null +++ b/src/com/android/messaging/ui/conversation/ConversationFragment.java @@ -0,0 +1,1662 @@ +/* + * 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.ui.conversation; + +import android.Manifest; +import android.app.Activity; +import android.app.AlertDialog; +import android.app.DownloadManager; +import android.app.Fragment; +import android.app.FragmentManager; +import android.app.FragmentTransaction; +import android.content.BroadcastReceiver; +import android.content.ClipData; +import android.content.ClipboardManager; +import android.content.Context; +import android.content.DialogInterface; +import android.content.DialogInterface.OnCancelListener; +import android.content.DialogInterface.OnClickListener; +import android.content.DialogInterface.OnDismissListener; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.res.Configuration; +import android.database.Cursor; +import android.graphics.Point; +import android.graphics.Rect; +import android.graphics.drawable.ColorDrawable; +import android.net.Uri; +import android.os.Bundle; +import android.os.Environment; +import android.os.Handler; +import android.os.Parcelable; +import android.support.v4.content.LocalBroadcastManager; +import android.support.v4.text.BidiFormatter; +import android.support.v4.text.TextDirectionHeuristicsCompat; +import android.support.v7.app.ActionBar; +import android.support.v7.widget.DefaultItemAnimator; +import android.support.v7.widget.LinearLayoutManager; +import android.support.v7.widget.RecyclerView; +import android.support.v7.widget.RecyclerView.ViewHolder; +import android.text.TextUtils; +import android.view.ActionMode; +import android.view.Display; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewConfiguration; +import android.view.ViewGroup; +import android.widget.TextView; + +import com.android.messaging.R; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.MessagingContentProvider; +import com.android.messaging.datamodel.action.InsertNewMessageAction; +import com.android.messaging.datamodel.binding.Binding; +import com.android.messaging.datamodel.binding.BindingBase; +import com.android.messaging.datamodel.binding.ImmutableBindingRef; +import com.android.messaging.datamodel.data.ConversationData; +import com.android.messaging.datamodel.data.ConversationData.ConversationDataListener; +import com.android.messaging.datamodel.data.ConversationMessageData; +import com.android.messaging.datamodel.data.ConversationParticipantsData; +import com.android.messaging.datamodel.data.DraftMessageData; +import com.android.messaging.datamodel.data.DraftMessageData.DraftMessageDataListener; +import com.android.messaging.datamodel.data.MessageData; +import com.android.messaging.datamodel.data.MessagePartData; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.datamodel.data.SubscriptionListData.SubscriptionListEntry; +import com.android.messaging.ui.AttachmentPreview; +import com.android.messaging.ui.BugleActionBarActivity; +import com.android.messaging.ui.ConversationDrawables; +import com.android.messaging.ui.SnackBar; +import com.android.messaging.ui.UIIntents; +import com.android.messaging.ui.animation.PopupTransitionAnimation; +import com.android.messaging.ui.contact.AddContactsConfirmationDialog; +import com.android.messaging.ui.conversation.ComposeMessageView.IComposeMessageViewHost; +import com.android.messaging.ui.conversation.ConversationInputManager.ConversationInputHost; +import com.android.messaging.ui.conversation.ConversationMessageView.ConversationMessageViewHost; +import com.android.messaging.ui.mediapicker.MediaPicker; +import com.android.messaging.util.AccessibilityUtil; +import com.android.messaging.util.Assert; +import com.android.messaging.util.AvatarUriUtil; +import com.android.messaging.util.ChangeDefaultSmsAppHelper; +import com.android.messaging.util.ContentType; +import com.android.messaging.util.ImeUtil; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.OsUtil; +import com.android.messaging.util.PhoneUtils; +import com.android.messaging.util.SafeAsyncTask; +import com.android.messaging.util.TextUtil; +import com.android.messaging.util.UiUtils; +import com.android.messaging.util.UriUtil; +import com.google.common.annotations.VisibleForTesting; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; + +/** + * Shows a list of messages/parts comprising a conversation. + */ +public class ConversationFragment extends Fragment implements ConversationDataListener, + IComposeMessageViewHost, ConversationMessageViewHost, ConversationInputHost, + DraftMessageDataListener { + + public interface ConversationFragmentHost extends ImeUtil.ImeStateHost { + void onStartComposeMessage(); + void onConversationMetadataUpdated(); + boolean shouldResumeComposeMessage(); + void onFinishCurrentConversation(); + void invalidateActionBar(); + ActionMode startActionMode(ActionMode.Callback callback); + void dismissActionMode(); + ActionMode getActionMode(); + void onConversationMessagesUpdated(int numberOfMessages); + void onConversationParticipantDataLoaded(int numberOfParticipants); + boolean isActiveAndFocused(); + } + + public static final String FRAGMENT_TAG = "conversation"; + + static final int REQUEST_CHOOSE_ATTACHMENTS = 2; + private static final int JUMP_SCROLL_THRESHOLD = 15; + // We animate the message from draft to message list, if we the message doesn't show up in the + // list within this time limit, then we just do a fade in animation instead + public static final int MESSAGE_ANIMATION_MAX_WAIT = 500; + + private ComposeMessageView mComposeMessageView; + private RecyclerView mRecyclerView; + private ConversationMessageAdapter mAdapter; + private ConversationFastScroller mFastScroller; + + private View mConversationComposeDivider; + private ChangeDefaultSmsAppHelper mChangeDefaultSmsAppHelper; + + private String mConversationId; + // If the fragment receives a draft as part of the invocation this is set + private MessageData mIncomingDraft; + + // This binding keeps track of our associated ConversationData instance + // A binding should have the lifetime of the owning component, + // don't recreate, unbind and bind if you need new data + @VisibleForTesting + final Binding<ConversationData> mBinding = BindingBase.createBinding(this); + + // Saved Instance State Data - only for temporal data which is nice to maintain but not + // critical for correctness. + private static final String SAVED_INSTANCE_STATE_LIST_VIEW_STATE_KEY = "conversationViewState"; + private Parcelable mListState; + + private ConversationFragmentHost mHost; + + protected List<Integer> mFilterResults; + + // The minimum scrolling distance between RecyclerView's scroll change event beyong which + // a fling motion is considered fast, in which case we'll delay load image attachments for + // perf optimization. + private int mFastFlingThreshold; + + // ConversationMessageView that is currently selected + private ConversationMessageView mSelectedMessage; + + // Attachment data for the attachment within the selected message that was long pressed + private MessagePartData mSelectedAttachment; + + // Normally, as soon as draft message is loaded, we trust the UI state held in + // ComposeMessageView to be the only source of truth (incl. the conversation self id). However, + // there can be external events that forces the UI state to change, such as SIM state changes + // or SIM auto-switching on receiving a message. This receiver is used to receive such + // local broadcast messages and reflect the change in the UI. + private final BroadcastReceiver mConversationSelfIdChangeReceiver = new BroadcastReceiver() { + @Override + public void onReceive(final Context context, final Intent intent) { + final String conversationId = + intent.getStringExtra(UIIntents.UI_INTENT_EXTRA_CONVERSATION_ID); + final String selfId = + intent.getStringExtra(UIIntents.UI_INTENT_EXTRA_CONVERSATION_SELF_ID); + Assert.notNull(conversationId); + Assert.notNull(selfId); + if (TextUtils.equals(mBinding.getData().getConversationId(), conversationId)) { + mComposeMessageView.updateConversationSelfIdOnExternalChange(selfId); + } + } + }; + + // Flag to prevent writing draft to DB on pause + private boolean mSuppressWriteDraft; + + // Indicates whether local draft should be cleared due to external draft changes that must + // be reloaded from db + private boolean mClearLocalDraft; + private ImmutableBindingRef<DraftMessageData> mDraftMessageDataModel; + + private boolean isScrolledToBottom() { + if (mRecyclerView.getChildCount() == 0) { + return true; + } + final View lastView = mRecyclerView.getChildAt(mRecyclerView.getChildCount() - 1); + int lastVisibleItem = ((LinearLayoutManager) mRecyclerView + .getLayoutManager()).findLastVisibleItemPosition(); + if (lastVisibleItem < 0) { + // If the recyclerView height is 0, then the last visible item position is -1 + // Try to compute the position of the last item, even though it's not visible + final long id = mRecyclerView.getChildItemId(lastView); + final RecyclerView.ViewHolder holder = mRecyclerView.findViewHolderForItemId(id); + if (holder != null) { + lastVisibleItem = holder.getAdapterPosition(); + } + } + final int totalItemCount = mRecyclerView.getAdapter().getItemCount(); + final boolean isAtBottom = (lastVisibleItem + 1 == totalItemCount); + return isAtBottom && lastView.getBottom() <= mRecyclerView.getHeight(); + } + + private void scrollToBottom(final boolean smoothScroll) { + if (mAdapter.getItemCount() > 0) { + scrollToPosition(mAdapter.getItemCount() - 1, smoothScroll); + } + } + + private int mScrollToDismissThreshold; + private final RecyclerView.OnScrollListener mListScrollListener = + new RecyclerView.OnScrollListener() { + // Keeps track of cumulative scroll delta during a scroll event, which we may use to + // hide the media picker & co. + private int mCumulativeScrollDelta; + private boolean mScrollToDismissHandled; + private boolean mWasScrolledToBottom = true; + private int mScrollState = RecyclerView.SCROLL_STATE_IDLE; + + @Override + public void onScrollStateChanged(final RecyclerView view, final int newState) { + if (newState == RecyclerView.SCROLL_STATE_IDLE) { + // Reset scroll states. + mCumulativeScrollDelta = 0; + mScrollToDismissHandled = false; + } else if (newState == RecyclerView.SCROLL_STATE_DRAGGING) { + mRecyclerView.getItemAnimator().endAnimations(); + } + mScrollState = newState; + } + + @Override + public void onScrolled(final RecyclerView view, final int dx, final int dy) { + if (mScrollState == RecyclerView.SCROLL_STATE_DRAGGING && + !mScrollToDismissHandled) { + mCumulativeScrollDelta += dy; + // Dismiss the keyboard only when the user scroll up (into the past). + if (mCumulativeScrollDelta < -mScrollToDismissThreshold) { + mComposeMessageView.hideAllComposeInputs(false /* animate */); + mScrollToDismissHandled = true; + } + } + if (mWasScrolledToBottom != isScrolledToBottom()) { + mConversationComposeDivider.animate().alpha(isScrolledToBottom() ? 0 : 1); + mWasScrolledToBottom = isScrolledToBottom(); + } + } + }; + + private final ActionMode.Callback mMessageActionModeCallback = new ActionMode.Callback() { + @Override + public boolean onCreateActionMode(final ActionMode actionMode, final Menu menu) { + if (mSelectedMessage == null) { + return false; + } + final ConversationMessageData data = mSelectedMessage.getData(); + final MenuInflater menuInflater = getActivity().getMenuInflater(); + menuInflater.inflate(R.menu.conversation_fragment_select_menu, menu); + menu.findItem(R.id.action_download).setVisible(data.getShowDownloadMessage()); + menu.findItem(R.id.action_send).setVisible(data.getShowResendMessage()); + + // ShareActionProvider does not work with ActionMode. So we use a normal menu item. + menu.findItem(R.id.share_message_menu).setVisible(data.getCanForwardMessage()); + menu.findItem(R.id.save_attachment).setVisible(mSelectedAttachment != null); + menu.findItem(R.id.forward_message_menu).setVisible(data.getCanForwardMessage()); + + // TODO: We may want to support copying attachments in the future, but it's + // unclear which attachment to pick when we make this context menu at the message level + // instead of the part level + menu.findItem(R.id.copy_text).setVisible(data.getCanCopyMessageToClipboard()); + + return true; + } + + @Override + public boolean onPrepareActionMode(final ActionMode actionMode, final Menu menu) { + return true; + } + + @Override + public boolean onActionItemClicked(final ActionMode actionMode, final MenuItem menuItem) { + final ConversationMessageData data = mSelectedMessage.getData(); + final String messageId = data.getMessageId(); + switch (menuItem.getItemId()) { + case R.id.save_attachment: + if (OsUtil.hasStoragePermission()) { + final SaveAttachmentTask saveAttachmentTask = new SaveAttachmentTask( + getActivity()); + for (final MessagePartData part : data.getAttachments()) { + saveAttachmentTask.addAttachmentToSave(part.getContentUri(), + part.getContentType()); + } + if (saveAttachmentTask.getAttachmentCount() > 0) { + saveAttachmentTask.executeOnThreadPool(); + mHost.dismissActionMode(); + } + } else { + getActivity().requestPermissions( + new String[] { Manifest.permission.WRITE_EXTERNAL_STORAGE }, 0); + } + return true; + case R.id.action_delete_message: + if (mSelectedMessage != null) { + deleteMessage(messageId); + } + return true; + case R.id.action_download: + if (mSelectedMessage != null) { + retryDownload(messageId); + mHost.dismissActionMode(); + } + return true; + case R.id.action_send: + if (mSelectedMessage != null) { + retrySend(messageId); + mHost.dismissActionMode(); + } + return true; + case R.id.copy_text: + Assert.isTrue(data.hasText()); + final ClipboardManager clipboard = (ClipboardManager) getActivity() + .getSystemService(Context.CLIPBOARD_SERVICE); + clipboard.setPrimaryClip( + ClipData.newPlainText(null /* label */, data.getText())); + mHost.dismissActionMode(); + return true; + case R.id.details_menu: + MessageDetailsDialog.show( + getActivity(), data, mBinding.getData().getParticipants(), + mBinding.getData().getSelfParticipantById(data.getSelfParticipantId())); + mHost.dismissActionMode(); + return true; + case R.id.share_message_menu: + shareMessage(data); + mHost.dismissActionMode(); + return true; + case R.id.forward_message_menu: + // TODO: Currently we are forwarding one part at a time, instead of + // the entire message. Change this to forwarding the entire message when we + // use message-based cursor in conversation. + final MessageData message = mBinding.getData().createForwardedMessage(data); + UIIntents.get().launchForwardMessageActivity(getActivity(), message); + mHost.dismissActionMode(); + return true; + } + return false; + } + + private void shareMessage(final ConversationMessageData data) { + // Figure out what to share. + MessagePartData attachmentToShare = mSelectedAttachment; + // If the user long-pressed on the background, we will share the text (if any) + // or the first attachment. + if (mSelectedAttachment == null + && TextUtil.isAllWhitespace(data.getText())) { + final List<MessagePartData> attachments = data.getAttachments(); + if (attachments.size() > 0) { + attachmentToShare = attachments.get(0); + } + } + + final Intent shareIntent = new Intent(); + shareIntent.setAction(Intent.ACTION_SEND); + if (attachmentToShare == null) { + shareIntent.putExtra(Intent.EXTRA_TEXT, data.getText()); + shareIntent.setType("text/plain"); + } else { + shareIntent.putExtra( + Intent.EXTRA_STREAM, attachmentToShare.getContentUri()); + shareIntent.setType(attachmentToShare.getContentType()); + } + final CharSequence title = getResources().getText(R.string.action_share); + startActivity(Intent.createChooser(shareIntent, title)); + } + + @Override + public void onDestroyActionMode(final ActionMode actionMode) { + selectMessage(null); + } + }; + + /** + * {@inheritDoc} from Fragment + */ + @Override + public void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + mFastFlingThreshold = getResources().getDimensionPixelOffset( + R.dimen.conversation_fast_fling_threshold); + mAdapter = new ConversationMessageAdapter(getActivity(), null, this, + null, + // Sets the item click listener on the Recycler item views. + new View.OnClickListener() { + @Override + public void onClick(final View v) { + final ConversationMessageView messageView = (ConversationMessageView) v; + handleMessageClick(messageView); + } + }, + new View.OnLongClickListener() { + @Override + public boolean onLongClick(final View view) { + selectMessage((ConversationMessageView) view); + return true; + } + } + ); + } + + /** + * setConversationInfo() may be called before or after onCreate(). When a user initiate a + * conversation from compose, the ConversationActivity creates this fragment and calls + * setConversationInfo(), so it happens before onCreate(). However, when the activity is + * restored from saved instance state, the ConversationFragment is created automatically by + * the fragment, before ConversationActivity has a chance to call setConversationInfo(). Since + * the ability to start loading data depends on both methods being called, we need to start + * loading when onActivityCreated() is called, which is guaranteed to happen after both. + */ + @Override + public void onActivityCreated(final Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + // Delay showing the message list until the participant list is loaded. + mRecyclerView.setVisibility(View.INVISIBLE); + mBinding.ensureBound(); + mBinding.getData().init(getLoaderManager(), mBinding); + + // Build the input manager with all its required dependencies and pass it along to the + // compose message view. + final ConversationInputManager inputManager = new ConversationInputManager( + getActivity(), this, mComposeMessageView, mHost, getFragmentManagerToUse(), + mBinding, mComposeMessageView.getDraftDataModel(), savedInstanceState); + mComposeMessageView.setInputManager(inputManager); + mComposeMessageView.setConversationDataModel(BindingBase.createBindingReference(mBinding)); + mHost.invalidateActionBar(); + + mDraftMessageDataModel = + BindingBase.createBindingReference(mComposeMessageView.getDraftDataModel()); + mDraftMessageDataModel.getData().addListener(this); + } + + public void onAttachmentChoosen() { + // Attachment has been choosen in the AttachmentChooserActivity, so clear local draft + // and reload draft on resume. + mClearLocalDraft = true; + } + + private int getScrollToMessagePosition() { + final Activity activity = getActivity(); + if (activity == null) { + return -1; + } + + final Intent intent = activity.getIntent(); + if (intent == null) { + return -1; + } + + return intent.getIntExtra(UIIntents.UI_INTENT_EXTRA_MESSAGE_POSITION, -1); + } + + private void clearScrollToMessagePosition() { + final Activity activity = getActivity(); + if (activity == null) { + return; + } + + final Intent intent = activity.getIntent(); + if (intent == null) { + return; + } + intent.putExtra(UIIntents.UI_INTENT_EXTRA_MESSAGE_POSITION, -1); + } + + private final Handler mHandler = new Handler(); + + /** + * {@inheritDoc} from Fragment + */ + @Override + public View onCreateView(final LayoutInflater inflater, final ViewGroup container, + final Bundle savedInstanceState) { + final View view = inflater.inflate(R.layout.conversation_fragment, container, false); + mRecyclerView = (RecyclerView) view.findViewById(android.R.id.list); + final LinearLayoutManager manager = new LinearLayoutManager(getActivity()); + manager.setStackFromEnd(true); + manager.setReverseLayout(false); + mRecyclerView.setHasFixedSize(true); + mRecyclerView.setLayoutManager(manager); + mRecyclerView.setItemAnimator(new DefaultItemAnimator() { + private final List<ViewHolder> mAddAnimations = new ArrayList<ViewHolder>(); + private PopupTransitionAnimation mPopupTransitionAnimation; + + @Override + public boolean animateAdd(final ViewHolder holder) { + final ConversationMessageView view = + (ConversationMessageView) holder.itemView; + final ConversationMessageData data = view.getData(); + endAnimation(holder); + final long timeSinceSend = System.currentTimeMillis() - data.getReceivedTimeStamp(); + if (data.getReceivedTimeStamp() == + InsertNewMessageAction.getLastSentMessageTimestamp() && + !data.getIsIncoming() && + timeSinceSend < MESSAGE_ANIMATION_MAX_WAIT) { + final ConversationMessageBubbleView messageBubble = + (ConversationMessageBubbleView) view + .findViewById(R.id.message_content); + final Rect startRect = UiUtils.getMeasuredBoundsOnScreen(mComposeMessageView); + final View composeBubbleView = mComposeMessageView.findViewById( + R.id.compose_message_text); + final Rect composeBubbleRect = + UiUtils.getMeasuredBoundsOnScreen(composeBubbleView); + final AttachmentPreview attachmentView = + (AttachmentPreview) mComposeMessageView.findViewById( + R.id.attachment_draft_view); + final Rect attachmentRect = UiUtils.getMeasuredBoundsOnScreen(attachmentView); + if (attachmentView.getVisibility() == View.VISIBLE) { + startRect.top = attachmentRect.top; + } else { + startRect.top = composeBubbleRect.top; + } + startRect.top -= view.getPaddingTop(); + startRect.bottom = + composeBubbleRect.bottom; + startRect.left += view.getPaddingRight(); + + view.setAlpha(0); + mPopupTransitionAnimation = new PopupTransitionAnimation(startRect, view); + mPopupTransitionAnimation.setOnStartCallback(new Runnable() { + @Override + public void run() { + final int startWidth = composeBubbleRect.width(); + attachmentView.onMessageAnimationStart(); + messageBubble.kickOffMorphAnimation(startWidth, + messageBubble.findViewById(R.id.message_text_and_info) + .getMeasuredWidth()); + } + }); + mPopupTransitionAnimation.setOnStopCallback(new Runnable() { + @Override + public void run() { + view.setAlpha(1); + } + }); + mPopupTransitionAnimation.startAfterLayoutComplete(); + mAddAnimations.add(holder); + return true; + } else { + return super.animateAdd(holder); + } + } + + @Override + public void endAnimation(final ViewHolder holder) { + if (mAddAnimations.remove(holder)) { + holder.itemView.clearAnimation(); + } + super.endAnimation(holder); + } + + @Override + public void endAnimations() { + for (final ViewHolder holder : mAddAnimations) { + holder.itemView.clearAnimation(); + } + mAddAnimations.clear(); + if (mPopupTransitionAnimation != null) { + mPopupTransitionAnimation.cancel(); + } + super.endAnimations(); + } + }); + mRecyclerView.setAdapter(mAdapter); + + if (savedInstanceState != null) { + mListState = savedInstanceState.getParcelable(SAVED_INSTANCE_STATE_LIST_VIEW_STATE_KEY); + } + + mConversationComposeDivider = view.findViewById(R.id.conversation_compose_divider); + mScrollToDismissThreshold = ViewConfiguration.get(getActivity()).getScaledTouchSlop(); + mRecyclerView.addOnScrollListener(mListScrollListener); + mFastScroller = ConversationFastScroller.addTo(mRecyclerView, + UiUtils.isRtlMode() ? ConversationFastScroller.POSITION_LEFT_SIDE : + ConversationFastScroller.POSITION_RIGHT_SIDE); + + mComposeMessageView = (ComposeMessageView) + view.findViewById(R.id.message_compose_view_container); + // Bind the compose message view to the DraftMessageData + mComposeMessageView.bind(DataModel.get().createDraftMessageData( + mBinding.getData().getConversationId()), this); + + return view; + } + + private void scrollToPosition(final int targetPosition, final boolean smoothScroll) { + if (smoothScroll) { + final int maxScrollDelta = JUMP_SCROLL_THRESHOLD; + + final LinearLayoutManager layoutManager = + (LinearLayoutManager) mRecyclerView.getLayoutManager(); + final int firstVisibleItemPosition = + layoutManager.findFirstVisibleItemPosition(); + final int delta = targetPosition - firstVisibleItemPosition; + final int intermediatePosition; + + if (delta > maxScrollDelta) { + intermediatePosition = Math.max(0, targetPosition - maxScrollDelta); + } else if (delta < -maxScrollDelta) { + final int count = layoutManager.getItemCount(); + intermediatePosition = Math.min(count - 1, targetPosition + maxScrollDelta); + } else { + intermediatePosition = -1; + } + if (intermediatePosition != -1) { + mRecyclerView.scrollToPosition(intermediatePosition); + } + mRecyclerView.smoothScrollToPosition(targetPosition); + } else { + mRecyclerView.scrollToPosition(targetPosition); + } + } + + private int getScrollPositionFromBottom() { + final LinearLayoutManager layoutManager = + (LinearLayoutManager) mRecyclerView.getLayoutManager(); + final int lastVisibleItem = + layoutManager.findLastVisibleItemPosition(); + return Math.max(mAdapter.getItemCount() - 1 - lastVisibleItem, 0); + } + + /** + * Display a photo using the Photoviewer component. + */ + @Override + public void displayPhoto(final Uri photoUri, final Rect imageBounds, final boolean isDraft) { + displayPhoto(photoUri, imageBounds, isDraft, mConversationId, getActivity()); + } + + public static void displayPhoto(final Uri photoUri, final Rect imageBounds, + final boolean isDraft, final String conversationId, final Activity activity) { + final Uri imagesUri = + isDraft ? MessagingContentProvider.buildDraftImagesUri(conversationId) + : MessagingContentProvider.buildConversationImagesUri(conversationId); + UIIntents.get().launchFullScreenPhotoViewer( + activity, photoUri, imageBounds, imagesUri); + } + + private void selectMessage(final ConversationMessageView messageView) { + selectMessage(messageView, null /* attachment */); + } + + private void selectMessage(final ConversationMessageView messageView, + final MessagePartData attachment) { + mSelectedMessage = messageView; + if (mSelectedMessage == null) { + mAdapter.setSelectedMessage(null); + mHost.dismissActionMode(); + mSelectedAttachment = null; + return; + } + mSelectedAttachment = attachment; + mAdapter.setSelectedMessage(messageView.getData().getMessageId()); + mHost.startActionMode(mMessageActionModeCallback); + } + + @Override + public void onSaveInstanceState(final Bundle outState) { + super.onSaveInstanceState(outState); + if (mListState != null) { + outState.putParcelable(SAVED_INSTANCE_STATE_LIST_VIEW_STATE_KEY, mListState); + } + mComposeMessageView.saveInputState(outState); + } + + @Override + public void onResume() { + super.onResume(); + + if (mIncomingDraft == null) { + mComposeMessageView.requestDraftMessage(mClearLocalDraft); + } else { + mComposeMessageView.setDraftMessage(mIncomingDraft); + mIncomingDraft = null; + } + mClearLocalDraft = false; + + // On resume, check if there's a pending request for resuming message compose. This + // may happen when the user commits the contact selection for a group conversation and + // goes from compose back to the conversation fragment. + if (mHost.shouldResumeComposeMessage()) { + mComposeMessageView.resumeComposeMessage(); + } + + setConversationFocus(); + + // On resume, invalidate all message views to show the updated timestamp. + mAdapter.notifyDataSetChanged(); + + LocalBroadcastManager.getInstance(getActivity()).registerReceiver( + mConversationSelfIdChangeReceiver, + new IntentFilter(UIIntents.CONVERSATION_SELF_ID_CHANGE_BROADCAST_ACTION)); + } + + void setConversationFocus() { + if (mHost.isActiveAndFocused()) { + mBinding.getData().setFocus(); + } + } + + @Override + public void onCreateOptionsMenu(final Menu menu, final MenuInflater inflater) { + if (mHost.getActionMode() != null) { + return; + } + + inflater.inflate(R.menu.conversation_menu, menu); + + final ConversationData data = mBinding.getData(); + + // Disable the "people & options" item if we haven't loaded participants yet. + menu.findItem(R.id.action_people_and_options).setEnabled(data.getParticipantsLoaded()); + + // See if we can show add contact action. + final ParticipantData participant = data.getOtherParticipant(); + final boolean addContactActionVisible = (participant != null + && TextUtils.isEmpty(participant.getLookupKey())); + menu.findItem(R.id.action_add_contact).setVisible(addContactActionVisible); + + // See if we should show archive or unarchive. + final boolean isArchived = data.getIsArchived(); + menu.findItem(R.id.action_archive).setVisible(!isArchived); + menu.findItem(R.id.action_unarchive).setVisible(isArchived); + + // Conditionally enable the phone call button. + final boolean supportCallAction = (PhoneUtils.getDefault().isVoiceCapable() && + data.getParticipantPhoneNumber() != null); + menu.findItem(R.id.action_call).setVisible(supportCallAction); + } + + @Override + public boolean onOptionsItemSelected(final MenuItem item) { + switch (item.getItemId()) { + case R.id.action_people_and_options: + Assert.isTrue(mBinding.getData().getParticipantsLoaded()); + UIIntents.get().launchPeopleAndOptionsActivity(getActivity(), mConversationId); + return true; + + case R.id.action_call: + final String phoneNumber = mBinding.getData().getParticipantPhoneNumber(); + Assert.notNull(phoneNumber); + final View targetView = getActivity().findViewById(R.id.action_call); + Point centerPoint; + if (targetView != null) { + final int screenLocation[] = new int[2]; + targetView.getLocationOnScreen(screenLocation); + final int centerX = screenLocation[0] + targetView.getWidth() / 2; + final int centerY = screenLocation[1] + targetView.getHeight() / 2; + centerPoint = new Point(centerX, centerY); + } else { + // In the overflow menu, just use the center of the screen. + final Display display = getActivity().getWindowManager().getDefaultDisplay(); + centerPoint = new Point(display.getWidth() / 2, display.getHeight() / 2); + } + UIIntents.get().launchPhoneCallActivity(getActivity(), phoneNumber, centerPoint); + return true; + + case R.id.action_archive: + mBinding.getData().archiveConversation(mBinding); + closeConversation(mConversationId); + return true; + + case R.id.action_unarchive: + mBinding.getData().unarchiveConversation(mBinding); + return true; + + case R.id.action_settings: + return true; + + case R.id.action_add_contact: + final ParticipantData participant = mBinding.getData().getOtherParticipant(); + Assert.notNull(participant); + final String destination = participant.getNormalizedDestination(); + final Uri avatarUri = AvatarUriUtil.createAvatarUri(participant); + (new AddContactsConfirmationDialog(getActivity(), avatarUri, destination)).show(); + return true; + + case R.id.action_delete: + if (isReadyForAction()) { + new AlertDialog.Builder(getActivity()) + .setTitle(getResources().getQuantityString( + R.plurals.delete_conversations_confirmation_dialog_title, 1)) + .setPositiveButton(R.string.delete_conversation_confirmation_button, + new DialogInterface.OnClickListener() { + @Override + public void onClick(final DialogInterface dialog, + final int button) { + deleteConversation(); + } + }) + .setNegativeButton(R.string.delete_conversation_decline_button, null) + .show(); + } else { + warnOfMissingActionConditions(false /*sending*/, + null /*commandToRunAfterActionConditionResolved*/); + } + return true; + } + return super.onOptionsItemSelected(item); + } + + /** + * {@inheritDoc} from ConversationDataListener + */ + @Override + public void onConversationMessagesCursorUpdated(final ConversationData data, + final Cursor cursor, final ConversationMessageData newestMessage, + final boolean isSync) { + mBinding.ensureBound(data); + + // This needs to be determined before swapping cursor, which may change the scroll state. + final boolean scrolledToBottom = isScrolledToBottom(); + final int positionFromBottom = getScrollPositionFromBottom(); + + // If participants not loaded, assume 1:1 since that's the 99% case + final boolean oneOnOne = + !data.getParticipantsLoaded() || data.getOtherParticipant() != null; + mAdapter.setOneOnOne(oneOnOne, false /* invalidate */); + + // Ensure that the action bar is updated with the current data. + invalidateOptionsMenu(); + final Cursor oldCursor = mAdapter.swapCursor(cursor); + + if (cursor != null && oldCursor == null) { + if (mListState != null) { + mRecyclerView.getLayoutManager().onRestoreInstanceState(mListState); + // RecyclerView restores scroll states without triggering scroll change events, so + // we need to manually ensure that they are correctly handled. + mListScrollListener.onScrolled(mRecyclerView, 0, 0); + } + } + + if (isSync) { + // This is a message sync. Syncing messages changes cursor item count, which would + // implicitly change RV's scroll position. We'd like the RV to keep scrolled to the same + // relative position from the bottom (because RV is stacked from bottom), so that it + // stays relatively put as we sync. + final int position = Math.max(mAdapter.getItemCount() - 1 - positionFromBottom, 0); + scrollToPosition(position, false /* smoothScroll */); + } else if (newestMessage != null) { + // Show a snack bar notification if we are not scrolled to the bottom and the new + // message is an incoming message. + if (!scrolledToBottom && newestMessage.getIsIncoming()) { + // If the conversation activity is started but not resumed (if another dialog + // activity was in the foregrond), we will show a system notification instead of + // the snack bar. + if (mBinding.getData().isFocused()) { + UiUtils.showSnackBarWithCustomAction(getActivity(), + getView().getRootView(), + getString(R.string.in_conversation_notify_new_message_text), + SnackBar.Action.createCustomAction(new Runnable() { + @Override + public void run() { + scrollToBottom(true /* smoothScroll */); + mComposeMessageView.hideAllComposeInputs(false /* animate */); + } + }, + getString(R.string.in_conversation_notify_new_message_action)), + null /* interactions */, + SnackBar.Placement.above(mComposeMessageView)); + } + } else { + // We are either already scrolled to the bottom or this is an outgoing message, + // scroll to the bottom to reveal it. + // Don't smooth scroll if we were already at the bottom; instead, we scroll + // immediately so RecyclerView's view animation will take place. + scrollToBottom(!scrolledToBottom); + } + } + + if (cursor != null) { + mHost.onConversationMessagesUpdated(cursor.getCount()); + + // Are we coming from a widget click where we're told to scroll to a particular item? + final int scrollToPos = getScrollToMessagePosition(); + if (scrollToPos >= 0) { + if (LogUtil.isLoggable(LogUtil.BUGLE_TAG, LogUtil.VERBOSE)) { + LogUtil.v(LogUtil.BUGLE_TAG, "onConversationMessagesCursorUpdated " + + " scrollToPos: " + scrollToPos + + " cursorCount: " + cursor.getCount()); + } + scrollToPosition(scrollToPos, true /*smoothScroll*/); + clearScrollToMessagePosition(); + } + } + + mHost.invalidateActionBar(); + } + + /** + * {@inheritDoc} from ConversationDataListener + */ + @Override + public void onConversationMetadataUpdated(final ConversationData conversationData) { + mBinding.ensureBound(conversationData); + + if (mSelectedMessage != null && mSelectedAttachment != null) { + // We may have just sent a message and the temp attachment we selected is now gone. + // and it was replaced with some new attachment. Since we don't know which one it + // is we shouldn't reselect it (unless there is just one) In the multi-attachment + // case we would just deselect the message and allow the user to reselect, otherwise we + // may act on old temp data and may crash. + final List<MessagePartData> currentAttachments = mSelectedMessage.getData().getAttachments(); + if (currentAttachments.size() == 1) { + mSelectedAttachment = currentAttachments.get(0); + } else if (!currentAttachments.contains(mSelectedAttachment)) { + selectMessage(null); + } + } + // Ensure that the action bar is updated with the current data. + invalidateOptionsMenu(); + mHost.onConversationMetadataUpdated(); + mAdapter.notifyDataSetChanged(); + } + + public void setConversationInfo(final Context context, final String conversationId, + final MessageData draftData) { + // TODO: Eventually I would like the Factory to implement + // Factory.get().bindConversationData(mBinding, getActivity(), this, conversationId)); + if (!mBinding.isBound()) { + mConversationId = conversationId; + mIncomingDraft = draftData; + mBinding.bind(DataModel.get().createConversationData(context, this, conversationId)); + } else { + Assert.isTrue(TextUtils.equals(mBinding.getData().getConversationId(), conversationId)); + } + } + + @Override + public void onDestroy() { + super.onDestroy(); + // Unbind all the views that we bound to data + if (mComposeMessageView != null) { + mComposeMessageView.unbind(); + } + + // And unbind this fragment from its data + mBinding.unbind(); + mConversationId = null; + } + + void suppressWriteDraft() { + mSuppressWriteDraft = true; + } + + @Override + public void onPause() { + super.onPause(); + if (mComposeMessageView != null && !mSuppressWriteDraft) { + mComposeMessageView.writeDraftMessage(); + } + mSuppressWriteDraft = false; + mBinding.getData().unsetFocus(); + mListState = mRecyclerView.getLayoutManager().onSaveInstanceState(); + + LocalBroadcastManager.getInstance(getActivity()) + .unregisterReceiver(mConversationSelfIdChangeReceiver); + } + + @Override + public void onConfigurationChanged(final Configuration newConfig) { + super.onConfigurationChanged(newConfig); + mRecyclerView.getItemAnimator().endAnimations(); + } + + // TODO: Remove isBound and replace it with ensureBound after b/15704674. + public boolean isBound() { + return mBinding.isBound(); + } + + private FragmentManager getFragmentManagerToUse() { + return OsUtil.isAtLeastJB_MR1() ? getChildFragmentManager() : getFragmentManager(); + } + + public MediaPicker getMediaPicker() { + return (MediaPicker) getFragmentManagerToUse().findFragmentByTag( + MediaPicker.FRAGMENT_TAG); + } + + @Override + public void sendMessage(final MessageData message) { + if (isReadyForAction()) { + if (ensureKnownRecipients()) { + // Merge the caption text from attachments into the text body of the messages + message.consolidateText(); + + mBinding.getData().sendMessage(mBinding, message); + mComposeMessageView.resetMediaPickerState(); + } else { + LogUtil.w(LogUtil.BUGLE_TAG, "Message can't be sent: conv participants not loaded"); + } + } else { + warnOfMissingActionConditions(true /*sending*/, + new Runnable() { + @Override + public void run() { + sendMessage(message); + } + }); + } + } + + public void setHost(final ConversationFragmentHost host) { + mHost = host; + } + + public String getConversationName() { + return mBinding.getData().getConversationName(); + } + + @Override + public void onComposeEditTextFocused() { + mHost.onStartComposeMessage(); + } + + @Override + public void onAttachmentsCleared() { + // When attachments are removed, reset transient media picker state such as image selection. + mComposeMessageView.resetMediaPickerState(); + } + + /** + * Called to check if all conditions are nominal and a "go" for some action, such as deleting + * a message, that requires this app to be the default app. This is also a precondition + * required for sending a draft. + * @return true if all conditions are nominal and we're ready to send a message + */ + @Override + public boolean isReadyForAction() { + return UiUtils.isReadyForAction(); + } + + /** + * When there's some condition that prevents an operation, such as sending a message, + * call warnOfMissingActionConditions to put up a snackbar and allow the user to repair + * that condition. + * @param sending - true if we're called during a sending operation + * @param commandToRunAfterActionConditionResolved - a runnable to run after the user responds + * positively to the condition prompt and resolves the condition. If null, + * the user will be shown a toast to tap the send button again. + */ + @Override + public void warnOfMissingActionConditions(final boolean sending, + final Runnable commandToRunAfterActionConditionResolved) { + if (mChangeDefaultSmsAppHelper == null) { + mChangeDefaultSmsAppHelper = new ChangeDefaultSmsAppHelper(); + } + mChangeDefaultSmsAppHelper.warnOfMissingActionConditions(sending, + commandToRunAfterActionConditionResolved, mComposeMessageView, + getView().getRootView(), + getActivity(), this); + } + + private boolean ensureKnownRecipients() { + final ConversationData conversationData = mBinding.getData(); + + if (!conversationData.getParticipantsLoaded()) { + // We can't tell yet whether or not we have an unknown recipient + return false; + } + + final ConversationParticipantsData participants = conversationData.getParticipants(); + for (final ParticipantData participant : participants) { + + + if (participant.isUnknownSender()) { + UiUtils.showToast(R.string.unknown_sender); + return false; + } + } + + return true; + } + + public void retryDownload(final String messageId) { + if (isReadyForAction()) { + mBinding.getData().downloadMessage(mBinding, messageId); + } else { + warnOfMissingActionConditions(false /*sending*/, + null /*commandToRunAfterActionConditionResolved*/); + } + } + + public void retrySend(final String messageId) { + if (isReadyForAction()) { + if (ensureKnownRecipients()) { + mBinding.getData().resendMessage(mBinding, messageId); + } + } else { + warnOfMissingActionConditions(true /*sending*/, + new Runnable() { + @Override + public void run() { + retrySend(messageId); + } + + }); + } + } + + void deleteMessage(final String messageId) { + if (isReadyForAction()) { + final AlertDialog.Builder builder = new AlertDialog.Builder(getActivity()) + .setTitle(R.string.delete_message_confirmation_dialog_title) + .setMessage(R.string.delete_message_confirmation_dialog_text) + .setPositiveButton(R.string.delete_message_confirmation_button, + new OnClickListener() { + @Override + public void onClick(final DialogInterface dialog, final int which) { + mBinding.getData().deleteMessage(mBinding, messageId); + } + }) + .setNegativeButton(android.R.string.cancel, null); + if (OsUtil.isAtLeastJB_MR1()) { + builder.setOnDismissListener(new OnDismissListener() { + @Override + public void onDismiss(final DialogInterface dialog) { + mHost.dismissActionMode(); + } + }); + } else { + builder.setOnCancelListener(new OnCancelListener() { + @Override + public void onCancel(final DialogInterface dialog) { + mHost.dismissActionMode(); + } + }); + } + builder.create().show(); + } else { + warnOfMissingActionConditions(false /*sending*/, + null /*commandToRunAfterActionConditionResolved*/); + mHost.dismissActionMode(); + } + } + + public void deleteConversation() { + if (isReadyForAction()) { + final Context context = getActivity(); + mBinding.getData().deleteConversation(mBinding); + closeConversation(mConversationId); + } else { + warnOfMissingActionConditions(false /*sending*/, + null /*commandToRunAfterActionConditionResolved*/); + } + } + + @Override + public void closeConversation(final String conversationId) { + if (TextUtils.equals(conversationId, mConversationId)) { + mHost.onFinishCurrentConversation(); + // TODO: Explicitly transition to ConversationList (or just go back)? + } + } + + @Override + public void onConversationParticipantDataLoaded(final ConversationData data) { + mBinding.ensureBound(data); + if (mBinding.getData().getParticipantsLoaded()) { + final boolean oneOnOne = mBinding.getData().getOtherParticipant() != null; + mAdapter.setOneOnOne(oneOnOne, true /* invalidate */); + + // refresh the options menu which will enable the "people & options" item. + invalidateOptionsMenu(); + + mHost.invalidateActionBar(); + + mRecyclerView.setVisibility(View.VISIBLE); + mHost.onConversationParticipantDataLoaded + (mBinding.getData().getNumberOfParticipantsExcludingSelf()); + } + } + + @Override + public void onSubscriptionListDataLoaded(final ConversationData data) { + mBinding.ensureBound(data); + mAdapter.notifyDataSetChanged(); + } + + @Override + public void promptForSelfPhoneNumber() { + if (mComposeMessageView != null) { + // Avoid bug in system which puts soft keyboard over dialog after orientation change + ImeUtil.hideSoftInput(getActivity(), mComposeMessageView); + } + + final FragmentTransaction ft = getActivity().getFragmentManager().beginTransaction(); + final EnterSelfPhoneNumberDialog dialog = EnterSelfPhoneNumberDialog + .newInstance(getConversationSelfSubId()); + dialog.setTargetFragment(this, 0/*requestCode*/); + dialog.show(ft, null/*tag*/); + } + + @Override + public void onActivityResult(final int requestCode, final int resultCode, final Intent data) { + if (mChangeDefaultSmsAppHelper == null) { + mChangeDefaultSmsAppHelper = new ChangeDefaultSmsAppHelper(); + } + mChangeDefaultSmsAppHelper.handleChangeDefaultSmsResult(requestCode, resultCode, null); + } + + public boolean hasMessages() { + return mAdapter != null && mAdapter.getItemCount() > 0; + } + + public boolean onBackPressed() { + if (mComposeMessageView.onBackPressed()) { + return true; + } + return false; + } + + public boolean onNavigationUpPressed() { + return mComposeMessageView.onNavigationUpPressed(); + } + + @Override + public boolean onAttachmentClick(final ConversationMessageView messageView, + final MessagePartData attachment, final Rect imageBounds, final boolean longPress) { + if (longPress) { + selectMessage(messageView, attachment); + return true; + } else if (messageView.getData().getOneClickResendMessage()) { + handleMessageClick(messageView); + return true; + } + + if (attachment.isImage()) { + displayPhoto(attachment.getContentUri(), imageBounds, false /* isDraft */); + } + + if (attachment.isVCard()) { + UIIntents.get().launchVCardDetailActivity(getActivity(), attachment.getContentUri()); + } + + return false; + } + + private void handleMessageClick(final ConversationMessageView messageView) { + if (messageView != mSelectedMessage) { + final ConversationMessageData data = messageView.getData(); + final boolean isReadyToSend = isReadyForAction(); + if (data.getOneClickResendMessage()) { + // Directly resend the message on tap if it's failed + retrySend(data.getMessageId()); + selectMessage(null); + } else if (data.getShowResendMessage() && isReadyToSend) { + // Select the message to show the resend/download/delete options + selectMessage(messageView); + } else if (data.getShowDownloadMessage() && isReadyToSend) { + // Directly download the message on tap + retryDownload(data.getMessageId()); + } else { + // Let the toast from warnOfMissingActionConditions show and skip + // selecting + warnOfMissingActionConditions(false /*sending*/, + null /*commandToRunAfterActionConditionResolved*/); + selectMessage(null); + } + } else { + selectMessage(null); + } + } + + private static class AttachmentToSave { + public final Uri uri; + public final String contentType; + public Uri persistedUri; + + AttachmentToSave(final Uri uri, final String contentType) { + this.uri = uri; + this.contentType = contentType; + } + } + + public static class SaveAttachmentTask extends SafeAsyncTask<Void, Void, Void> { + private final Context mContext; + private final List<AttachmentToSave> mAttachmentsToSave = new ArrayList<>(); + + public SaveAttachmentTask(final Context context, final Uri contentUri, + final String contentType) { + mContext = context; + addAttachmentToSave(contentUri, contentType); + } + + public SaveAttachmentTask(final Context context) { + mContext = context; + } + + public void addAttachmentToSave(final Uri contentUri, final String contentType) { + mAttachmentsToSave.add(new AttachmentToSave(contentUri, contentType)); + } + + public int getAttachmentCount() { + return mAttachmentsToSave.size(); + } + + @Override + protected Void doInBackgroundTimed(final Void... arg) { + final File appDir = new File(Environment.getExternalStoragePublicDirectory( + Environment.DIRECTORY_PICTURES), + mContext.getResources().getString(R.string.app_name)); + final File downloadDir = Environment.getExternalStoragePublicDirectory( + Environment.DIRECTORY_DOWNLOADS); + for (final AttachmentToSave attachment : mAttachmentsToSave) { + final boolean isImageOrVideo = ContentType.isImageType(attachment.contentType) + || ContentType.isVideoType(attachment.contentType); + attachment.persistedUri = UriUtil.persistContent(attachment.uri, + isImageOrVideo ? appDir : downloadDir, attachment.contentType); + } + return null; + } + + @Override + protected void onPostExecute(final Void result) { + int failCount = 0; + int imageCount = 0; + int videoCount = 0; + int otherCount = 0; + for (final AttachmentToSave attachment : mAttachmentsToSave) { + if (attachment.persistedUri == null) { + failCount++; + continue; + } + + // Inform MediaScanner about the new file + final Intent scanFileIntent = new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE); + scanFileIntent.setData(attachment.persistedUri); + mContext.sendBroadcast(scanFileIntent); + + if (ContentType.isImageType(attachment.contentType)) { + imageCount++; + } else if (ContentType.isVideoType(attachment.contentType)) { + videoCount++; + } else { + otherCount++; + // Inform DownloadManager of the file so it will show in the "downloads" app + final DownloadManager downloadManager = + (DownloadManager) mContext.getSystemService( + Context.DOWNLOAD_SERVICE); + final String filePath = attachment.persistedUri.getPath(); + final File file = new File(filePath); + + if (file.exists()) { + downloadManager.addCompletedDownload( + file.getName() /* title */, + mContext.getString( + R.string.attachment_file_description) /* description */, + true /* isMediaScannerScannable */, + attachment.contentType, + file.getAbsolutePath(), + file.length(), + false /* showNotification */); + } + } + } + + String message; + if (failCount > 0) { + message = mContext.getResources().getQuantityString( + R.plurals.attachment_save_error, failCount, failCount); + } else { + int messageId = R.plurals.attachments_saved; + if (otherCount > 0) { + if (imageCount + videoCount == 0) { + messageId = R.plurals.attachments_saved_to_downloads; + } + } else { + if (videoCount == 0) { + messageId = R.plurals.photos_saved_to_album; + } else if (imageCount == 0) { + messageId = R.plurals.videos_saved_to_album; + } else { + messageId = R.plurals.attachments_saved_to_album; + } + } + final String appName = mContext.getResources().getString(R.string.app_name); + final int count = imageCount + videoCount + otherCount; + message = mContext.getResources().getQuantityString( + messageId, count, count, appName); + } + UiUtils.showToastAtBottom(message); + } + } + + private void invalidateOptionsMenu() { + final Activity activity = getActivity(); + // TODO: Add the supportInvalidateOptionsMenu call to the host activity. + if (activity == null || !(activity instanceof BugleActionBarActivity)) { + return; + } + ((BugleActionBarActivity) activity).supportInvalidateOptionsMenu(); + } + + @Override + public void setOptionsMenuVisibility(final boolean visible) { + setHasOptionsMenu(visible); + } + + @Override + public int getConversationSelfSubId() { + final String selfParticipantId = mComposeMessageView.getConversationSelfId(); + final ParticipantData self = mBinding.getData().getSelfParticipantById(selfParticipantId); + // If the self id or the self participant data hasn't been loaded yet, fallback to + // the default setting. + return self == null ? ParticipantData.DEFAULT_SELF_SUB_ID : self.getSubId(); + } + + @Override + public void invalidateActionBar() { + mHost.invalidateActionBar(); + } + + @Override + public void dismissActionMode() { + mHost.dismissActionMode(); + } + + @Override + public void selectSim(final SubscriptionListEntry subscriptionData) { + mComposeMessageView.selectSim(subscriptionData); + mHost.onStartComposeMessage(); + } + + @Override + public void onStartComposeMessage() { + mHost.onStartComposeMessage(); + } + + @Override + public SubscriptionListEntry getSubscriptionEntryForSelfParticipant( + final String selfParticipantId, final boolean excludeDefault) { + // TODO: ConversationMessageView is the only one using this. We should probably + // inject this into the view during binding in the ConversationMessageAdapter. + return mBinding.getData().getSubscriptionEntryForSelfParticipant(selfParticipantId, + excludeDefault); + } + + @Override + public SimSelectorView getSimSelectorView() { + return (SimSelectorView) getView().findViewById(R.id.sim_selector); + } + + @Override + public MediaPicker createMediaPicker() { + return new MediaPicker(getActivity()); + } + + @Override + public void notifyOfAttachmentLoadFailed() { + UiUtils.showToastAtBottom(R.string.attachment_load_failed_dialog_message); + } + + @Override + public void warnOfExceedingMessageLimit(final boolean sending, final boolean tooManyVideos) { + warnOfExceedingMessageLimit(sending, mComposeMessageView, mConversationId, + getActivity(), tooManyVideos); + } + + public static void warnOfExceedingMessageLimit(final boolean sending, + final ComposeMessageView composeMessageView, final String conversationId, + final Activity activity, final boolean tooManyVideos) { + final AlertDialog.Builder builder = + new AlertDialog.Builder(activity) + .setTitle(R.string.mms_attachment_limit_reached); + + if (sending) { + if (tooManyVideos) { + builder.setMessage(R.string.video_attachment_limit_exceeded_when_sending); + } else { + builder.setMessage(R.string.attachment_limit_reached_dialog_message_when_sending) + .setNegativeButton(R.string.attachment_limit_reached_send_anyway, + new OnClickListener() { + @Override + public void onClick(final DialogInterface dialog, + final int which) { + composeMessageView.sendMessageIgnoreMessageSizeLimit(); + } + }); + } + builder.setPositiveButton(android.R.string.ok, new OnClickListener() { + @Override + public void onClick(final DialogInterface dialog, final int which) { + showAttachmentChooser(conversationId, activity); + }}); + } else { + builder.setMessage(R.string.attachment_limit_reached_dialog_message_when_composing) + .setPositiveButton(android.R.string.ok, null); + } + builder.show(); + } + + @Override + public void showAttachmentChooser() { + showAttachmentChooser(mConversationId, getActivity()); + } + + public static void showAttachmentChooser(final String conversationId, + final Activity activity) { + UIIntents.get().launchAttachmentChooserActivity(activity, + conversationId, REQUEST_CHOOSE_ATTACHMENTS); + } + + private void updateActionAndStatusBarColor(final ActionBar actionBar) { + final int themeColor = ConversationDrawables.get().getConversationThemeColor(); + actionBar.setBackgroundDrawable(new ColorDrawable(themeColor)); + UiUtils.setStatusBarColor(getActivity(), themeColor); + } + + public void updateActionBar(final ActionBar actionBar) { + if (mComposeMessageView == null || !mComposeMessageView.updateActionBar(actionBar)) { + updateActionAndStatusBarColor(actionBar); + // We update this regardless of whether or not the action bar is showing so that we + // don't get a race when it reappears. + actionBar.setDisplayOptions(ActionBar.DISPLAY_SHOW_CUSTOM); + actionBar.setDisplayHomeAsUpEnabled(true); + // Reset the back arrow to its default + actionBar.setHomeAsUpIndicator(0); + View customView = actionBar.getCustomView(); + if (customView == null || customView.getId() != R.id.conversation_title_container) { + final LayoutInflater inflator = (LayoutInflater) + getActivity().getSystemService(Context.LAYOUT_INFLATER_SERVICE); + customView = inflator.inflate(R.layout.action_bar_conversation_name, null); + customView.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(final View v) { + onBackPressed(); + } + }); + actionBar.setCustomView(customView); + } + + final TextView conversationNameView = + (TextView) customView.findViewById(R.id.conversation_title); + final String conversationName = getConversationName(); + if (!TextUtils.isEmpty(conversationName)) { + // RTL : To format conversation title if it happens to be phone numbers. + final BidiFormatter bidiFormatter = BidiFormatter.getInstance(); + final String formattedName = bidiFormatter.unicodeWrap( + UiUtils.commaEllipsize( + conversationName, + conversationNameView.getPaint(), + conversationNameView.getWidth(), + getString(R.string.plus_one), + getString(R.string.plus_n)).toString(), + TextDirectionHeuristicsCompat.LTR); + conversationNameView.setText(formattedName); + // In case phone numbers are mixed in the conversation name, we need to vocalize it. + final String vocalizedConversationName = + AccessibilityUtil.getVocalizedPhoneNumber(getResources(), conversationName); + conversationNameView.setContentDescription(vocalizedConversationName); + getActivity().setTitle(conversationName); + } else { + final String appName = getString(R.string.app_name); + conversationNameView.setText(appName); + getActivity().setTitle(appName); + } + + // When conversation is showing and media picker is not showing, then hide the action + // bar only when we are in landscape mode, with IME open. + if (mHost.isImeOpen() && UiUtils.isLandscapeMode()) { + actionBar.hide(); + } else { + actionBar.show(); + } + } + } + + @Override + public boolean shouldShowSubjectEditor() { + return true; + } + + @Override + public boolean shouldHideAttachmentsWhenSimSelectorShown() { + return false; + } + + @Override + public void showHideSimSelector(final boolean show) { + // no-op for now + } + + @Override + public int getSimSelectorItemLayoutId() { + return R.layout.sim_selector_item_view; + } + + @Override + public Uri getSelfSendButtonIconUri() { + return null; // use default button icon uri + } + + @Override + public int overrideCounterColor() { + return -1; // don't override the color + } + + @Override + public void onAttachmentsChanged(final boolean haveAttachments) { + // no-op for now + } + + @Override + public void onDraftChanged(final DraftMessageData data, final int changeFlags) { + mDraftMessageDataModel.ensureBound(data); + // We're specifically only interested in ATTACHMENTS_CHANGED from the widget. Ignore + // other changes. When the widget changes an attachment, we need to reload the draft. + if (changeFlags == + (DraftMessageData.WIDGET_CHANGED | DraftMessageData.ATTACHMENTS_CHANGED)) { + mClearLocalDraft = true; // force a reload of the draft in onResume + } + } + + @Override + public void onDraftAttachmentLimitReached(final DraftMessageData data) { + // no-op for now + } + + @Override + public void onDraftAttachmentLoadFailed() { + // no-op for now + } + + @Override + public int getAttachmentsClearedFlags() { + return DraftMessageData.ATTACHMENTS_CHANGED; + } +} diff --git a/src/com/android/messaging/ui/conversation/ConversationInput.java b/src/com/android/messaging/ui/conversation/ConversationInput.java new file mode 100644 index 0000000..bf60aa8 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/ConversationInput.java @@ -0,0 +1,103 @@ +/* + * 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.ui.conversation; + +import android.os.Bundle; +import android.support.v7.app.ActionBar; + +/** + * The base class for a method of user input, e.g. media picker. + */ +public abstract class ConversationInput { + /** + * The host component where all input components are contained. This is typically the + * conversation fragment but may be mocked in test code. + */ + public interface ConversationInputBase { + boolean showHideInternal(final ConversationInput target, final boolean show, + final boolean animate); + String getInputStateKey(final ConversationInput input); + void beginUpdate(); + void handleOnShow(final ConversationInput target); + void endUpdate(); + } + + protected boolean mShowing; + protected ConversationInputBase mConversationInputBase; + + public abstract boolean show(boolean animate); + public abstract boolean hide(boolean animate); + + public ConversationInput(ConversationInputBase baseHost, final boolean isShowing) { + mConversationInputBase = baseHost; + mShowing = isShowing; + } + + public boolean onBackPressed() { + if (mShowing) { + mConversationInputBase.showHideInternal(this, false /* show */, true /* animate */); + return true; + } + return false; + } + + public boolean onNavigationUpPressed() { + return false; + } + + /** + * Toggle the visibility of this view. + * @param animate + * @return true if the view is now shown, false if it now hidden + */ + public boolean toggle(final boolean animate) { + mConversationInputBase.showHideInternal(this, !mShowing /* show */, true /* animate */); + return mShowing; + } + + public void saveState(final Bundle savedState) { + savedState.putBoolean(mConversationInputBase.getInputStateKey(this), mShowing); + } + + public void restoreState(final Bundle savedState) { + // Things are hidden by default, so only handle show. + if (savedState.getBoolean(mConversationInputBase.getInputStateKey(this))) { + mConversationInputBase.showHideInternal(this, true /* show */, false /* animate */); + } + } + + public boolean updateActionBar(final ActionBar actionBar) { + return false; + } + + /** + * Update our visibility flag in response to visibility change, both for actions + * initiated by this class (through the show/hide methods), and for external changes + * tracked by event listeners (e.g. ImeStateObserver, MediaPickerListener). As part of + * handling an input showing, we will hide all other inputs to ensure they are mutually + * exclusive. + */ + protected void onVisibilityChanged(final boolean visible) { + if (mShowing != visible) { + mConversationInputBase.beginUpdate(); + mShowing = visible; + if (visible) { + mConversationInputBase.handleOnShow(this); + } + mConversationInputBase.endUpdate(); + } + } +} diff --git a/src/com/android/messaging/ui/conversation/ConversationInputManager.java b/src/com/android/messaging/ui/conversation/ConversationInputManager.java new file mode 100644 index 0000000..e10abe7 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/ConversationInputManager.java @@ -0,0 +1,550 @@ +/* + * 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.ui.conversation; + +import android.app.FragmentManager; +import android.content.Context; +import android.os.Bundle; +import android.support.v7.app.ActionBar; +import android.widget.EditText; + +import com.android.messaging.R; +import com.android.messaging.datamodel.binding.BindingBase; +import com.android.messaging.datamodel.binding.ImmutableBindingRef; +import com.android.messaging.datamodel.data.ConversationData; +import com.android.messaging.datamodel.data.ConversationData.ConversationDataListener; +import com.android.messaging.datamodel.data.ConversationData.SimpleConversationDataListener; +import com.android.messaging.datamodel.data.DraftMessageData; +import com.android.messaging.datamodel.data.DraftMessageData.DraftMessageSubscriptionDataProvider; +import com.android.messaging.datamodel.data.MessagePartData; +import com.android.messaging.datamodel.data.PendingAttachmentData; +import com.android.messaging.datamodel.data.SubscriptionListData.SubscriptionListEntry; +import com.android.messaging.ui.ConversationDrawables; +import com.android.messaging.ui.mediapicker.MediaPicker; +import com.android.messaging.ui.mediapicker.MediaPicker.MediaPickerListener; +import com.android.messaging.util.Assert; +import com.android.messaging.util.ImeUtil; +import com.android.messaging.util.ImeUtil.ImeStateHost; +import com.google.common.annotations.VisibleForTesting; + +import java.util.Collection; + +/** + * Manages showing/hiding/persisting different mutually exclusive UI components nested in + * ConversationFragment that take user inputs, i.e. media picker, SIM selector and + * IME keyboard (the IME keyboard is not owned by Bugle, but we try to model it the same way + * as the other components). + */ +public class ConversationInputManager implements ConversationInput.ConversationInputBase { + /** + * The host component where all input components are contained. This is typically the + * conversation fragment but may be mocked in test code. + */ + public interface ConversationInputHost extends DraftMessageSubscriptionDataProvider { + void invalidateActionBar(); + void setOptionsMenuVisibility(boolean visible); + void dismissActionMode(); + void selectSim(SubscriptionListEntry subscriptionData); + void onStartComposeMessage(); + SimSelectorView getSimSelectorView(); + MediaPicker createMediaPicker(); + void showHideSimSelector(boolean show); + int getSimSelectorItemLayoutId(); + } + + /** + * The "sink" component where all inputs components will direct the user inputs to. This is + * typically the ComposeMessageView but may be mocked in test code. + */ + public interface ConversationInputSink { + void onMediaItemsSelected(Collection<MessagePartData> items); + void onMediaItemsUnselected(MessagePartData item); + void onPendingAttachmentAdded(PendingAttachmentData pendingItem); + void resumeComposeMessage(); + EditText getComposeEditText(); + void setAccessibility(boolean enabled); + } + + private final ConversationInputHost mHost; + private final ConversationInputSink mSink; + + /** Dependencies injected from the host during construction */ + private final FragmentManager mFragmentManager; + private final Context mContext; + private final ImeStateHost mImeStateHost; + private final ImmutableBindingRef<ConversationData> mConversationDataModel; + private final ImmutableBindingRef<DraftMessageData> mDraftDataModel; + + private final ConversationInput[] mInputs; + private final ConversationMediaPicker mMediaInput; + private final ConversationSimSelector mSimInput; + private final ConversationImeKeyboard mImeInput; + private int mUpdateCount; + + private final ImeUtil.ImeStateObserver mImeStateObserver = new ImeUtil.ImeStateObserver() { + @Override + public void onImeStateChanged(final boolean imeOpen) { + mImeInput.onVisibilityChanged(imeOpen); + } + }; + + private final ConversationDataListener mDataListener = new SimpleConversationDataListener() { + @Override + public void onConversationParticipantDataLoaded(ConversationData data) { + mConversationDataModel.ensureBound(data); + } + + @Override + public void onSubscriptionListDataLoaded(ConversationData data) { + mConversationDataModel.ensureBound(data); + mSimInput.onSubscriptionListDataLoaded(data.getSubscriptionListData()); + } + }; + + public ConversationInputManager( + final Context context, + final ConversationInputHost host, + final ConversationInputSink sink, + final ImeStateHost imeStateHost, + final FragmentManager fm, + final BindingBase<ConversationData> conversationDataModel, + final BindingBase<DraftMessageData> draftDataModel, + final Bundle savedState) { + mHost = host; + mSink = sink; + mFragmentManager = fm; + mContext = context; + mImeStateHost = imeStateHost; + mConversationDataModel = BindingBase.createBindingReference(conversationDataModel); + mDraftDataModel = BindingBase.createBindingReference(draftDataModel); + + // Register listeners on dependencies. + mImeStateHost.registerImeStateObserver(mImeStateObserver); + mConversationDataModel.getData().addConversationDataListener(mDataListener); + + // Initialize the inputs + mMediaInput = new ConversationMediaPicker(this); + mSimInput = new SimSelector(this); + mImeInput = new ConversationImeKeyboard(this, mImeStateHost.isImeOpen()); + mInputs = new ConversationInput[] { mMediaInput, mSimInput, mImeInput }; + + if (savedState != null) { + for (int i = 0; i < mInputs.length; i++) { + mInputs[i].restoreState(savedState); + } + } + updateHostOptionsMenu(); + } + + public void onDetach() { + mImeStateHost.unregisterImeStateObserver(mImeStateObserver); + // Don't need to explicitly unregister for data model events. It will unregister all + // listeners automagically on unbind. + } + + public void onSaveInputState(final Bundle savedState) { + for (int i = 0; i < mInputs.length; i++) { + mInputs[i].saveState(savedState); + } + } + + @Override + public String getInputStateKey(final ConversationInput input) { + return input.getClass().getCanonicalName() + "_savedstate_"; + } + + public boolean onBackPressed() { + for (int i = 0; i < mInputs.length; i++) { + if (mInputs[i].onBackPressed()) { + return true; + } + } + return false; + } + + public boolean onNavigationUpPressed() { + for (int i = 0; i < mInputs.length; i++) { + if (mInputs[i].onNavigationUpPressed()) { + return true; + } + } + return false; + } + + public void resetMediaPickerState() { + mMediaInput.resetViewHolderState(); + } + + public void showHideMediaPicker(final boolean show, final boolean animate) { + showHideInternal(mMediaInput, show, animate); + } + + /** + * Show or hide the sim selector + * @param show visibility + * @param animate whether to animate the change in visibility + * @return true if the state of the visibility was changed + */ + public boolean showHideSimSelector(final boolean show, final boolean animate) { + return showHideInternal(mSimInput, show, animate); + } + + public void showHideImeKeyboard(final boolean show, final boolean animate) { + showHideInternal(mImeInput, show, animate); + } + + public void hideAllInputs(final boolean animate) { + beginUpdate(); + for (int i = 0; i < mInputs.length; i++) { + showHideInternal(mInputs[i], false, animate); + } + endUpdate(); + } + + /** + * Toggle the visibility of the sim selector. + * @param animate + * @param subEntry + * @return true if the view is now shown, false if it now hidden + */ + public boolean toggleSimSelector(final boolean animate, final SubscriptionListEntry subEntry) { + mSimInput.setSelected(subEntry); + return mSimInput.toggle(animate); + } + + public boolean updateActionBar(final ActionBar actionBar) { + for (int i = 0; i < mInputs.length; i++) { + if (mInputs[i].mShowing) { + return mInputs[i].updateActionBar(actionBar); + } + } + return false; + } + + @VisibleForTesting + boolean isMediaPickerVisible() { + return mMediaInput.mShowing; + } + + @VisibleForTesting + boolean isSimSelectorVisible() { + return mSimInput.mShowing; + } + + @VisibleForTesting + boolean isImeKeyboardVisible() { + return mImeInput.mShowing; + } + + @VisibleForTesting + void testNotifyImeStateChanged(final boolean imeOpen) { + mImeStateObserver.onImeStateChanged(imeOpen); + } + + /** + * returns true if the state of the visibility was actually changed + */ + @Override + public boolean showHideInternal(final ConversationInput target, final boolean show, + final boolean animate) { + if (!mConversationDataModel.isBound()) { + return false; + } + + if (target.mShowing == show) { + return false; + } + beginUpdate(); + boolean success; + if (!show) { + success = target.hide(animate); + } else { + success = target.show(animate); + } + + if (success) { + target.onVisibilityChanged(show); + } + endUpdate(); + return true; + } + + @Override + public void handleOnShow(final ConversationInput target) { + if (!mConversationDataModel.isBound()) { + return; + } + beginUpdate(); + + // All inputs are mutually exclusive. Showing one will hide everything else. + // The one exception, is that the keyboard and location media chooser can be open at the + // time to enable searching within that chooser + for (int i = 0; i < mInputs.length; i++) { + final ConversationInput currInput = mInputs[i]; + if (currInput != target) { + // TODO : If there's more exceptions we will want to make this more + // generic + if (currInput instanceof ConversationMediaPicker && + target instanceof ConversationImeKeyboard && + mMediaInput.getExistingOrCreateMediaPicker() != null && + mMediaInput.getExistingOrCreateMediaPicker().canShowIme()) { + // Allow the keyboard and location mediaPicker to be open at the same time, + // but ensure the media picker is full screen to allow enough room + mMediaInput.getExistingOrCreateMediaPicker().setFullScreen(true); + continue; + } + showHideInternal(currInput, false /* show */, false /* animate */); + } + } + // Always dismiss action mode on show. + mHost.dismissActionMode(); + // Invoking any non-keyboard input UI is treated as starting message compose. + if (target != mImeInput) { + mHost.onStartComposeMessage(); + } + endUpdate(); + } + + @Override + public void beginUpdate() { + mUpdateCount++; + } + + @Override + public void endUpdate() { + Assert.isTrue(mUpdateCount > 0); + if (--mUpdateCount == 0) { + // Always try to update the host action bar after every update cycle. + mHost.invalidateActionBar(); + } + } + + private void updateHostOptionsMenu() { + mHost.setOptionsMenuVisibility(!mMediaInput.isOpen()); + } + + /** + * Manages showing/hiding the media picker in conversation. + */ + private class ConversationMediaPicker extends ConversationInput { + public ConversationMediaPicker(ConversationInputBase baseHost) { + super(baseHost, false); + } + + private MediaPicker mMediaPicker; + + @Override + public boolean show(boolean animate) { + if (mMediaPicker == null) { + mMediaPicker = getExistingOrCreateMediaPicker(); + setConversationThemeColor(ConversationDrawables.get().getConversationThemeColor()); + mMediaPicker.setSubscriptionDataProvider(mHost); + mMediaPicker.setDraftMessageDataModel(mDraftDataModel); + mMediaPicker.setListener(new MediaPickerListener() { + @Override + public void onOpened() { + handleStateChange(); + } + + @Override + public void onFullScreenChanged(boolean fullScreen) { + // When we're full screen, we want to disable accessibility on the + // ComposeMessageView controls (attach button, message input, sim chooser) + // that are hiding underneath the action bar. + mSink.setAccessibility(!fullScreen /*enabled*/); + handleStateChange(); + } + + @Override + public void onDismissed() { + // Re-enable accessibility on all controls now that the media picker is + // going away. + mSink.setAccessibility(true /*enabled*/); + handleStateChange(); + } + + private void handleStateChange() { + onVisibilityChanged(isOpen()); + mHost.invalidateActionBar(); + updateHostOptionsMenu(); + } + + @Override + public void onItemsSelected(final Collection<MessagePartData> items, + final boolean resumeCompose) { + mSink.onMediaItemsSelected(items); + mHost.invalidateActionBar(); + if (resumeCompose) { + mSink.resumeComposeMessage(); + } + } + + @Override + public void onItemUnselected(final MessagePartData item) { + mSink.onMediaItemsUnselected(item); + mHost.invalidateActionBar(); + } + + @Override + public void onConfirmItemSelection() { + mSink.resumeComposeMessage(); + } + + @Override + public void onPendingItemAdded(final PendingAttachmentData pendingItem) { + mSink.onPendingAttachmentAdded(pendingItem); + } + + @Override + public void onChooserSelected(final int chooserIndex) { + mHost.invalidateActionBar(); + mHost.dismissActionMode(); + } + }); + } + + mMediaPicker.open(MediaPicker.MEDIA_TYPE_DEFAULT, animate); + + return isOpen(); + } + + @Override + public boolean hide(boolean animate) { + if (mMediaPicker != null) { + mMediaPicker.dismiss(animate); + } + return !isOpen(); + } + + public void resetViewHolderState() { + if (mMediaPicker != null) { + mMediaPicker.resetViewHolderState(); + } + } + + public void setConversationThemeColor(final int themeColor) { + if (mMediaPicker != null) { + mMediaPicker.setConversationThemeColor(themeColor); + } + } + + private boolean isOpen() { + return (mMediaPicker != null && mMediaPicker.isOpen()); + } + + private MediaPicker getExistingOrCreateMediaPicker() { + if (mMediaPicker != null) { + return mMediaPicker; + } + MediaPicker mediaPicker = (MediaPicker) + mFragmentManager.findFragmentByTag(MediaPicker.FRAGMENT_TAG); + if (mediaPicker == null) { + mediaPicker = mHost.createMediaPicker(); + if (mediaPicker == null) { + return null; // this use of ComposeMessageView doesn't support media picking + } + mFragmentManager.beginTransaction().replace( + R.id.mediapicker_container, + mediaPicker, + MediaPicker.FRAGMENT_TAG).commit(); + } + return mediaPicker; + } + + @Override + public boolean updateActionBar(ActionBar actionBar) { + if (isOpen()) { + mMediaPicker.updateActionBar(actionBar); + return true; + } + return false; + } + + @Override + public boolean onNavigationUpPressed() { + if (isOpen() && mMediaPicker.isFullScreen()) { + return onBackPressed(); + } + return super.onNavigationUpPressed(); + } + + public boolean onBackPressed() { + if (mMediaPicker != null && mMediaPicker.onBackPressed()) { + return true; + } + return super.onBackPressed(); + } + } + + /** + * Manages showing/hiding the SIM selector in conversation. + */ + private class SimSelector extends ConversationSimSelector { + public SimSelector(ConversationInputBase baseHost) { + super(baseHost); + } + + @Override + protected SimSelectorView getSimSelectorView() { + return mHost.getSimSelectorView(); + } + + @Override + public int getSimSelectorItemLayoutId() { + return mHost.getSimSelectorItemLayoutId(); + } + + @Override + protected void selectSim(SubscriptionListEntry item) { + mHost.selectSim(item); + } + + @Override + public boolean show(boolean animate) { + final boolean result = super.show(animate); + mHost.showHideSimSelector(true /*show*/); + return result; + } + + @Override + public boolean hide(boolean animate) { + final boolean result = super.hide(animate); + mHost.showHideSimSelector(false /*show*/); + return result; + } + } + + /** + * Manages showing/hiding the IME keyboard in conversation. + */ + private class ConversationImeKeyboard extends ConversationInput { + public ConversationImeKeyboard(ConversationInputBase baseHost, final boolean isShowing) { + super(baseHost, isShowing); + } + + @Override + public boolean show(boolean animate) { + ImeUtil.get().showImeKeyboard(mContext, mSink.getComposeEditText()); + return true; + } + + @Override + public boolean hide(boolean animate) { + ImeUtil.get().hideImeKeyboard(mContext, mSink.getComposeEditText()); + return true; + } + } +} diff --git a/src/com/android/messaging/ui/conversation/ConversationMessageAdapter.java b/src/com/android/messaging/ui/conversation/ConversationMessageAdapter.java new file mode 100644 index 0000000..2748fff --- /dev/null +++ b/src/com/android/messaging/ui/conversation/ConversationMessageAdapter.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.ui.conversation; + +import android.content.Context; +import android.database.Cursor; +import android.support.v7.widget.RecyclerView; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import com.android.messaging.R; +import com.android.messaging.ui.AsyncImageView; +import com.android.messaging.ui.CursorRecyclerAdapter; +import com.android.messaging.ui.AsyncImageView.AsyncImageViewDelayLoader; +import com.android.messaging.ui.conversation.ConversationMessageView.ConversationMessageViewHost; +import com.android.messaging.util.Assert; + +import java.util.HashSet; +import java.util.List; + +/** + * Provides an interface to expose Conversation Message Cursor data to a UI widget like a + * RecyclerView. + */ +public class ConversationMessageAdapter extends + CursorRecyclerAdapter<ConversationMessageAdapter.ConversationMessageViewHolder> { + + private final ConversationMessageViewHost mHost; + private final AsyncImageViewDelayLoader mImageViewDelayLoader; + private final View.OnClickListener mViewClickListener; + private final View.OnLongClickListener mViewLongClickListener; + private boolean mOneOnOne; + private String mSelectedMessageId; + + public ConversationMessageAdapter(final Context context, final Cursor cursor, + final ConversationMessageViewHost host, + final AsyncImageViewDelayLoader imageViewDelayLoader, + final View.OnClickListener viewClickListener, + final View.OnLongClickListener longClickListener) { + super(context, cursor, 0); + mHost = host; + mViewClickListener = viewClickListener; + mViewLongClickListener = longClickListener; + mImageViewDelayLoader = imageViewDelayLoader; + setHasStableIds(true); + } + + @Override + public void bindViewHolder(final ConversationMessageViewHolder holder, + final Context context, final Cursor cursor) { + Assert.isTrue(holder.mView instanceof ConversationMessageView); + final ConversationMessageView conversationMessageView = + (ConversationMessageView) holder.mView; + conversationMessageView.bind(cursor, mOneOnOne, mSelectedMessageId); + } + + @Override + public ConversationMessageViewHolder createViewHolder(final Context context, + final ViewGroup parent, final int viewType) { + final LayoutInflater layoutInflater = LayoutInflater.from(context); + final ConversationMessageView conversationMessageView = (ConversationMessageView) + layoutInflater.inflate(R.layout.conversation_message_view, null); + conversationMessageView.setHost(mHost); + conversationMessageView.setImageViewDelayLoader(mImageViewDelayLoader); + return new ConversationMessageViewHolder(conversationMessageView, + mViewClickListener, mViewLongClickListener); + } + + public void setSelectedMessage(final String messageId) { + mSelectedMessageId = messageId; + notifyDataSetChanged(); + } + + public void setOneOnOne(final boolean oneOnOne, final boolean invalidate) { + if (mOneOnOne != oneOnOne) { + mOneOnOne = oneOnOne; + if (invalidate) { + notifyDataSetChanged(); + } + } + } + + /** + * ViewHolder that holds a ConversationMessageView. + */ + public static class ConversationMessageViewHolder extends RecyclerView.ViewHolder { + final View mView; + + /** + * @param viewClickListener a View.OnClickListener that should define the interaction when + * an item in the RecyclerView is clicked. + */ + public ConversationMessageViewHolder(final View itemView, + final View.OnClickListener viewClickListener, + final View.OnLongClickListener viewLongClickListener) { + super(itemView); + mView = itemView; + + mView.setOnClickListener(viewClickListener); + mView.setOnLongClickListener(viewLongClickListener); + } + } +} diff --git a/src/com/android/messaging/ui/conversation/ConversationMessageBubbleView.java b/src/com/android/messaging/ui/conversation/ConversationMessageBubbleView.java new file mode 100644 index 0000000..ef6aeb4 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/ConversationMessageBubbleView.java @@ -0,0 +1,132 @@ +/* + * 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.ui.conversation; + +import android.animation.Animator; +import android.animation.Animator.AnimatorListener; +import android.animation.ObjectAnimator; +import android.content.Context; +import android.util.AttributeSet; +import android.view.ViewGroup; +import android.widget.LinearLayout; + +import com.android.messaging.R; +import com.android.messaging.annotation.VisibleForAnimation; +import com.android.messaging.datamodel.data.ConversationMessageBubbleData; +import com.android.messaging.datamodel.data.ConversationMessageData; +import com.android.messaging.util.UiUtils; + +/** + * Shows the message bubble for one conversation message. It is able to animate size changes + * by morphing when the message content changes size. + */ +// TODO: Move functionality from ConversationMessageView into this class as appropriate +public class ConversationMessageBubbleView extends LinearLayout { + private int mIntrinsicWidth; + private int mMorphedWidth; + private ObjectAnimator mAnimator; + private boolean mShouldAnimateWidthChange; + private final ConversationMessageBubbleData mData; + private int mRunningStartWidth; + private ViewGroup mBubbleBackground; + + public ConversationMessageBubbleView(final Context context, final AttributeSet attrs) { + super(context, attrs); + mData = new ConversationMessageBubbleData(); + } + + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + mBubbleBackground = (ViewGroup) findViewById(R.id.message_text_and_info); + } + + @Override + protected void onMeasure(final int widthMeasureSpec, final int heightMeasureSpec) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + + final int newIntrinsicWidth = getMeasuredWidth(); + if (mIntrinsicWidth == 0 && newIntrinsicWidth != mIntrinsicWidth) { + if (mShouldAnimateWidthChange) { + kickOffMorphAnimation(mIntrinsicWidth, newIntrinsicWidth); + } + mIntrinsicWidth = newIntrinsicWidth; + } + + if (mMorphedWidth > 0) { + mBubbleBackground.getLayoutParams().width = mMorphedWidth; + } else { + mBubbleBackground.getLayoutParams().width = LayoutParams.WRAP_CONTENT; + } + mBubbleBackground.requestLayout(); + } + + @VisibleForAnimation + public void setMorphWidth(final int width) { + mMorphedWidth = width; + requestLayout(); + } + + public void bind(final ConversationMessageData data) { + final boolean changed = mData.bind(data); + // Animate width change only when we are binding to the same message, so that we may + // animate view size changes on the same message bubble due to things like status text + // change. + // Don't animate width change when the bubble contains attachments. Width animation is + // only suitable for text-only messages (where the bubble size change due to status or + // time stamp changes). + mShouldAnimateWidthChange = !changed && !data.hasAttachments(); + if (mAnimator == null) { + mMorphedWidth = 0; + } + } + + public void kickOffMorphAnimation(final int oldWidth, final int newWidth) { + if (mAnimator != null) { + mAnimator.setIntValues(mRunningStartWidth, newWidth); + return; + } + mRunningStartWidth = oldWidth; + mAnimator = ObjectAnimator.ofInt(this, "morphWidth", oldWidth, newWidth); + mAnimator.setDuration(UiUtils.MEDIAPICKER_TRANSITION_DURATION); + mAnimator.addListener(new AnimatorListener() { + @Override + public void onAnimationStart(Animator animator) { + } + + @Override + public void onAnimationEnd(Animator animator) { + mAnimator = null; + mMorphedWidth = 0; + // Allow the bubble to resize if, for example, the status text changed during + // the animation. This will snap to the bigger size if needed. This is intentional + // as animating immediately after looks really bad and switching layout params + // during the original animation does not achieve the desired effect. + mBubbleBackground.getLayoutParams().width = LayoutParams.WRAP_CONTENT; + mBubbleBackground.requestLayout(); + } + + @Override + public void onAnimationCancel(Animator animator) { + } + + @Override + public void onAnimationRepeat(Animator animator) { + } + }); + mAnimator.start(); + } +} diff --git a/src/com/android/messaging/ui/conversation/ConversationMessageView.java b/src/com/android/messaging/ui/conversation/ConversationMessageView.java new file mode 100644 index 0000000..e22e2c7 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/ConversationMessageView.java @@ -0,0 +1,1206 @@ +/* + * 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.ui.conversation; + +import android.content.Context; +import android.content.res.Resources; +import android.database.Cursor; +import android.graphics.Rect; +import android.graphics.drawable.Drawable; +import android.net.Uri; +import android.support.annotation.Nullable; +import android.text.Spanned; +import android.text.TextUtils; +import android.text.format.DateUtils; +import android.text.format.Formatter; +import android.text.style.URLSpan; +import android.text.util.Linkify; +import android.util.AttributeSet; +import android.util.DisplayMetrics; +import android.view.Gravity; +import android.view.LayoutInflater; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewGroup; +import android.view.WindowManager; +import android.widget.FrameLayout; +import android.widget.ImageView.ScaleType; +import android.widget.LinearLayout; +import android.widget.TextView; + +import com.android.messaging.R; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.data.ConversationMessageData; +import com.android.messaging.datamodel.data.MessageData; +import com.android.messaging.datamodel.data.MessagePartData; +import com.android.messaging.datamodel.data.SubscriptionListData.SubscriptionListEntry; +import com.android.messaging.datamodel.media.ImageRequestDescriptor; +import com.android.messaging.datamodel.media.MessagePartImageRequestDescriptor; +import com.android.messaging.datamodel.media.UriImageRequestDescriptor; +import com.android.messaging.sms.MmsUtils; +import com.android.messaging.ui.AsyncImageView; +import com.android.messaging.ui.AsyncImageView.AsyncImageViewDelayLoader; +import com.android.messaging.ui.AudioAttachmentView; +import com.android.messaging.ui.ContactIconView; +import com.android.messaging.ui.ConversationDrawables; +import com.android.messaging.ui.MultiAttachmentLayout; +import com.android.messaging.ui.MultiAttachmentLayout.OnAttachmentClickListener; +import com.android.messaging.ui.PersonItemView; +import com.android.messaging.ui.UIIntents; +import com.android.messaging.ui.VideoThumbnailView; +import com.android.messaging.util.AccessibilityUtil; +import com.android.messaging.util.Assert; +import com.android.messaging.util.AvatarUriUtil; +import com.android.messaging.util.ContentType; +import com.android.messaging.util.ImageUtils; +import com.android.messaging.util.OsUtil; +import com.android.messaging.util.PhoneUtils; +import com.android.messaging.util.UiUtils; +import com.android.messaging.util.YouTubeUtil; +import com.google.common.base.Predicate; + +import java.util.Collections; +import java.util.Comparator; +import java.util.List; + +/** + * The view for a single entry in a conversation. + */ +public class ConversationMessageView extends FrameLayout implements View.OnClickListener, + View.OnLongClickListener, OnAttachmentClickListener { + public interface ConversationMessageViewHost { + boolean onAttachmentClick(ConversationMessageView view, MessagePartData attachment, + Rect imageBounds, boolean longPress); + SubscriptionListEntry getSubscriptionEntryForSelfParticipant(String selfParticipantId, + boolean excludeDefault); + } + + private final ConversationMessageData mData; + + private LinearLayout mMessageAttachmentsView; + private MultiAttachmentLayout mMultiAttachmentView; + private AsyncImageView mMessageImageView; + private TextView mMessageTextView; + private boolean mMessageTextHasLinks; + private boolean mMessageHasYouTubeLink; + private TextView mStatusTextView; + private TextView mTitleTextView; + private TextView mMmsInfoTextView; + private LinearLayout mMessageTitleLayout; + private TextView mSenderNameTextView; + private ContactIconView mContactIconView; + private ConversationMessageBubbleView mMessageBubble; + private View mSubjectView; + private TextView mSubjectLabel; + private TextView mSubjectText; + private View mDeliveredBadge; + private ViewGroup mMessageMetadataView; + private ViewGroup mMessageTextAndInfoView; + private TextView mSimNameView; + + private boolean mOneOnOne; + private ConversationMessageViewHost mHost; + + public ConversationMessageView(final Context context, final AttributeSet attrs) { + super(context, attrs); + // TODO: we should switch to using Binding and DataModel factory methods. + mData = new ConversationMessageData(); + } + + @Override + protected void onFinishInflate() { + mContactIconView = (ContactIconView) findViewById(R.id.conversation_icon); + mContactIconView.setOnLongClickListener(new OnLongClickListener() { + @Override + public boolean onLongClick(final View view) { + ConversationMessageView.this.performLongClick(); + return true; + } + }); + + mMessageAttachmentsView = (LinearLayout) findViewById(R.id.message_attachments); + mMultiAttachmentView = (MultiAttachmentLayout) findViewById(R.id.multiple_attachments); + mMultiAttachmentView.setOnAttachmentClickListener(this); + + mMessageImageView = (AsyncImageView) findViewById(R.id.message_image); + mMessageImageView.setOnClickListener(this); + mMessageImageView.setOnLongClickListener(this); + + mMessageTextView = (TextView) findViewById(R.id.message_text); + mMessageTextView.setOnClickListener(this); + IgnoreLinkLongClickHelper.ignoreLinkLongClick(mMessageTextView, this); + + mStatusTextView = (TextView) findViewById(R.id.message_status); + mTitleTextView = (TextView) findViewById(R.id.message_title); + mMmsInfoTextView = (TextView) findViewById(R.id.mms_info); + mMessageTitleLayout = (LinearLayout) findViewById(R.id.message_title_layout); + mSenderNameTextView = (TextView) findViewById(R.id.message_sender_name); + mMessageBubble = (ConversationMessageBubbleView) findViewById(R.id.message_content); + mSubjectView = findViewById(R.id.subject_container); + mSubjectLabel = (TextView) mSubjectView.findViewById(R.id.subject_label); + mSubjectText = (TextView) mSubjectView.findViewById(R.id.subject_text); + mDeliveredBadge = findViewById(R.id.smsDeliveredBadge); + mMessageMetadataView = (ViewGroup) findViewById(R.id.message_metadata); + mMessageTextAndInfoView = (ViewGroup) findViewById(R.id.message_text_and_info); + mSimNameView = (TextView) findViewById(R.id.sim_name); + } + + @Override + protected void onMeasure(final int widthMeasureSpec, final int heightMeasureSpec) { + final int horizontalSpace = MeasureSpec.getSize(widthMeasureSpec); + final int iconSize = getResources() + .getDimensionPixelSize(R.dimen.conversation_message_contact_icon_size); + + final int unspecifiedMeasureSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED); + final int iconMeasureSpec = MeasureSpec.makeMeasureSpec(iconSize, MeasureSpec.EXACTLY); + + mContactIconView.measure(iconMeasureSpec, iconMeasureSpec); + + final int arrowWidth = + getResources().getDimensionPixelSize(R.dimen.message_bubble_arrow_width); + + // We need to subtract contact icon width twice from the horizontal space to get + // the max leftover space because we want the message bubble to extend no further than the + // starting position of the message bubble in the opposite direction. + final int maxLeftoverSpace = horizontalSpace - mContactIconView.getMeasuredWidth() * 2 + - arrowWidth - getPaddingLeft() - getPaddingRight(); + final int messageContentWidthMeasureSpec = MeasureSpec.makeMeasureSpec(maxLeftoverSpace, + MeasureSpec.AT_MOST); + + mMessageBubble.measure(messageContentWidthMeasureSpec, unspecifiedMeasureSpec); + + final int maxHeight = Math.max(mContactIconView.getMeasuredHeight(), + mMessageBubble.getMeasuredHeight()); + setMeasuredDimension(horizontalSpace, maxHeight + getPaddingBottom() + getPaddingTop()); + } + + @Override + protected void onLayout(final boolean changed, final int left, final int top, final int right, + final int bottom) { + final boolean isRtl = AccessibilityUtil.isLayoutRtl(this); + + final int iconWidth = mContactIconView.getMeasuredWidth(); + final int iconHeight = mContactIconView.getMeasuredHeight(); + final int iconTop = getPaddingTop(); + final int contentWidth = (right -left) - iconWidth - getPaddingLeft() - getPaddingRight(); + final int contentHeight = mMessageBubble.getMeasuredHeight(); + final int contentTop = iconTop; + + final int iconLeft; + final int contentLeft; + if (mData.getIsIncoming()) { + if (isRtl) { + iconLeft = (right - left) - getPaddingRight() - iconWidth; + contentLeft = iconLeft - contentWidth; + } else { + iconLeft = getPaddingLeft(); + contentLeft = iconLeft + iconWidth; + } + } else { + if (isRtl) { + iconLeft = getPaddingLeft(); + contentLeft = iconLeft + iconWidth; + } else { + iconLeft = (right - left) - getPaddingRight() - iconWidth; + contentLeft = iconLeft - contentWidth; + } + } + + mContactIconView.layout(iconLeft, iconTop, iconLeft + iconWidth, iconTop + iconHeight); + + mMessageBubble.layout(contentLeft, contentTop, contentLeft + contentWidth, + contentTop + contentHeight); + } + + /** + * Fills in the data associated with this view. + * + * @param cursor The cursor from a MessageList that this view is in, pointing to its entry. + */ + public void bind(final Cursor cursor) { + bind(cursor, true, null); + } + + /** + * Fills in the data associated with this view. + * + * @param cursor The cursor from a MessageList that this view is in, pointing to its entry. + * @param oneOnOne Whether this is a 1:1 conversation + */ + public void bind(final Cursor cursor, + final boolean oneOnOne, final String selectedMessageId) { + mOneOnOne = oneOnOne; + + // Update our UI model + mData.bind(cursor); + setSelected(TextUtils.equals(mData.getMessageId(), selectedMessageId)); + + // Update text and image content for the view. + updateViewContent(); + + // Update colors and layout parameters for the view. + updateViewAppearance(); + + updateContentDescription(); + } + + public void setHost(final ConversationMessageViewHost host) { + mHost = host; + } + + /** + * Sets a delay loader instance to manage loading / resuming of image attachments. + */ + public void setImageViewDelayLoader(final AsyncImageViewDelayLoader delayLoader) { + Assert.notNull(mMessageImageView); + mMessageImageView.setDelayLoader(delayLoader); + mMultiAttachmentView.setImageViewDelayLoader(delayLoader); + } + + public ConversationMessageData getData() { + return mData; + } + + /** + * Returns whether we should show simplified visual style for the message view (i.e. hide the + * avatar and bubble arrow, reduce padding). + */ + private boolean shouldShowSimplifiedVisualStyle() { + return mData.getCanClusterWithPreviousMessage(); + } + + /** + * Returns whether we need to show message bubble arrow. We don't show arrow if the message + * contains media attachments or if shouldShowSimplifiedVisualStyle() is true. + */ + private boolean shouldShowMessageBubbleArrow() { + return !shouldShowSimplifiedVisualStyle() + && !(mData.hasAttachments() || mMessageHasYouTubeLink); + } + + /** + * Returns whether we need to show a message bubble for text content. + */ + private boolean shouldShowMessageTextBubble() { + if (mData.hasText()) { + return true; + } + final String subjectText = MmsUtils.cleanseMmsSubject(getResources(), + mData.getMmsSubject()); + if (!TextUtils.isEmpty(subjectText)) { + return true; + } + return false; + } + + private void updateViewContent() { + updateMessageContent(); + int titleResId = -1; + int statusResId = -1; + String statusText = null; + switch(mData.getStatus()) { + case MessageData.BUGLE_STATUS_INCOMING_AUTO_DOWNLOADING: + case MessageData.BUGLE_STATUS_INCOMING_MANUAL_DOWNLOADING: + case MessageData.BUGLE_STATUS_INCOMING_RETRYING_AUTO_DOWNLOAD: + case MessageData.BUGLE_STATUS_INCOMING_RETRYING_MANUAL_DOWNLOAD: + titleResId = R.string.message_title_downloading; + statusResId = R.string.message_status_downloading; + break; + + case MessageData.BUGLE_STATUS_INCOMING_YET_TO_MANUAL_DOWNLOAD: + if (!OsUtil.isSecondaryUser()) { + titleResId = R.string.message_title_manual_download; + if (isSelected()) { + statusResId = R.string.message_status_download_action; + } else { + statusResId = R.string.message_status_download; + } + } + break; + + case MessageData.BUGLE_STATUS_INCOMING_EXPIRED_OR_NOT_AVAILABLE: + if (!OsUtil.isSecondaryUser()) { + titleResId = R.string.message_title_download_failed; + statusResId = R.string.message_status_download_error; + } + break; + + case MessageData.BUGLE_STATUS_INCOMING_DOWNLOAD_FAILED: + if (!OsUtil.isSecondaryUser()) { + titleResId = R.string.message_title_download_failed; + if (isSelected()) { + statusResId = R.string.message_status_download_action; + } else { + statusResId = R.string.message_status_download; + } + } + break; + + case MessageData.BUGLE_STATUS_OUTGOING_YET_TO_SEND: + case MessageData.BUGLE_STATUS_OUTGOING_SENDING: + statusResId = R.string.message_status_sending; + break; + + case MessageData.BUGLE_STATUS_OUTGOING_RESENDING: + case MessageData.BUGLE_STATUS_OUTGOING_AWAITING_RETRY: + statusResId = R.string.message_status_send_retrying; + break; + + case MessageData.BUGLE_STATUS_OUTGOING_FAILED_EMERGENCY_NUMBER: + statusResId = R.string.message_status_send_failed_emergency_number; + break; + + case MessageData.BUGLE_STATUS_OUTGOING_FAILED: + // don't show the error state unless we're the default sms app + if (PhoneUtils.getDefault().isDefaultSmsApp()) { + if (isSelected()) { + statusResId = R.string.message_status_resend; + } else { + statusResId = MmsUtils.mapRawStatusToErrorResourceId( + mData.getStatus(), mData.getRawTelephonyStatus()); + } + break; + } + // FALL THROUGH HERE + + case MessageData.BUGLE_STATUS_OUTGOING_COMPLETE: + case MessageData.BUGLE_STATUS_INCOMING_COMPLETE: + default: + if (!mData.getCanClusterWithNextMessage()) { + statusText = mData.getFormattedReceivedTimeStamp(); + } + break; + } + + final boolean titleVisible = (titleResId >= 0); + if (titleVisible) { + final String titleText = getResources().getString(titleResId); + mTitleTextView.setText(titleText); + + final String mmsInfoText = getResources().getString( + R.string.mms_info, + Formatter.formatFileSize(getContext(), mData.getSmsMessageSize()), + DateUtils.formatDateTime( + getContext(), + mData.getMmsExpiry(), + DateUtils.FORMAT_SHOW_DATE | + DateUtils.FORMAT_SHOW_TIME | + DateUtils.FORMAT_NUMERIC_DATE | + DateUtils.FORMAT_NO_YEAR)); + mMmsInfoTextView.setText(mmsInfoText); + mMessageTitleLayout.setVisibility(View.VISIBLE); + } else { + mMessageTitleLayout.setVisibility(View.GONE); + } + + final String subjectText = MmsUtils.cleanseMmsSubject(getResources(), + mData.getMmsSubject()); + final boolean subjectVisible = !TextUtils.isEmpty(subjectText); + + final boolean senderNameVisible = !mOneOnOne && !mData.getCanClusterWithNextMessage() + && mData.getIsIncoming(); + if (senderNameVisible) { + mSenderNameTextView.setText(mData.getSenderDisplayName()); + mSenderNameTextView.setVisibility(View.VISIBLE); + } else { + mSenderNameTextView.setVisibility(View.GONE); + } + + if (statusResId >= 0) { + statusText = getResources().getString(statusResId); + } + + // We set the text even if the view will be GONE for accessibility + mStatusTextView.setText(statusText); + final boolean statusVisible = !TextUtils.isEmpty(statusText); + if (statusVisible) { + mStatusTextView.setVisibility(View.VISIBLE); + } else { + mStatusTextView.setVisibility(View.GONE); + } + + final boolean deliveredBadgeVisible = + mData.getStatus() == MessageData.BUGLE_STATUS_OUTGOING_DELIVERED; + mDeliveredBadge.setVisibility(deliveredBadgeVisible ? View.VISIBLE : View.GONE); + + // Update the sim indicator. + final boolean showSimIconAsIncoming = mData.getIsIncoming() && + (!mData.hasAttachments() || shouldShowMessageTextBubble()); + final SubscriptionListEntry subscriptionEntry = + mHost.getSubscriptionEntryForSelfParticipant(mData.getSelfParticipantId(), + true /* excludeDefault */); + final boolean simNameVisible = subscriptionEntry != null && + !TextUtils.isEmpty(subscriptionEntry.displayName) && + !mData.getCanClusterWithNextMessage(); + if (simNameVisible) { + final String simNameText = mData.getIsIncoming() ? getResources().getString( + R.string.incoming_sim_name_text, subscriptionEntry.displayName) : + subscriptionEntry.displayName; + mSimNameView.setText(simNameText); + mSimNameView.setTextColor(showSimIconAsIncoming ? getResources().getColor( + R.color.timestamp_text_incoming) : subscriptionEntry.displayColor); + mSimNameView.setVisibility(VISIBLE); + } else { + mSimNameView.setText(null); + mSimNameView.setVisibility(GONE); + } + + final boolean metadataVisible = senderNameVisible || statusVisible + || deliveredBadgeVisible || simNameVisible; + mMessageMetadataView.setVisibility(metadataVisible ? View.VISIBLE : View.GONE); + + final boolean messageTextAndOrInfoVisible = titleVisible || subjectVisible + || mData.hasText() || metadataVisible; + mMessageTextAndInfoView.setVisibility( + messageTextAndOrInfoVisible ? View.VISIBLE : View.GONE); + + if (shouldShowSimplifiedVisualStyle()) { + mContactIconView.setVisibility(View.GONE); + mContactIconView.setImageResourceUri(null); + } else { + mContactIconView.setVisibility(View.VISIBLE); + final Uri avatarUri = AvatarUriUtil.createAvatarUri( + mData.getSenderProfilePhotoUri(), + mData.getSenderFullName(), + mData.getSenderNormalizedDestination(), + mData.getSenderContactLookupKey()); + mContactIconView.setImageResourceUri(avatarUri, mData.getSenderContactId(), + mData.getSenderContactLookupKey(), mData.getSenderNormalizedDestination()); + } + } + + private void updateMessageContent() { + // We must update the text before the attachments since we search the text to see if we + // should make a preview youtube image in the attachments + updateMessageText(); + updateMessageAttachments(); + updateMessageSubject(); + mMessageBubble.bind(mData); + } + + private void updateMessageAttachments() { + // Bind video, audio, and VCard attachments. If there are multiple, they stack vertically. + bindAttachmentsOfSameType(sVideoFilter, + R.layout.message_video_attachment, mVideoViewBinder, VideoThumbnailView.class); + bindAttachmentsOfSameType(sAudioFilter, + R.layout.message_audio_attachment, mAudioViewBinder, AudioAttachmentView.class); + bindAttachmentsOfSameType(sVCardFilter, + R.layout.message_vcard_attachment, mVCardViewBinder, PersonItemView.class); + + // Bind image attachments. If there are multiple, they are shown in a collage view. + final List<MessagePartData> imageParts = mData.getAttachments(sImageFilter); + if (imageParts.size() > 1) { + Collections.sort(imageParts, sImageComparator); + mMultiAttachmentView.bindAttachments(imageParts, null, imageParts.size()); + mMultiAttachmentView.setVisibility(View.VISIBLE); + } else { + mMultiAttachmentView.setVisibility(View.GONE); + } + + // In the case that we have no image attachments and exactly one youtube link in a message + // then we will show a preview. + String youtubeThumbnailUrl = null; + String originalYoutubeLink = null; + if (mMessageTextHasLinks && imageParts.size() == 0) { + CharSequence messageTextWithSpans = mMessageTextView.getText(); + final URLSpan[] spans = ((Spanned) messageTextWithSpans).getSpans(0, + messageTextWithSpans.length(), URLSpan.class); + for (URLSpan span : spans) { + String url = span.getURL(); + String youtubeLinkForUrl = YouTubeUtil.getYoutubePreviewImageLink(url); + if (!TextUtils.isEmpty(youtubeLinkForUrl)) { + if (TextUtils.isEmpty(youtubeThumbnailUrl)) { + // Save the youtube link if we don't already have one + youtubeThumbnailUrl = youtubeLinkForUrl; + originalYoutubeLink = url; + } else { + // We already have a youtube link. This means we have two youtube links so + // we shall show none. + youtubeThumbnailUrl = null; + originalYoutubeLink = null; + break; + } + } + } + } + // We need to keep track if we have a youtube link in the message so that we will not show + // the arrow + mMessageHasYouTubeLink = !TextUtils.isEmpty(youtubeThumbnailUrl); + + // We will show the message image view if there is one attachment or one youtube link + if (imageParts.size() == 1 || mMessageHasYouTubeLink) { + // Get the display metrics for a hint for how large to pull the image data into + final WindowManager windowManager = (WindowManager) getContext(). + getSystemService(Context.WINDOW_SERVICE); + final DisplayMetrics displayMetrics = new DisplayMetrics(); + windowManager.getDefaultDisplay().getMetrics(displayMetrics); + + final int iconSize = getResources() + .getDimensionPixelSize(R.dimen.conversation_message_contact_icon_size); + final int desiredWidth = displayMetrics.widthPixels - iconSize - iconSize; + + if (imageParts.size() == 1) { + final MessagePartData imagePart = imageParts.get(0); + // If the image is big, we want to scale it down to save memory since we're going to + // scale it down to fit into the bubble width. We don't constrain the height. + final ImageRequestDescriptor imageRequest = + new MessagePartImageRequestDescriptor(imagePart, + desiredWidth, + MessagePartData.UNSPECIFIED_SIZE, + false); + adjustImageViewBounds(imagePart); + mMessageImageView.setImageResourceId(imageRequest); + mMessageImageView.setTag(imagePart); + } else { + // Youtube Thumbnail image + final ImageRequestDescriptor imageRequest = + new UriImageRequestDescriptor(Uri.parse(youtubeThumbnailUrl), desiredWidth, + MessagePartData.UNSPECIFIED_SIZE, true /* allowCompression */, + true /* isStatic */, false /* cropToCircle */, + ImageUtils.DEFAULT_CIRCLE_BACKGROUND_COLOR /* circleBackgroundColor */, + ImageUtils.DEFAULT_CIRCLE_STROKE_COLOR /* circleStrokeColor */); + mMessageImageView.setImageResourceId(imageRequest); + mMessageImageView.setTag(originalYoutubeLink); + } + mMessageImageView.setVisibility(View.VISIBLE); + } else { + mMessageImageView.setImageResourceId(null); + mMessageImageView.setVisibility(View.GONE); + } + + // Show the message attachments container if any of its children are visible + boolean attachmentsVisible = false; + for (int i = 0, size = mMessageAttachmentsView.getChildCount(); i < size; i++) { + final View attachmentView = mMessageAttachmentsView.getChildAt(i); + if (attachmentView.getVisibility() == View.VISIBLE) { + attachmentsVisible = true; + break; + } + } + mMessageAttachmentsView.setVisibility(attachmentsVisible ? View.VISIBLE : View.GONE); + } + + private void bindAttachmentsOfSameType(final Predicate<MessagePartData> attachmentTypeFilter, + final int attachmentViewLayoutRes, final AttachmentViewBinder viewBinder, + final Class<?> attachmentViewClass) { + final LayoutInflater layoutInflater = LayoutInflater.from(getContext()); + + // Iterate through all attachments of a particular type (video, audio, etc). + // Find the first attachment index that matches the given type if possible. + int attachmentViewIndex = -1; + View existingAttachmentView; + do { + existingAttachmentView = mMessageAttachmentsView.getChildAt(++attachmentViewIndex); + } while (existingAttachmentView != null && + !(attachmentViewClass.isInstance(existingAttachmentView))); + + for (final MessagePartData attachment : mData.getAttachments(attachmentTypeFilter)) { + View attachmentView = mMessageAttachmentsView.getChildAt(attachmentViewIndex); + if (!attachmentViewClass.isInstance(attachmentView)) { + attachmentView = layoutInflater.inflate(attachmentViewLayoutRes, + mMessageAttachmentsView, false /* attachToRoot */); + attachmentView.setOnClickListener(this); + attachmentView.setOnLongClickListener(this); + mMessageAttachmentsView.addView(attachmentView, attachmentViewIndex); + } + viewBinder.bindView(attachmentView, attachment); + attachmentView.setTag(attachment); + attachmentView.setVisibility(View.VISIBLE); + attachmentViewIndex++; + } + // If there are unused views left over, unbind or remove them. + while (attachmentViewIndex < mMessageAttachmentsView.getChildCount()) { + final View attachmentView = mMessageAttachmentsView.getChildAt(attachmentViewIndex); + if (attachmentViewClass.isInstance(attachmentView)) { + mMessageAttachmentsView.removeViewAt(attachmentViewIndex); + } else { + // No more views of this type; we're done. + break; + } + } + } + + private void updateMessageSubject() { + final String subjectText = MmsUtils.cleanseMmsSubject(getResources(), + mData.getMmsSubject()); + final boolean subjectVisible = !TextUtils.isEmpty(subjectText); + + if (subjectVisible) { + mSubjectText.setText(subjectText); + mSubjectView.setVisibility(View.VISIBLE); + } else { + mSubjectView.setVisibility(View.GONE); + } + } + + private void updateMessageText() { + final String text = mData.getText(); + if (!TextUtils.isEmpty(text)) { + mMessageTextView.setText(text); + // Linkify phone numbers, web urls, emails, and map addresses to allow users to + // click on them and take the default intent. + mMessageTextHasLinks = Linkify.addLinks(mMessageTextView, Linkify.ALL); + mMessageTextView.setVisibility(View.VISIBLE); + } else { + mMessageTextView.setVisibility(View.GONE); + mMessageTextHasLinks = false; + } + } + + private void updateViewAppearance() { + final Resources res = getResources(); + final ConversationDrawables drawableProvider = ConversationDrawables.get(); + final boolean incoming = mData.getIsIncoming(); + final boolean outgoing = !incoming; + final boolean showArrow = shouldShowMessageBubbleArrow(); + + final int messageTopPaddingClustered = + res.getDimensionPixelSize(R.dimen.message_padding_same_author); + final int messageTopPaddingDefault = + res.getDimensionPixelSize(R.dimen.message_padding_default); + final int arrowWidth = res.getDimensionPixelOffset(R.dimen.message_bubble_arrow_width); + final int messageTextMinHeightDefault = res.getDimensionPixelSize( + R.dimen.conversation_message_contact_icon_size); + final int messageTextLeftRightPadding = res.getDimensionPixelOffset( + R.dimen.message_text_left_right_padding); + final int textTopPaddingDefault = res.getDimensionPixelOffset( + R.dimen.message_text_top_padding); + final int textBottomPaddingDefault = res.getDimensionPixelOffset( + R.dimen.message_text_bottom_padding); + + // These values depend on whether the message has text, attachments, or both. + // We intentionally don't set defaults, so the compiler will tell us if we forget + // to set one of them, or if we set one more than once. + final int contentLeftPadding, contentRightPadding; + final Drawable textBackground; + final int textMinHeight; + final int textTopMargin; + final int textTopPadding, textBottomPadding; + final int textLeftPadding, textRightPadding; + + if (mData.hasAttachments()) { + if (shouldShowMessageTextBubble()) { + // Text and attachment(s) + contentLeftPadding = incoming ? arrowWidth : 0; + contentRightPadding = outgoing ? arrowWidth : 0; + textBackground = drawableProvider.getBubbleDrawable( + isSelected(), + incoming, + false /* needArrow */, + mData.hasIncomingErrorStatus()); + textMinHeight = messageTextMinHeightDefault; + textTopMargin = messageTopPaddingClustered; + textTopPadding = textTopPaddingDefault; + textBottomPadding = textBottomPaddingDefault; + textLeftPadding = messageTextLeftRightPadding; + textRightPadding = messageTextLeftRightPadding; + } else { + // Attachment(s) only + contentLeftPadding = incoming ? arrowWidth : 0; + contentRightPadding = outgoing ? arrowWidth : 0; + textBackground = null; + textMinHeight = 0; + textTopMargin = 0; + textTopPadding = 0; + textBottomPadding = 0; + textLeftPadding = 0; + textRightPadding = 0; + } + } else { + // Text only + contentLeftPadding = (!showArrow && incoming) ? arrowWidth : 0; + contentRightPadding = (!showArrow && outgoing) ? arrowWidth : 0; + textBackground = drawableProvider.getBubbleDrawable( + isSelected(), + incoming, + shouldShowMessageBubbleArrow(), + mData.hasIncomingErrorStatus()); + textMinHeight = messageTextMinHeightDefault; + textTopMargin = 0; + textTopPadding = textTopPaddingDefault; + textBottomPadding = textBottomPaddingDefault; + if (showArrow && incoming) { + textLeftPadding = messageTextLeftRightPadding + arrowWidth; + } else { + textLeftPadding = messageTextLeftRightPadding; + } + if (showArrow && outgoing) { + textRightPadding = messageTextLeftRightPadding + arrowWidth; + } else { + textRightPadding = messageTextLeftRightPadding; + } + } + + // These values do not depend on whether the message includes attachments + final int gravity = incoming ? (Gravity.START | Gravity.CENTER_VERTICAL) : + (Gravity.END | Gravity.CENTER_VERTICAL); + final int messageTopPadding = shouldShowSimplifiedVisualStyle() ? + messageTopPaddingClustered : messageTopPaddingDefault; + final int metadataTopPadding = res.getDimensionPixelOffset( + R.dimen.message_metadata_top_padding); + + // Update the message text/info views + ImageUtils.setBackgroundDrawableOnView(mMessageTextAndInfoView, textBackground); + mMessageTextAndInfoView.setMinimumHeight(textMinHeight); + final LinearLayout.LayoutParams textAndInfoLayoutParams = + (LinearLayout.LayoutParams) mMessageTextAndInfoView.getLayoutParams(); + textAndInfoLayoutParams.topMargin = textTopMargin; + + if (UiUtils.isRtlMode()) { + // Need to switch right and left padding in RtL mode + mMessageTextAndInfoView.setPadding(textRightPadding, textTopPadding, textLeftPadding, + textBottomPadding); + mMessageBubble.setPadding(contentRightPadding, 0, contentLeftPadding, 0); + } else { + mMessageTextAndInfoView.setPadding(textLeftPadding, textTopPadding, textRightPadding, + textBottomPadding); + mMessageBubble.setPadding(contentLeftPadding, 0, contentRightPadding, 0); + } + + // Update the message row and message bubble views + setPadding(getPaddingLeft(), messageTopPadding, getPaddingRight(), 0); + mMessageBubble.setGravity(gravity); + updateMessageAttachmentsAppearance(gravity); + + mMessageMetadataView.setPadding(0, metadataTopPadding, 0, 0); + + updateTextAppearance(); + + requestLayout(); + } + + private void updateContentDescription() { + StringBuilder description = new StringBuilder(); + + Resources res = getResources(); + String separator = res.getString(R.string.enumeration_comma); + + // Sender information + boolean hasPlainTextMessage = !(TextUtils.isEmpty(mData.getText()) || + mMessageTextHasLinks); + if (mData.getIsIncoming()) { + int senderResId = hasPlainTextMessage + ? R.string.incoming_text_sender_content_description + : R.string.incoming_sender_content_description; + description.append(res.getString(senderResId, mData.getSenderDisplayName())); + } else { + int senderResId = hasPlainTextMessage + ? R.string.outgoing_text_sender_content_description + : R.string.outgoing_sender_content_description; + description.append(res.getString(senderResId)); + } + + if (mSubjectView.getVisibility() == View.VISIBLE) { + description.append(separator); + description.append(mSubjectText.getText()); + } + + if (mMessageTextView.getVisibility() == View.VISIBLE) { + // If the message has hyperlinks, we will let the user navigate to the text message so + // that the hyperlink can be clicked. Otherwise, the text message does not need to + // be reachable. + if (mMessageTextHasLinks) { + mMessageTextView.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_YES); + } else { + mMessageTextView.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO); + description.append(separator); + description.append(mMessageTextView.getText()); + } + } + + if (mMessageTitleLayout.getVisibility() == View.VISIBLE) { + description.append(separator); + description.append(mTitleTextView.getText()); + + description.append(separator); + description.append(mMmsInfoTextView.getText()); + } + + if (mStatusTextView.getVisibility() == View.VISIBLE) { + description.append(separator); + description.append(mStatusTextView.getText()); + } + + if (mSimNameView.getVisibility() == View.VISIBLE) { + description.append(separator); + description.append(mSimNameView.getText()); + } + + if (mDeliveredBadge.getVisibility() == View.VISIBLE) { + description.append(separator); + description.append(res.getString(R.string.delivered_status_content_description)); + } + + setContentDescription(description); + } + + private void updateMessageAttachmentsAppearance(final int gravity) { + mMessageAttachmentsView.setGravity(gravity); + + // Tint image/video attachments when selected + final int selectedImageTint = getResources().getColor(R.color.message_image_selected_tint); + if (mMessageImageView.getVisibility() == View.VISIBLE) { + if (isSelected()) { + mMessageImageView.setColorFilter(selectedImageTint); + } else { + mMessageImageView.clearColorFilter(); + } + } + if (mMultiAttachmentView.getVisibility() == View.VISIBLE) { + if (isSelected()) { + mMultiAttachmentView.setColorFilter(selectedImageTint); + } else { + mMultiAttachmentView.clearColorFilter(); + } + } + for (int i = 0, size = mMessageAttachmentsView.getChildCount(); i < size; i++) { + final View attachmentView = mMessageAttachmentsView.getChildAt(i); + if (attachmentView instanceof VideoThumbnailView + && attachmentView.getVisibility() == View.VISIBLE) { + final VideoThumbnailView videoView = (VideoThumbnailView) attachmentView; + if (isSelected()) { + videoView.setColorFilter(selectedImageTint); + } else { + videoView.clearColorFilter(); + } + } + } + + // If there are multiple attachment bubbles in a single message, add some separation. + final int multipleAttachmentPadding = + getResources().getDimensionPixelSize(R.dimen.message_padding_same_author); + + boolean previousVisibleView = false; + for (int i = 0, size = mMessageAttachmentsView.getChildCount(); i < size; i++) { + final View attachmentView = mMessageAttachmentsView.getChildAt(i); + if (attachmentView.getVisibility() == View.VISIBLE) { + final int margin = previousVisibleView ? multipleAttachmentPadding : 0; + ((LinearLayout.LayoutParams) attachmentView.getLayoutParams()).topMargin = margin; + // updateViewAppearance calls requestLayout() at the end, so we don't need to here + previousVisibleView = true; + } + } + } + + private void updateTextAppearance() { + int messageColorResId; + int statusColorResId = -1; + int infoColorResId = -1; + int timestampColorResId; + int subjectLabelColorResId; + if (isSelected()) { + messageColorResId = R.color.message_text_color_incoming; + statusColorResId = R.color.message_action_status_text; + infoColorResId = R.color.message_action_info_text; + if (shouldShowMessageTextBubble()) { + timestampColorResId = R.color.message_action_timestamp_text; + subjectLabelColorResId = R.color.message_action_timestamp_text; + } else { + // If there's no text, the timestamp will be shown below the attachments, + // against the conversation view background. + timestampColorResId = R.color.timestamp_text_outgoing; + subjectLabelColorResId = R.color.timestamp_text_outgoing; + } + } else { + messageColorResId = (mData.getIsIncoming() ? + R.color.message_text_color_incoming : R.color.message_text_color_outgoing); + statusColorResId = messageColorResId; + infoColorResId = R.color.timestamp_text_incoming; + switch(mData.getStatus()) { + + case MessageData.BUGLE_STATUS_OUTGOING_FAILED: + case MessageData.BUGLE_STATUS_OUTGOING_FAILED_EMERGENCY_NUMBER: + timestampColorResId = R.color.message_failed_timestamp_text; + subjectLabelColorResId = R.color.timestamp_text_outgoing; + break; + + case MessageData.BUGLE_STATUS_OUTGOING_YET_TO_SEND: + case MessageData.BUGLE_STATUS_OUTGOING_SENDING: + case MessageData.BUGLE_STATUS_OUTGOING_RESENDING: + case MessageData.BUGLE_STATUS_OUTGOING_AWAITING_RETRY: + case MessageData.BUGLE_STATUS_OUTGOING_COMPLETE: + case MessageData.BUGLE_STATUS_OUTGOING_DELIVERED: + timestampColorResId = R.color.timestamp_text_outgoing; + subjectLabelColorResId = R.color.timestamp_text_outgoing; + break; + + case MessageData.BUGLE_STATUS_INCOMING_EXPIRED_OR_NOT_AVAILABLE: + case MessageData.BUGLE_STATUS_INCOMING_DOWNLOAD_FAILED: + messageColorResId = R.color.message_text_color_incoming_download_failed; + timestampColorResId = R.color.message_download_failed_timestamp_text; + subjectLabelColorResId = R.color.message_text_color_incoming_download_failed; + statusColorResId = R.color.message_download_failed_status_text; + infoColorResId = R.color.message_info_text_incoming_download_failed; + break; + + case MessageData.BUGLE_STATUS_INCOMING_AUTO_DOWNLOADING: + case MessageData.BUGLE_STATUS_INCOMING_MANUAL_DOWNLOADING: + case MessageData.BUGLE_STATUS_INCOMING_RETRYING_AUTO_DOWNLOAD: + case MessageData.BUGLE_STATUS_INCOMING_RETRYING_MANUAL_DOWNLOAD: + case MessageData.BUGLE_STATUS_INCOMING_YET_TO_MANUAL_DOWNLOAD: + timestampColorResId = R.color.message_text_color_incoming; + subjectLabelColorResId = R.color.message_text_color_incoming; + infoColorResId = R.color.timestamp_text_incoming; + break; + + case MessageData.BUGLE_STATUS_INCOMING_COMPLETE: + default: + timestampColorResId = R.color.timestamp_text_incoming; + subjectLabelColorResId = R.color.timestamp_text_incoming; + infoColorResId = -1; // Not used + break; + } + } + final int messageColor = getResources().getColor(messageColorResId); + mMessageTextView.setTextColor(messageColor); + mMessageTextView.setLinkTextColor(messageColor); + mSubjectText.setTextColor(messageColor); + if (statusColorResId >= 0) { + mTitleTextView.setTextColor(getResources().getColor(statusColorResId)); + } + if (infoColorResId >= 0) { + mMmsInfoTextView.setTextColor(getResources().getColor(infoColorResId)); + } + if (timestampColorResId == R.color.timestamp_text_incoming && + mData.hasAttachments() && !shouldShowMessageTextBubble()) { + timestampColorResId = R.color.timestamp_text_outgoing; + } + mStatusTextView.setTextColor(getResources().getColor(timestampColorResId)); + + mSubjectLabel.setTextColor(getResources().getColor(subjectLabelColorResId)); + mSenderNameTextView.setTextColor(getResources().getColor(timestampColorResId)); + } + + /** + * If we don't know the size of the image, we want to show it in a fixed-sized frame to + * avoid janks when the image is loaded and resized. Otherwise, we can set the imageview to + * take on normal layout params. + */ + private void adjustImageViewBounds(final MessagePartData imageAttachment) { + Assert.isTrue(ContentType.isImageType(imageAttachment.getContentType())); + final ViewGroup.LayoutParams layoutParams = mMessageImageView.getLayoutParams(); + if (imageAttachment.getWidth() == MessagePartData.UNSPECIFIED_SIZE || + imageAttachment.getHeight() == MessagePartData.UNSPECIFIED_SIZE) { + // We don't know the size of the image attachment, enable letterboxing on the image + // and show a fixed sized attachment. This should happen at most once per image since + // after the image is loaded we then save the image dimensions to the db so that the + // next time we can display the full size. + layoutParams.width = getResources() + .getDimensionPixelSize(R.dimen.image_attachment_fallback_width); + layoutParams.height = getResources() + .getDimensionPixelSize(R.dimen.image_attachment_fallback_height); + mMessageImageView.setScaleType(ScaleType.CENTER_CROP); + } else { + layoutParams.width = ViewGroup.LayoutParams.MATCH_PARENT; + layoutParams.height = ViewGroup.LayoutParams.WRAP_CONTENT; + // ScaleType.CENTER_INSIDE and FIT_CENTER behave similarly for most images. However, + // FIT_CENTER works better for small images as it enlarges the image such that the + // minimum size ("android:minWidth" etc) is honored. + mMessageImageView.setScaleType(ScaleType.FIT_CENTER); + } + } + + @Override + public void onClick(final View view) { + final Object tag = view.getTag(); + if (tag instanceof MessagePartData) { + final Rect bounds = UiUtils.getMeasuredBoundsOnScreen(view); + onAttachmentClick((MessagePartData) tag, bounds, false /* longPress */); + } else if (tag instanceof String) { + // Currently the only object that would make a tag of a string is a youtube preview + // image + UIIntents.get().launchBrowserForUrl(getContext(), (String) tag); + } + } + + @Override + public boolean onLongClick(final View view) { + if (view == mMessageTextView) { + // Preemptively handle the long click event on message text so it's not handled by + // the link spans. + return performLongClick(); + } + + final Object tag = view.getTag(); + if (tag instanceof MessagePartData) { + final Rect bounds = UiUtils.getMeasuredBoundsOnScreen(view); + return onAttachmentClick((MessagePartData) tag, bounds, true /* longPress */); + } + + return false; + } + + @Override + public boolean onAttachmentClick(final MessagePartData attachment, + final Rect viewBoundsOnScreen, final boolean longPress) { + return mHost.onAttachmentClick(this, attachment, viewBoundsOnScreen, longPress); + } + + public ContactIconView getContactIconView() { + return mContactIconView; + } + + // Sort photos in MultiAttachLayout in the same order as the ConversationImagePartsView + static final Comparator<MessagePartData> sImageComparator = new Comparator<MessagePartData>(){ + @Override + public int compare(final MessagePartData x, final MessagePartData y) { + return x.getPartId().compareTo(y.getPartId()); + } + }; + + static final Predicate<MessagePartData> sVideoFilter = new Predicate<MessagePartData>() { + @Override + public boolean apply(final MessagePartData part) { + return part.isVideo(); + } + }; + + static final Predicate<MessagePartData> sAudioFilter = new Predicate<MessagePartData>() { + @Override + public boolean apply(final MessagePartData part) { + return part.isAudio(); + } + }; + + static final Predicate<MessagePartData> sVCardFilter = new Predicate<MessagePartData>() { + @Override + public boolean apply(final MessagePartData part) { + return part.isVCard(); + } + }; + + static final Predicate<MessagePartData> sImageFilter = new Predicate<MessagePartData>() { + @Override + public boolean apply(final MessagePartData part) { + return part.isImage(); + } + }; + + interface AttachmentViewBinder { + void bindView(View view, MessagePartData attachment); + void unbind(View view); + } + + final AttachmentViewBinder mVideoViewBinder = new AttachmentViewBinder() { + @Override + public void bindView(final View view, final MessagePartData attachment) { + ((VideoThumbnailView) view).setSource(attachment, mData.getIsIncoming()); + } + + @Override + public void unbind(final View view) { + ((VideoThumbnailView) view).setSource((Uri) null, mData.getIsIncoming()); + } + }; + + final AttachmentViewBinder mAudioViewBinder = new AttachmentViewBinder() { + @Override + public void bindView(final View view, final MessagePartData attachment) { + final AudioAttachmentView audioView = (AudioAttachmentView) view; + audioView.bindMessagePartData(attachment, isSelected() || mData.getIsIncoming()); + audioView.setBackground(ConversationDrawables.get().getBubbleDrawable( + isSelected(), mData.getIsIncoming(), false /* needArrow */, + mData.hasIncomingErrorStatus())); + } + + @Override + public void unbind(final View view) { + ((AudioAttachmentView) view).bindMessagePartData(null, mData.getIsIncoming()); + } + }; + + final AttachmentViewBinder mVCardViewBinder = new AttachmentViewBinder() { + @Override + public void bindView(final View view, final MessagePartData attachment) { + final PersonItemView personView = (PersonItemView) view; + personView.bind(DataModel.get().createVCardContactItemData(getContext(), + attachment)); + personView.setBackground(ConversationDrawables.get().getBubbleDrawable( + isSelected(), mData.getIsIncoming(), false /* needArrow */, + mData.hasIncomingErrorStatus())); + final int nameTextColorRes; + final int detailsTextColorRes; + if (isSelected()) { + nameTextColorRes = R.color.message_text_color_incoming; + detailsTextColorRes = R.color.message_text_color_incoming; + } else { + nameTextColorRes = mData.getIsIncoming() ? R.color.message_text_color_incoming + : R.color.message_text_color_outgoing; + detailsTextColorRes = mData.getIsIncoming() ? R.color.timestamp_text_incoming + : R.color.timestamp_text_outgoing; + } + personView.setNameTextColor(getResources().getColor(nameTextColorRes)); + personView.setDetailsTextColor(getResources().getColor(detailsTextColorRes)); + } + + @Override + public void unbind(final View view) { + ((PersonItemView) view).bind(null); + } + }; + + /** + * A helper class that allows us to handle long clicks on linkified message text view (i.e. to + * select the message) so it's not handled by the link spans to launch apps for the links. + */ + private static class IgnoreLinkLongClickHelper implements OnLongClickListener, OnTouchListener { + private boolean mIsLongClick; + private final OnLongClickListener mDelegateLongClickListener; + + /** + * Ignore long clicks on linkified texts for a given text view. + * @param textView the TextView to ignore long clicks on + * @param longClickListener a delegate OnLongClickListener to be called when the view is + * long clicked. + */ + public static void ignoreLinkLongClick(final TextView textView, + @Nullable final OnLongClickListener longClickListener) { + final IgnoreLinkLongClickHelper helper = + new IgnoreLinkLongClickHelper(longClickListener); + textView.setOnLongClickListener(helper); + textView.setOnTouchListener(helper); + } + + private IgnoreLinkLongClickHelper(@Nullable final OnLongClickListener longClickListener) { + mDelegateLongClickListener = longClickListener; + } + + @Override + public boolean onLongClick(final View v) { + // Record that this click is a long click. + mIsLongClick = true; + if (mDelegateLongClickListener != null) { + return mDelegateLongClickListener.onLongClick(v); + } + return false; + } + + @Override + public boolean onTouch(final View v, final MotionEvent event) { + if (event.getActionMasked() == MotionEvent.ACTION_UP && mIsLongClick) { + // This touch event is a long click, preemptively handle this touch event so that + // the link span won't get a onClicked() callback. + mIsLongClick = false; + return true; + } + + if (event.getActionMasked() == MotionEvent.ACTION_DOWN) { + mIsLongClick = false; + } + return false; + } + } +} diff --git a/src/com/android/messaging/ui/conversation/ConversationSimSelector.java b/src/com/android/messaging/ui/conversation/ConversationSimSelector.java new file mode 100644 index 0000000..fc43a46 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/ConversationSimSelector.java @@ -0,0 +1,128 @@ +/* + * 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.ui.conversation; + +import android.content.Context; +import android.support.v4.util.Pair; +import android.text.TextUtils; + +import com.android.messaging.Factory; +import com.android.messaging.R; +import com.android.messaging.datamodel.data.SubscriptionListData; +import com.android.messaging.datamodel.data.SubscriptionListData.SubscriptionListEntry; +import com.android.messaging.ui.conversation.SimSelectorView.SimSelectorViewListener; +import com.android.messaging.util.AccessibilityUtil; +import com.android.messaging.util.Assert; +import com.android.messaging.util.OsUtil; +import com.android.messaging.util.ThreadUtil; + +/** + * Manages showing/hiding the SIM selector in conversation. + */ +abstract class ConversationSimSelector extends ConversationInput { + private SimSelectorView mSimSelectorView; + private Pair<Boolean /* show */, Boolean /* animate */> mPendingShow; + private boolean mDataReady; + private String mSelectedSimText; + + public ConversationSimSelector(ConversationInputBase baseHost) { + super(baseHost, false); + } + + public void onSubscriptionListDataLoaded(final SubscriptionListData subscriptionListData) { + ensureSimSelectorView(); + mSimSelectorView.bind(subscriptionListData); + mDataReady = subscriptionListData != null && subscriptionListData.hasData(); + if (mPendingShow != null && mDataReady) { + Assert.isTrue(OsUtil.isAtLeastL_MR1()); + final boolean show = mPendingShow.first; + final boolean animate = mPendingShow.second; + ThreadUtil.getMainThreadHandler().post(new Runnable() { + @Override + public void run() { + // This will No-Op if we are no longer attached to the host. + mConversationInputBase.showHideInternal(ConversationSimSelector.this, + show, animate); + } + }); + mPendingShow = null; + } + } + + private void announcedSelectedSim() { + final Context context = Factory.get().getApplicationContext(); + if (AccessibilityUtil.isTouchExplorationEnabled(context) && + !TextUtils.isEmpty(mSelectedSimText)) { + AccessibilityUtil.announceForAccessibilityCompat( + mSimSelectorView, null, + context.getString(R.string.selected_sim_content_message, mSelectedSimText)); + } + } + + public void setSelected(final SubscriptionListEntry subEntry) { + mSelectedSimText = subEntry == null ? null : subEntry.displayName; + } + + @Override + public boolean show(boolean animate) { + announcedSelectedSim(); + return showHide(true, animate); + } + + @Override + public boolean hide(boolean animate) { + return showHide(false, animate); + } + + private boolean showHide(final boolean show, final boolean animate) { + if (!OsUtil.isAtLeastL_MR1()) { + return false; + } + + if (mDataReady) { + mSimSelectorView.showOrHide(show, animate); + return mSimSelectorView.isOpen() == show; + } else { + mPendingShow = Pair.create(show, animate); + return false; + } + } + + private void ensureSimSelectorView() { + if (mSimSelectorView == null) { + // Grab the SIM selector view from the host. This class assumes ownership of it. + mSimSelectorView = getSimSelectorView(); + mSimSelectorView.setItemLayoutId(getSimSelectorItemLayoutId()); + mSimSelectorView.setListener(new SimSelectorViewListener() { + + @Override + public void onSimSelectorVisibilityChanged(boolean visible) { + onVisibilityChanged(visible); + } + + @Override + public void onSimItemClicked(SubscriptionListEntry item) { + selectSim(item); + } + }); + } + } + + protected abstract SimSelectorView getSimSelectorView(); + protected abstract void selectSim(final SubscriptionListEntry item); + protected abstract int getSimSelectorItemLayoutId(); + +} diff --git a/src/com/android/messaging/ui/conversation/EnterSelfPhoneNumberDialog.java b/src/com/android/messaging/ui/conversation/EnterSelfPhoneNumberDialog.java new file mode 100644 index 0000000..e3ad601 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/EnterSelfPhoneNumberDialog.java @@ -0,0 +1,92 @@ +/* + * 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.ui.conversation; + +import android.app.AlertDialog; +import android.app.Dialog; +import android.app.DialogFragment; +import android.content.Context; +import android.content.DialogInterface; +import android.os.Bundle; +import android.text.TextUtils; +import android.view.LayoutInflater; +import android.widget.EditText; + +import com.android.messaging.R; +import com.android.messaging.datamodel.ParticipantRefresh; +import com.android.messaging.util.BuglePrefs; +import com.android.messaging.util.UiUtils; + +/** + * The dialog for the user to enter the phone number of their sim. + */ +public class EnterSelfPhoneNumberDialog extends DialogFragment { + private EditText mEditText; + private int mSubId; + + public static EnterSelfPhoneNumberDialog newInstance(final int subId) { + final EnterSelfPhoneNumberDialog dialog = new EnterSelfPhoneNumberDialog(); + dialog.mSubId = subId; + return dialog; + } + + @Override + public Dialog onCreateDialog(final Bundle savedInstanceState) { + final Context context = getActivity(); + final LayoutInflater inflater = LayoutInflater.from(context); + mEditText = (EditText) inflater.inflate(R.layout.enter_phone_number_view, null, false); + + final AlertDialog.Builder builder = new AlertDialog.Builder(context); + builder.setTitle(R.string.enter_phone_number_title) + .setMessage(R.string.enter_phone_number_text) + .setView(mEditText) + .setNegativeButton(android.R.string.cancel, + new DialogInterface.OnClickListener() { + @Override + public void onClick(final DialogInterface dialog, + final int button) { + dismiss(); + } + }) + .setPositiveButton(android.R.string.ok, + new DialogInterface.OnClickListener() { + @Override + public void onClick(final DialogInterface dialog, + final int button) { + final String newNumber = mEditText.getText().toString(); + dismiss(); + if (!TextUtils.isEmpty(newNumber)) { + savePhoneNumberInPrefs(newNumber); + // TODO: Remove this toast and just auto-send + // the message instead + UiUtils.showToast( + R.string + .toast_after_setting_default_sms_app_for_message_send); + } + } + }); + return builder.create(); + } + + private void savePhoneNumberInPrefs(final String newPhoneNumber) { + final BuglePrefs subPrefs = BuglePrefs.getSubscriptionPrefs(mSubId); + subPrefs.putString(getString(R.string.mms_phone_number_pref_key), + newPhoneNumber); + // Update the self participants so the new phone number will be reflected + // everywhere in the UI. + ParticipantRefresh.refreshSelfParticipants(); + } +} diff --git a/src/com/android/messaging/ui/conversation/LaunchConversationActivity.java b/src/com/android/messaging/ui/conversation/LaunchConversationActivity.java new file mode 100644 index 0000000..8af9f75 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/LaunchConversationActivity.java @@ -0,0 +1,134 @@ +/* + * 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.ui.conversation; + +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; +import android.text.TextUtils; + +import com.android.messaging.Factory; +import com.android.messaging.R; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.binding.Binding; +import com.android.messaging.datamodel.binding.BindingBase; +import com.android.messaging.datamodel.data.LaunchConversationData; +import com.android.messaging.ui.UIIntents; +import com.android.messaging.util.ContentType; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.UiUtils; +import com.android.messaging.util.UriUtil; + +import java.io.UnsupportedEncodingException; +import java.net.URLDecoder; + +/** + * Launches ConversationActivity for sending a message to, or viewing messages from, a specific + * recipient. + * <p> + * (This activity should be marked noHistory="true" in AndroidManifest.xml) + */ +public class LaunchConversationActivity extends Activity implements + LaunchConversationData.LaunchConversationDataListener { + static final String SMS_BODY = "sms_body"; + static final String ADDRESS = "address"; + final Binding<LaunchConversationData> mBinding = BindingBase.createBinding(this); + String mSmsBody; + + @Override + protected void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + if (UiUtils.redirectToPermissionCheckIfNeeded(this)) { + return; + } + + final Intent intent = getIntent(); + final String action = intent.getAction(); + if (Intent.ACTION_SENDTO.equals(action) || Intent.ACTION_VIEW.equals(action)) { + String[] recipients = UriUtil.parseRecipientsFromSmsMmsUri(intent.getData()); + final boolean haveAddress = !TextUtils.isEmpty(intent.getStringExtra(ADDRESS)); + final boolean haveEmail = !TextUtils.isEmpty(intent.getStringExtra(Intent.EXTRA_EMAIL)); + if (recipients == null && (haveAddress || haveEmail)) { + if (haveAddress) { + recipients = new String[] { intent.getStringExtra(ADDRESS) }; + } else { + recipients = new String[] { intent.getStringExtra(Intent.EXTRA_EMAIL) }; + } + } + mSmsBody = intent.getStringExtra(SMS_BODY); + if (TextUtils.isEmpty(mSmsBody)) { + // Used by intents sent from the web YouTube (and perhaps others). + mSmsBody = getBody(intent.getData()); + if (TextUtils.isEmpty(mSmsBody)) { + // If that fails, try yet another method apps use to share text + if (ContentType.TEXT_PLAIN.equals(intent.getType())) { + mSmsBody = intent.getStringExtra(Intent.EXTRA_TEXT); + } + } + } + if (recipients != null) { + mBinding.bind(DataModel.get().createLaunchConversationData(this)); + mBinding.getData().getOrCreateConversation(mBinding, recipients); + } else { + // No recipients were specified in the intent. + // Start a new conversation with contact picker. The new conversation will be + // primed with the (optional) message in mSmsBody. + onGetOrCreateNewConversation(null); + } + } else { + LogUtil.w(LogUtil.BUGLE_TAG, "Unsupported conversation intent action : " + action); + } + // As of M, activities without a visible window must finish before onResume completes. + finish(); + } + + private String getBody(final Uri uri) { + if (uri == null) { + return null; + } + String urlStr = uri.getSchemeSpecificPart(); + if (!urlStr.contains("?")) { + return null; + } + urlStr = urlStr.substring(urlStr.indexOf('?') + 1); + final String[] params = urlStr.split("&"); + for (final String p : params) { + if (p.startsWith("body=")) { + try { + return URLDecoder.decode(p.substring(5), "UTF-8"); + } catch (final UnsupportedEncodingException e) { + // Invalid URL, ignore + } + } + } + return null; + } + + @Override + public void onGetOrCreateNewConversation(final String conversationId) { + final Context context = Factory.get().getApplicationContext(); + UIIntents.get().launchConversationActivityWithParentStack(context, conversationId, + mSmsBody); + } + + @Override + public void onGetOrCreateNewConversationFailed() { + UiUtils.showToastAtBottom(R.string.conversation_creation_failure); + } +} diff --git a/src/com/android/messaging/ui/conversation/MessageBubbleBackground.java b/src/com/android/messaging/ui/conversation/MessageBubbleBackground.java new file mode 100644 index 0000000..4c22970 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/MessageBubbleBackground.java @@ -0,0 +1,47 @@ +/* + * 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.ui.conversation; + +import android.content.Context; +import android.util.AttributeSet; +import android.widget.LinearLayout; + +import com.android.messaging.R; + +public class MessageBubbleBackground extends LinearLayout { + private final int mSnapWidthPixels; + + public MessageBubbleBackground(Context context, AttributeSet attrs) { + super(context, attrs); + mSnapWidthPixels = context.getResources().getDimensionPixelSize( + R.dimen.conversation_bubble_width_snap); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + final int widthPadding = getPaddingLeft() + getPaddingRight(); + int bubbleWidth = getMeasuredWidth() - widthPadding; + final int maxWidth = MeasureSpec.getSize(widthMeasureSpec) - widthPadding; + // Round up to next snapWidthPixels + bubbleWidth = Math.min(maxWidth, + (int) (Math.ceil(bubbleWidth / (float) mSnapWidthPixels) * mSnapWidthPixels)); + super.onMeasure( + MeasureSpec.makeMeasureSpec(bubbleWidth + widthPadding, MeasureSpec.EXACTLY), + heightMeasureSpec); + } +} diff --git a/src/com/android/messaging/ui/conversation/MessageDetailsDialog.java b/src/com/android/messaging/ui/conversation/MessageDetailsDialog.java new file mode 100644 index 0000000..89b9148 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/MessageDetailsDialog.java @@ -0,0 +1,381 @@ +/* + * 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.ui.conversation; + +import android.app.AlertDialog; +import android.content.Context; +import android.content.res.Resources; +import android.net.Uri; +import android.text.TextUtils; +import android.text.format.Formatter; + +import com.android.messaging.Factory; +import com.android.messaging.R; +import com.android.messaging.datamodel.BugleDatabaseOperations; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.data.ConversationMessageData; +import com.android.messaging.datamodel.data.ConversationParticipantsData; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.mmslib.pdu.PduHeaders; +import com.android.messaging.sms.DatabaseMessages.MmsMessage; +import com.android.messaging.sms.MmsUtils; +import com.android.messaging.util.Assert; +import com.android.messaging.util.Assert.DoesNotRunOnMainThread; +import com.android.messaging.util.Dates; +import com.android.messaging.util.DebugUtils; +import com.android.messaging.util.OsUtil; +import com.android.messaging.util.PhoneUtils; +import com.android.messaging.util.SafeAsyncTask; + +import java.util.List; + +public class MessageDetailsDialog { + private static final String RECIPIENT_SEPARATOR = ", "; + + // All methods are static, no creating this class + private MessageDetailsDialog() { + } + + public static void show(final Context context, final ConversationMessageData data, + final ConversationParticipantsData participants, final ParticipantData self) { + if (DebugUtils.isDebugEnabled()) { + new SafeAsyncTask<Void, Void, String>() { + @Override + protected String doInBackgroundTimed(Void... params) { + return getMessageDetails(context, data, participants, self); + } + + @Override + protected void onPostExecute(String messageDetails) { + showDialog(context, messageDetails); + } + }.executeOnThreadPool(null, null, null); + } else { + String messageDetails = getMessageDetails(context, data, participants, self); + showDialog(context, messageDetails); + } + } + + private static String getMessageDetails(final Context context, + final ConversationMessageData data, + final ConversationParticipantsData participants, final ParticipantData self) { + String messageDetails = null; + if (data.getIsSms()) { + messageDetails = getSmsMessageDetails(data, participants, self); + } else { + // TODO: Handle SMS_TYPE_MMS_PUSH_NOTIFICATION type differently? + messageDetails = getMmsMessageDetails(context, data, participants, self); + } + + return messageDetails; + } + + private static void showDialog(final Context context, String messageDetails) { + if (!TextUtils.isEmpty(messageDetails)) { + new AlertDialog.Builder(context) + .setTitle(R.string.message_details_title) + .setMessage(messageDetails) + .setCancelable(true) + .show(); + } + } + + /** + * Return a string, separated by newlines, that contains a number of labels and values + * for this sms message. The string will be displayed in a modal dialog. + * @return string list of various message properties + */ + private static String getSmsMessageDetails(final ConversationMessageData data, + final ConversationParticipantsData participants, final ParticipantData self) { + final Resources res = Factory.get().getApplicationContext().getResources(); + final StringBuilder details = new StringBuilder(); + + // Type: Text message + details.append(res.getString(R.string.message_type_label)); + details.append(res.getString(R.string.text_message)); + + // From: +1425xxxxxxx + // or To: +1425xxxxxxx + final String rawSender = data.getSenderNormalizedDestination(); + if (!TextUtils.isEmpty(rawSender)) { + details.append('\n'); + details.append(res.getString(R.string.from_label)); + details.append(rawSender); + } + final String rawRecipients = getRecipientParticipantString(participants, + data.getParticipantId(), data.getIsIncoming(), data.getSelfParticipantId()); + if (!TextUtils.isEmpty(rawRecipients)) { + details.append('\n'); + details.append(res.getString(R.string.to_address_label)); + details.append(rawRecipients); + } + + // Sent: Mon 11:42AM + if (data.getIsIncoming()) { + if (data.getSentTimeStamp() != MmsUtils.INVALID_TIMESTAMP) { + details.append('\n'); + details.append(res.getString(R.string.sent_label)); + details.append( + Dates.getMessageDetailsTimeString(data.getSentTimeStamp()).toString()); + } + } + + // Sent: Mon 11:43AM + // or Received: Mon 11:43AM + appendSentOrReceivedTimestamp(res, details, data); + + appendSimInfo(res, self, details); + + if (DebugUtils.isDebugEnabled()) { + appendDebugInfo(details, data); + } + + return details.toString(); + } + + /** + * Return a string, separated by newlines, that contains a number of labels and values + * for this mms message. The string will be displayed in a modal dialog. + * @return string list of various message properties + */ + private static String getMmsMessageDetails(Context context, final ConversationMessageData data, + final ConversationParticipantsData participants, final ParticipantData self) { + final Resources res = Factory.get().getApplicationContext().getResources(); + // TODO: when we support non-auto-download of mms messages, we'll have to handle + // the case when the message is a PduHeaders.MESSAGE_TYPE_NOTIFICATION_IND and display + // something different. See the Messaging app's MessageUtils.getNotificationIndDetails() + + final StringBuilder details = new StringBuilder(); + + // Type: Multimedia message. + details.append(res.getString(R.string.message_type_label)); + details.append(res.getString(R.string.multimedia_message)); + + // From: +1425xxxxxxx + final String rawSender = data.getSenderNormalizedDestination(); + details.append('\n'); + details.append(res.getString(R.string.from_label)); + details.append(!TextUtils.isEmpty(rawSender) ? rawSender : + res.getString(R.string.hidden_sender_address)); + + // To: +1425xxxxxxx + final String rawRecipients = getRecipientParticipantString(participants, + data.getParticipantId(), data.getIsIncoming(), data.getSelfParticipantId()); + if (!TextUtils.isEmpty(rawRecipients)) { + details.append('\n'); + details.append(res.getString(R.string.to_address_label)); + details.append(rawRecipients); + } + + // Sent: Tue 3:05PM + // or Received: Tue 3:05PM + appendSentOrReceivedTimestamp(res, details, data); + + // Subject: You're awesome + details.append('\n'); + details.append(res.getString(R.string.subject_label)); + if (!TextUtils.isEmpty(MmsUtils.cleanseMmsSubject(res, data.getMmsSubject()))) { + details.append(data.getMmsSubject()); + } + + // Priority: High/Normal/Low + details.append('\n'); + details.append(res.getString(R.string.priority_label)); + details.append(getPriorityDescription(res, data.getSmsPriority())); + + // Message size: 30 KB + if (data.getSmsMessageSize() > 0) { + details.append('\n'); + details.append(res.getString(R.string.message_size_label)); + details.append(Formatter.formatFileSize(context, data.getSmsMessageSize())); + } + + appendSimInfo(res, self, details); + + if (DebugUtils.isDebugEnabled()) { + appendDebugInfo(details, data); + } + + return details.toString(); + } + + private static void appendSentOrReceivedTimestamp(Resources res, StringBuilder details, + ConversationMessageData data) { + int labelId = -1; + if (data.getIsIncoming()) { + labelId = R.string.received_label; + } else if (data.getIsSendComplete()) { + labelId = R.string.sent_label; + } + if (labelId >= 0) { + details.append('\n'); + details.append(res.getString(labelId)); + details.append( + Dates.getMessageDetailsTimeString(data.getReceivedTimeStamp()).toString()); + } + } + + @DoesNotRunOnMainThread + private static void appendDebugInfo(StringBuilder details, ConversationMessageData data) { + // We grab the thread id from the database, so this needs to run in the background + Assert.isNotMainThread(); + details.append("\n\n"); + details.append("DEBUG"); + + details.append('\n'); + details.append("Message id: "); + details.append(data.getMessageId()); + + final String telephonyUri = data.getSmsMessageUri(); + details.append('\n'); + details.append("Telephony uri: "); + details.append(telephonyUri); + + final String conversationId = data.getConversationId(); + + if (conversationId == null) { + return; + } + + details.append('\n'); + details.append("Conversation id: "); + details.append(conversationId); + + final long threadId = BugleDatabaseOperations.getThreadId(DataModel.get().getDatabase(), + conversationId); + + details.append('\n'); + details.append("Conversation telephony thread id: "); + details.append(threadId); + + MmsMessage mms = null; + + if (data.getIsMms()) { + if (telephonyUri == null) { + return; + } + mms = MmsUtils.loadMms(Uri.parse(telephonyUri)); + if (mms == null) { + return; + } + + // We log the thread id again to check that they are internally consistent + final long mmsThreadId = mms.mThreadId; + details.append('\n'); + details.append("Telephony thread id: "); + details.append(mmsThreadId); + + // Log the MMS content location + final String mmsContentLocation = mms.mContentLocation; + details.append('\n'); + details.append("Content location URL: "); + details.append(mmsContentLocation); + } + + final String recipientsString = MmsUtils.getRawRecipientIdsForThread(threadId); + if (recipientsString != null) { + details.append('\n'); + details.append("Thread recipient ids: "); + details.append(recipientsString); + } + + final List<String> recipients = MmsUtils.getRecipientsByThread(threadId); + if (recipients != null) { + details.append('\n'); + details.append("Thread recipients: "); + details.append(recipients.toString()); + + if (mms != null) { + final String from = MmsUtils.getMmsSender(recipients, mms.getUri()); + details.append('\n'); + details.append("Sender: "); + details.append(from); + } + } + } + + private static String getRecipientParticipantString( + final ConversationParticipantsData participants, final String senderId, + final boolean addSelf, final String selfId) { + final StringBuilder recipients = new StringBuilder(); + for (final ParticipantData participant : participants) { + if (TextUtils.equals(participant.getId(), senderId)) { + // Don't add sender + continue; + } + if (participant.isSelf() && + (!participant.getId().equals(selfId) || !addSelf)) { + // For self participants, don't add the one that's not relevant to this message + // or if we are asked not to add self + continue; + } + final String phoneNumber = participant.getNormalizedDestination(); + // Don't add empty number. This should not happen. But if that happens + // we should not add it. + if (!TextUtils.isEmpty(phoneNumber)) { + if (recipients.length() > 0) { + recipients.append(RECIPIENT_SEPARATOR); + } + recipients.append(phoneNumber); + } + } + return recipients.toString(); + } + + /** + * Convert the numeric mms priority into a human-readable string + * @param res + * @param priorityValue coded PduHeader priority + * @return string representation of the priority + */ + private static String getPriorityDescription(final Resources res, final int priorityValue) { + switch(priorityValue) { + case PduHeaders.PRIORITY_HIGH: + return res.getString(R.string.priority_high); + case PduHeaders.PRIORITY_LOW: + return res.getString(R.string.priority_low); + case PduHeaders.PRIORITY_NORMAL: + default: + return res.getString(R.string.priority_normal); + } + } + + private static void appendSimInfo(final Resources res, + final ParticipantData self, final StringBuilder outString) { + if (!OsUtil.isAtLeastL_MR1() + || self == null + || PhoneUtils.getDefault().getActiveSubscriptionCount() < 2) { + return; + } + // The appended SIM info would look like: + // SIM: SUB 01 + // or SIM: SIM 1 + // or SIM: Unknown + Assert.isTrue(self.isSelf()); + outString.append('\n'); + outString.append(res.getString(R.string.sim_label)); + if (self.isActiveSubscription() && !self.isDefaultSelf()) { + final String subscriptionName = self.getSubscriptionName(); + if (TextUtils.isEmpty(subscriptionName)) { + outString.append(res.getString(R.string.sim_slot_identifier, + self.getDisplaySlotId())); + } else { + outString.append(subscriptionName); + } + } + } +} diff --git a/src/com/android/messaging/ui/conversation/SimIconView.java b/src/com/android/messaging/ui/conversation/SimIconView.java new file mode 100644 index 0000000..e2e446c --- /dev/null +++ b/src/com/android/messaging/ui/conversation/SimIconView.java @@ -0,0 +1,51 @@ +/* + * 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.ui.conversation; + +import android.content.Context; +import android.graphics.Outline; +import android.net.Uri; +import android.util.AttributeSet; +import android.view.View; +import android.view.ViewOutlineProvider; + +import com.android.messaging.ui.ContactIconView; +import com.android.messaging.util.Assert; +import com.android.messaging.util.AvatarUriUtil; +import com.android.messaging.util.OsUtil; + +/** + * Shows SIM avatar icon in the SIM switcher / Self-send button. + */ +public class SimIconView extends ContactIconView { + public SimIconView(Context context, AttributeSet attrs) { + super(context, attrs); + if (OsUtil.isAtLeastL()) { + setOutlineProvider(new ViewOutlineProvider() { + @Override + public void getOutline(View v, Outline outline) { + outline.setOval(0, 0, v.getWidth(), v.getHeight()); + } + }); + } + } + + @Override + protected void maybeInitializeOnClickListener() { + // TODO: SIM icon view shouldn't consume or handle clicks, but it should if + // this is the send button for the only SIM in the device or if MSIM is not supported. + } +} diff --git a/src/com/android/messaging/ui/conversation/SimSelectorItemView.java b/src/com/android/messaging/ui/conversation/SimSelectorItemView.java new file mode 100644 index 0000000..3058d31 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/SimSelectorItemView.java @@ -0,0 +1,90 @@ +/* + * 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.ui.conversation; + +import android.content.Context; +import android.text.TextUtils; +import android.util.AttributeSet; +import android.view.View; +import android.widget.LinearLayout; +import android.widget.TextView; + +import com.android.messaging.R; +import com.android.messaging.datamodel.data.SubscriptionListData.SubscriptionListEntry; +import com.android.messaging.util.Assert; + +/** + * Shows a view for a SIM in the SIM selector. + */ +public class SimSelectorItemView extends LinearLayout { + public interface HostInterface { + void onSimItemClicked(SubscriptionListEntry item); + } + + private SubscriptionListEntry mData; + private TextView mNameTextView; + private TextView mDetailsTextView; + private SimIconView mSimIconView; + private HostInterface mHost; + + public SimSelectorItemView(final Context context, final AttributeSet attrs) { + super(context, attrs); + } + + @Override + protected void onFinishInflate() { + mNameTextView = (TextView) findViewById(R.id.name); + mDetailsTextView = (TextView) findViewById(R.id.details); + mSimIconView = (SimIconView) findViewById(R.id.sim_icon); + setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + mHost.onSimItemClicked(mData); + } + }); + } + + public void bind(final SubscriptionListEntry simEntry) { + Assert.notNull(simEntry); + mData = simEntry; + updateViewAppearance(); + } + + public void setHostInterface(final HostInterface host) { + mHost = host; + } + + private void updateViewAppearance() { + Assert.notNull(mData); + final String displayName = mData.displayName; + if (TextUtils.isEmpty(displayName)) { + mNameTextView.setVisibility(GONE); + } else { + mNameTextView.setVisibility(VISIBLE); + mNameTextView.setText(displayName); + } + + final String details = mData.displayDestination; + if (TextUtils.isEmpty(details)) { + mDetailsTextView.setVisibility(GONE); + } else { + mDetailsTextView.setVisibility(VISIBLE); + mDetailsTextView.setText(details); + } + + mSimIconView.setImageResourceUri(mData.iconUri); + } +} diff --git a/src/com/android/messaging/ui/conversation/SimSelectorView.java b/src/com/android/messaging/ui/conversation/SimSelectorView.java new file mode 100644 index 0000000..b07ff19 --- /dev/null +++ b/src/com/android/messaging/ui/conversation/SimSelectorView.java @@ -0,0 +1,169 @@ +/* + * 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.ui.conversation; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.animation.Animation; +import android.view.animation.TranslateAnimation; +import android.widget.ArrayAdapter; +import android.widget.FrameLayout; +import android.widget.ListView; + +import com.android.messaging.R; +import com.android.messaging.datamodel.data.SubscriptionListData; +import com.android.messaging.datamodel.data.SubscriptionListData.SubscriptionListEntry; +import com.android.messaging.util.UiUtils; + +import java.util.ArrayList; +import java.util.List; + +/** + * Displays a SIM selector above the compose message view and overlays the message list. + */ +public class SimSelectorView extends FrameLayout implements SimSelectorItemView.HostInterface { + public interface SimSelectorViewListener { + void onSimItemClicked(SubscriptionListEntry item); + void onSimSelectorVisibilityChanged(boolean visible); + } + + private ListView mSimListView; + private final SimSelectorAdapter mAdapter; + private boolean mShow; + private SimSelectorViewListener mListener; + private int mItemLayoutId; + + public SimSelectorView(Context context, AttributeSet attrs) { + super(context, attrs); + mAdapter = new SimSelectorAdapter(getContext()); + } + + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + mSimListView = (ListView) findViewById(R.id.sim_list); + mSimListView.setAdapter(mAdapter); + + // Clicking anywhere outside the switcher list should dismiss. + setOnClickListener(new OnClickListener() { + @Override + public void onClick(View v) { + showOrHide(false, true); + } + }); + } + + public void bind(final SubscriptionListData data) { + mAdapter.bindData(data.getActiveSubscriptionEntriesExcludingDefault()); + } + + public void setItemLayoutId(final int layoutId) { + mItemLayoutId = layoutId; + } + + public void setListener(final SimSelectorViewListener listener) { + mListener = listener; + } + + public void toggleVisibility() { + showOrHide(!mShow, true); + } + + public void showOrHide(final boolean show, final boolean animate) { + final boolean oldShow = mShow; + mShow = show && mAdapter.getCount() > 1; + if (oldShow != mShow) { + if (mListener != null) { + mListener.onSimSelectorVisibilityChanged(mShow); + } + + if (animate) { + // Fade in the background pane. + setVisibility(VISIBLE); + setAlpha(mShow ? 0.0f : 1.0f); + animate().alpha(mShow ? 1.0f : 0.0f) + .setDuration(UiUtils.REVEAL_ANIMATION_DURATION) + .withEndAction(new Runnable() { + @Override + public void run() { + setAlpha(1.0f); + setVisibility(mShow ? VISIBLE : GONE); + } + }); + } else { + setVisibility(mShow ? VISIBLE : GONE); + } + + // Slide in the SIM selector list via a translate animation. + mSimListView.setVisibility(mShow ? VISIBLE : GONE); + if (animate) { + mSimListView.clearAnimation(); + final TranslateAnimation translateAnimation = new TranslateAnimation( + Animation.RELATIVE_TO_SELF, 0, Animation.RELATIVE_TO_SELF, 0, + Animation.RELATIVE_TO_SELF, mShow ? 1.0f : 0.0f, + Animation.RELATIVE_TO_SELF, mShow ? 0.0f : 1.0f); + translateAnimation.setInterpolator(UiUtils.EASE_OUT_INTERPOLATOR); + translateAnimation.setDuration(UiUtils.REVEAL_ANIMATION_DURATION); + mSimListView.startAnimation(translateAnimation); + } + } + } + + /** + * An adapter that takes a list of SubscriptionListEntry and displays them as a list of + * available SIMs in the SIM selector. + */ + private class SimSelectorAdapter extends ArrayAdapter<SubscriptionListEntry> { + public SimSelectorAdapter(final Context context) { + super(context, R.layout.sim_selector_item_view, new ArrayList<SubscriptionListEntry>()); + } + + public void bindData(final List<SubscriptionListEntry> newList) { + clear(); + addAll(newList); + notifyDataSetChanged(); + } + + @Override + public View getView(final int position, final View convertView, final ViewGroup parent) { + SimSelectorItemView itemView; + if (convertView != null && convertView instanceof SimSelectorItemView) { + itemView = (SimSelectorItemView) convertView; + } else { + final LayoutInflater inflater = (LayoutInflater) getContext() + .getSystemService(Context.LAYOUT_INFLATER_SERVICE); + itemView = (SimSelectorItemView) inflater.inflate(mItemLayoutId, + parent, false); + itemView.setHostInterface(SimSelectorView.this); + } + itemView.bind(getItem(position)); + return itemView; + } + } + + @Override + public void onSimItemClicked(SubscriptionListEntry item) { + mListener.onSimItemClicked(item); + showOrHide(false, true); + } + + public boolean isOpen() { + return mShow; + } +} diff --git a/src/com/android/messaging/ui/conversationlist/AbstractConversationListActivity.java b/src/com/android/messaging/ui/conversationlist/AbstractConversationListActivity.java new file mode 100644 index 0000000..dbbbb15 --- /dev/null +++ b/src/com/android/messaging/ui/conversationlist/AbstractConversationListActivity.java @@ -0,0 +1,339 @@ +/* + * 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.ui.conversationlist; + +import android.app.Activity; +import android.app.AlertDialog; +import android.app.Fragment; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.content.res.Resources; +import android.net.Uri; +import android.os.Bundle; +import android.support.annotation.NonNull; +import android.view.View; + +import com.android.messaging.R; +import com.android.messaging.datamodel.action.DeleteConversationAction; +import com.android.messaging.datamodel.action.UpdateConversationArchiveStatusAction; +import com.android.messaging.datamodel.action.UpdateConversationOptionsAction; +import com.android.messaging.datamodel.action.UpdateDestinationBlockedAction; +import com.android.messaging.datamodel.data.ConversationListData; +import com.android.messaging.datamodel.data.ConversationListItemData; +import com.android.messaging.ui.BugleActionBarActivity; +import com.android.messaging.ui.SnackBar; +import com.android.messaging.ui.SnackBarInteraction; +import com.android.messaging.ui.UIIntents; +import com.android.messaging.ui.contact.AddContactsConfirmationDialog; +import com.android.messaging.ui.conversationlist.ConversationListFragment.ConversationListFragmentHost; +import com.android.messaging.ui.conversationlist.MultiSelectActionModeCallback.SelectedConversation; +import com.android.messaging.util.BugleGservices; +import com.android.messaging.util.BugleGservicesKeys; +import com.android.messaging.util.DebugUtils; +import com.android.messaging.util.PhoneUtils; +import com.android.messaging.util.Trace; +import com.android.messaging.util.UiUtils; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +import javax.annotation.Nullable; + +/** + * Base class for many Conversation List activities. This will handle the common actions of multi + * select and common launching of intents. + */ +public abstract class AbstractConversationListActivity extends BugleActionBarActivity + implements ConversationListFragmentHost, MultiSelectActionModeCallback.Listener { + + private static final int REQUEST_SET_DEFAULT_SMS_APP = 1; + + protected ConversationListFragment mConversationListFragment; + + @Override + public void onAttachFragment(final Fragment fragment) { + Trace.beginSection("AbstractConversationListActivity.onAttachFragment"); + // Fragment could be debug dialog + if (fragment instanceof ConversationListFragment) { + mConversationListFragment = (ConversationListFragment) fragment; + mConversationListFragment.setHost(this); + } + Trace.endSection(); + } + + @Override + public void onBackPressed() { + // If action mode is active dismiss it + if (getActionMode() != null) { + dismissActionMode(); + return; + } + super.onBackPressed(); + } + + protected void startMultiSelectActionMode() { + startActionMode(new MultiSelectActionModeCallback(this)); + } + + protected void exitMultiSelectState() { + mConversationListFragment.showFab(); + dismissActionMode(); + mConversationListFragment.updateUi(); + } + + protected boolean isInConversationListSelectMode() { + return getActionModeCallback() instanceof MultiSelectActionModeCallback; + } + + @Override + public boolean isSelectionMode() { + return isInConversationListSelectMode(); + } + + @Override + public void onActivityResult(final int requestCode, final int resultCode, final Intent data) { + } + + @Override + public void onActionBarDelete(final Collection<SelectedConversation> conversations) { + if (!PhoneUtils.getDefault().isDefaultSmsApp()) { + // TODO: figure out a good way to combine this with the implementation in + // ConversationFragment doing similar things + final Activity activity = this; + UiUtils.showSnackBarWithCustomAction(this, + getWindow().getDecorView().getRootView(), + getString(R.string.requires_default_sms_app), + SnackBar.Action.createCustomAction(new Runnable() { + @Override + public void run() { + final Intent intent = + UIIntents.get().getChangeDefaultSmsAppIntent(activity); + startActivityForResult(intent, REQUEST_SET_DEFAULT_SMS_APP); + } + }, + getString(R.string.requires_default_sms_change_button)), + null /* interactions */, + null /* placement */); + return; + } + + new AlertDialog.Builder(this) + .setTitle(getResources().getQuantityString( + R.plurals.delete_conversations_confirmation_dialog_title, + conversations.size())) + .setPositiveButton(R.string.delete_conversation_confirmation_button, + new DialogInterface.OnClickListener() { + @Override + public void onClick(final DialogInterface dialog, + final int button) { + for (final SelectedConversation conversation : conversations) { + DeleteConversationAction.deleteConversation( + conversation.conversationId, + conversation.timestamp); + } + exitMultiSelectState(); + } + }) + .setNegativeButton(R.string.delete_conversation_decline_button, null) + .show(); + } + + @Override + public void onActionBarArchive(final Iterable<SelectedConversation> conversations, + final boolean isToArchive) { + final ArrayList<String> conversationIds = new ArrayList<String>(); + for (final SelectedConversation conversation : conversations) { + final String conversationId = conversation.conversationId; + conversationIds.add(conversationId); + if (isToArchive) { + UpdateConversationArchiveStatusAction.archiveConversation(conversationId); + } else { + UpdateConversationArchiveStatusAction.unarchiveConversation(conversationId); + } + } + + final Runnable undoRunnable = new Runnable() { + @Override + public void run() { + for (final String conversationId : conversationIds) { + if (isToArchive) { + UpdateConversationArchiveStatusAction.unarchiveConversation(conversationId); + } else { + UpdateConversationArchiveStatusAction.archiveConversation(conversationId); + } + } + } + }; + + final int textId = + isToArchive ? R.string.archived_toast_message : R.string.unarchived_toast_message; + final String message = getResources().getString(textId, conversationIds.size()); + UiUtils.showSnackBar(this, findViewById(android.R.id.list), message, undoRunnable, + SnackBar.Action.SNACK_BAR_UNDO, + mConversationListFragment.getSnackBarInteractions()); + exitMultiSelectState(); + } + + @Override + public void onActionBarNotification(final Iterable<SelectedConversation> conversations, + final boolean isNotificationOn) { + for (final SelectedConversation conversation : conversations) { + UpdateConversationOptionsAction.enableConversationNotifications( + conversation.conversationId, isNotificationOn); + } + + final int textId = isNotificationOn ? + R.string.notification_on_toast_message : R.string.notification_off_toast_message; + final String message = getResources().getString(textId, 1); + UiUtils.showSnackBar(this, findViewById(android.R.id.list), message, + null /* undoRunnable */, + SnackBar.Action.SNACK_BAR_UNDO, mConversationListFragment.getSnackBarInteractions()); + exitMultiSelectState(); + } + + @Override + public void onActionBarAddContact(final SelectedConversation conversation) { + final Uri avatarUri; + if (conversation.icon != null) { + avatarUri = Uri.parse(conversation.icon); + } else { + avatarUri = null; + } + final AddContactsConfirmationDialog dialog = new AddContactsConfirmationDialog( + this, avatarUri, conversation.otherParticipantNormalizedDestination); + dialog.show(); + exitMultiSelectState(); + } + + @Override + public void onActionBarBlock(final SelectedConversation conversation) { + final Resources res = getResources(); + new AlertDialog.Builder(this) + .setTitle(res.getString(R.string.block_confirmation_title, + conversation.otherParticipantNormalizedDestination)) + .setMessage(res.getString(R.string.block_confirmation_message)) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() { + @Override + public void onClick(final DialogInterface arg0, final int arg1) { + final Context context = AbstractConversationListActivity.this; + final View listView = findViewById(android.R.id.list); + final List<SnackBarInteraction> interactions = + mConversationListFragment.getSnackBarInteractions(); + final UpdateDestinationBlockedAction.UpdateDestinationBlockedActionListener + undoListener = + new UpdateDestinationBlockedActionSnackBar( + context, listView, null /* undoRunnable */, + interactions); + final Runnable undoRunnable = new Runnable() { + @Override + public void run() { + UpdateDestinationBlockedAction.updateDestinationBlocked( + conversation.otherParticipantNormalizedDestination, false, + conversation.conversationId, + undoListener); + } + }; + final UpdateDestinationBlockedAction.UpdateDestinationBlockedActionListener + listener = new UpdateDestinationBlockedActionSnackBar( + context, listView, undoRunnable, interactions); + UpdateDestinationBlockedAction.updateDestinationBlocked( + conversation.otherParticipantNormalizedDestination, true, + conversation.conversationId, + listener); + exitMultiSelectState(); + } + }) + .create() + .show(); + } + + @Override + public void onConversationClick(final ConversationListData listData, + final ConversationListItemData conversationListItemData, + final boolean isLongClick, + final ConversationListItemView conversationView) { + if (isLongClick && !isInConversationListSelectMode()) { + startMultiSelectActionMode(); + } + + if (isInConversationListSelectMode()) { + final MultiSelectActionModeCallback multiSelectActionMode = + (MultiSelectActionModeCallback) getActionModeCallback(); + multiSelectActionMode.toggleSelect(listData, conversationListItemData); + mConversationListFragment.updateUi(); + } else { + final String conversationId = conversationListItemData.getConversationId(); + Bundle sceneTransitionAnimationOptions = null; + boolean hasCustomTransitions = false; + + UIIntents.get().launchConversationActivity( + this, conversationId, null, + sceneTransitionAnimationOptions, + hasCustomTransitions); + } + } + + @Override + public void onCreateConversationClick() { + UIIntents.get().launchCreateNewConversationActivity(this, null); + } + + + @Override + public boolean isConversationSelected(final String conversationId) { + return isInConversationListSelectMode() && + ((MultiSelectActionModeCallback) getActionModeCallback()).isSelected( + conversationId); + } + + public void onActionBarDebug() { + DebugUtils.showDebugOptions(this); + } + + private static class UpdateDestinationBlockedActionSnackBar + implements UpdateDestinationBlockedAction.UpdateDestinationBlockedActionListener { + private final Context mContext; + private final View mParentView; + private final Runnable mUndoRunnable; + private final List<SnackBarInteraction> mInteractions; + + UpdateDestinationBlockedActionSnackBar(final Context context, + @NonNull final View parentView, @Nullable final Runnable undoRunnable, + @Nullable List<SnackBarInteraction> interactions) { + mContext = context; + mParentView = parentView; + mUndoRunnable = undoRunnable; + mInteractions = interactions; + } + + @Override + public void onUpdateDestinationBlockedAction( + final UpdateDestinationBlockedAction action, + final boolean success, final boolean block, + final String destination) { + if (success) { + final int messageId = block ? R.string.blocked_toast_message + : R.string.unblocked_toast_message; + final String message = mContext.getResources().getString(messageId, 1); + UiUtils.showSnackBar(mContext, mParentView, message, mUndoRunnable, + SnackBar.Action.SNACK_BAR_UNDO, mInteractions); + } + } + } +} diff --git a/src/com/android/messaging/ui/conversationlist/ArchivedConversationListActivity.java b/src/com/android/messaging/ui/conversationlist/ArchivedConversationListActivity.java new file mode 100644 index 0000000..366c7d3 --- /dev/null +++ b/src/com/android/messaging/ui/conversationlist/ArchivedConversationListActivity.java @@ -0,0 +1,96 @@ +/* + * 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.ui.conversationlist; + +import android.graphics.drawable.ColorDrawable; +import android.os.Bundle; +import android.support.v7.app.ActionBar; +import android.view.Menu; +import android.view.MenuItem; + +import com.android.messaging.R; +import com.android.messaging.util.DebugUtils; + +public class ArchivedConversationListActivity extends AbstractConversationListActivity { + + @Override + protected void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + final ConversationListFragment fragment = + ConversationListFragment.createArchivedConversationListFragment(); + getFragmentManager().beginTransaction().add(android.R.id.content, fragment).commit(); + invalidateActionBar(); + } + + protected void updateActionBar(ActionBar actionBar) { + actionBar.setTitle(getString(R.string.archived_activity_title)); + actionBar.setDisplayShowTitleEnabled(true); + actionBar.setDisplayHomeAsUpEnabled(true); + actionBar.setBackgroundDrawable(new ColorDrawable( + getResources().getColor( + R.color.archived_conversation_action_bar_background_color_dark))); + actionBar.show(); + super.updateActionBar(actionBar); + } + + @Override + public void onBackPressed() { + if (isInConversationListSelectMode()) { + exitMultiSelectState(); + } else { + super.onBackPressed(); + } + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + if (super.onCreateOptionsMenu(menu)) { + return true; + } + getMenuInflater().inflate(R.menu.archived_conversation_list_menu, menu); + final MenuItem item = menu.findItem(R.id.action_debug_options); + if (item != null) { + final boolean enableDebugItems = DebugUtils.isDebugEnabled(); + item.setVisible(enableDebugItems).setEnabled(enableDebugItems); + } + return true; + } + + @Override + public boolean onOptionsItemSelected(MenuItem menuItem) { + switch(menuItem.getItemId()) { + case R.id.action_debug_options: + onActionBarDebug(); + return true; + case android.R.id.home: + onActionBarHome(); + return true; + default: + return super.onOptionsItemSelected(menuItem); + } + } + + @Override + public void onActionBarHome() { + onBackPressed(); + } + + @Override + public boolean isSwipeAnimatable() { + return false; + } +} diff --git a/src/com/android/messaging/ui/conversationlist/ConversationListActivity.java b/src/com/android/messaging/ui/conversationlist/ConversationListActivity.java new file mode 100644 index 0000000..f8abe81 --- /dev/null +++ b/src/com/android/messaging/ui/conversationlist/ConversationListActivity.java @@ -0,0 +1,144 @@ +/* + * 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.ui.conversationlist; + +import android.graphics.drawable.ColorDrawable; +import android.os.Bundle; +import android.support.v7.app.ActionBar; +import android.view.Menu; +import android.view.MenuItem; + +import com.android.messaging.R; +import com.android.messaging.ui.UIIntents; +import com.android.messaging.util.DebugUtils; +import com.android.messaging.util.Trace; + +public class ConversationListActivity extends AbstractConversationListActivity { + @Override + protected void onCreate(final Bundle savedInstanceState) { + Trace.beginSection("ConversationListActivity.onCreate"); + super.onCreate(savedInstanceState); + setContentView(R.layout.conversation_list_activity); + Trace.endSection(); + invalidateActionBar(); + } + + @Override + protected void updateActionBar(final ActionBar actionBar) { + actionBar.setTitle(getString(R.string.app_name)); + actionBar.setDisplayShowTitleEnabled(true); + actionBar.setDisplayHomeAsUpEnabled(false); + actionBar.setNavigationMode(ActionBar.NAVIGATION_MODE_STANDARD); + actionBar.setBackgroundDrawable(new ColorDrawable( + getResources().getColor(R.color.action_bar_background_color))); + actionBar.show(); + super.updateActionBar(actionBar); + } + + @Override + public void onResume() { + super.onResume(); + // Invalidate the menu as items that are based on settings may have changed + // while not in the app (e.g. Talkback enabled/disable affects new conversation + // button) + supportInvalidateOptionsMenu(); + } + + @Override + public void onBackPressed() { + if (isInConversationListSelectMode()) { + exitMultiSelectState(); + } else { + super.onBackPressed(); + } + } + + @Override + public boolean onCreateOptionsMenu(final Menu menu) { + if (super.onCreateOptionsMenu(menu)) { + return true; + } + getMenuInflater().inflate(R.menu.conversation_list_fragment_menu, menu); + final MenuItem item = menu.findItem(R.id.action_debug_options); + if (item != null) { + final boolean enableDebugItems = DebugUtils.isDebugEnabled(); + item.setVisible(enableDebugItems).setEnabled(enableDebugItems); + } + return true; + } + + @Override + public boolean onOptionsItemSelected(final MenuItem menuItem) { + switch(menuItem.getItemId()) { + case R.id.action_start_new_conversation: + onActionBarStartNewConversation(); + return true; + case R.id.action_settings: + onActionBarSettings(); + return true; + case R.id.action_debug_options: + onActionBarDebug(); + return true; + case R.id.action_show_archived: + onActionBarArchived(); + return true; + case R.id.action_show_blocked_contacts: + onActionBarBlockedParticipants(); + return true; + } + return super.onOptionsItemSelected(menuItem); + } + + @Override + public void onActionBarHome() { + exitMultiSelectState(); + } + + public void onActionBarStartNewConversation() { + UIIntents.get().launchCreateNewConversationActivity(this, null); + } + + public void onActionBarSettings() { + UIIntents.get().launchSettingsActivity(this); + } + + public void onActionBarBlockedParticipants() { + UIIntents.get().launchBlockedParticipantsActivity(this); + } + + public void onActionBarArchived() { + UIIntents.get().launchArchivedConversationsActivity(this); + } + + @Override + public boolean isSwipeAnimatable() { + return !isInConversationListSelectMode(); + } + + @Override + public void onWindowFocusChanged(final boolean hasFocus) { + super.onWindowFocusChanged(hasFocus); + final ConversationListFragment conversationListFragment = + (ConversationListFragment) getFragmentManager().findFragmentById( + R.id.conversation_list_fragment); + // When the screen is turned on, the last used activity gets resumed, but it gets + // window focus only after the lock screen is unlocked. + if (hasFocus && conversationListFragment != null) { + conversationListFragment.setScrolledToNewestConversationIfNeeded(); + } + } +} diff --git a/src/com/android/messaging/ui/conversationlist/ConversationListAdapter.java b/src/com/android/messaging/ui/conversationlist/ConversationListAdapter.java new file mode 100644 index 0000000..629c4ae --- /dev/null +++ b/src/com/android/messaging/ui/conversationlist/ConversationListAdapter.java @@ -0,0 +1,77 @@ +/* + * 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.ui.conversationlist; + +import android.content.Context; +import android.database.Cursor; +import android.support.v7.widget.RecyclerView; +import android.view.LayoutInflater; +import android.view.ViewGroup; + +import com.android.messaging.R; +import com.android.messaging.ui.CursorRecyclerAdapter; +import com.android.messaging.ui.conversationlist.ConversationListItemView.HostInterface; + +/** + * Provides an interface to expose Conversation List Cursor data to a UI widget like a ListView. + */ +public class ConversationListAdapter + extends CursorRecyclerAdapter<ConversationListAdapter.ConversationListViewHolder> { + + private final ConversationListItemView.HostInterface mClivHostInterface; + + public ConversationListAdapter(final Context context, final Cursor cursor, + final ConversationListItemView.HostInterface clivHostInterface) { + super(context, cursor, 0); + mClivHostInterface = clivHostInterface; + setHasStableIds(true); + } + + /** + * @see com.android.messaging.ui.CursorRecyclerAdapter#bindViewHolder( + * android.support.v7.widget.RecyclerView.ViewHolder, android.content.Context, + * android.database.Cursor) + */ + @Override + public void bindViewHolder(final ConversationListViewHolder holder, final Context context, + final Cursor cursor) { + final ConversationListItemView conversationListItemView = holder.mView; + conversationListItemView.bind(cursor, mClivHostInterface); + } + + @Override + public ConversationListViewHolder createViewHolder(final Context context, + final ViewGroup parent, final int viewType) { + final LayoutInflater layoutInflater = LayoutInflater.from(context); + final ConversationListItemView itemView = + (ConversationListItemView) layoutInflater.inflate( + R.layout.conversation_list_item_view, null); + return new ConversationListViewHolder(itemView); + } + + /** + * ViewHolder that holds a ConversationListItemView. + */ + public static class ConversationListViewHolder extends RecyclerView.ViewHolder { + final ConversationListItemView mView; + + public ConversationListViewHolder(final ConversationListItemView itemView) { + super(itemView); + mView = itemView; + } + } +} diff --git a/src/com/android/messaging/ui/conversationlist/ConversationListFragment.java b/src/com/android/messaging/ui/conversationlist/ConversationListFragment.java new file mode 100644 index 0000000..2f868d4 --- /dev/null +++ b/src/com/android/messaging/ui/conversationlist/ConversationListFragment.java @@ -0,0 +1,446 @@ +/* + * 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.ui.conversationlist; + +import android.app.Activity; +import android.app.Fragment; +import android.content.Context; +import android.database.Cursor; +import android.graphics.Rect; +import android.net.Uri; +import android.os.Bundle; +import android.os.Parcelable; +import android.support.v4.view.ViewCompat; +import android.support.v4.view.ViewGroupCompat; +import android.support.v7.widget.LinearLayoutManager; +import android.support.v7.widget.RecyclerView; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.View.OnClickListener; +import android.view.ViewGroup; +import android.view.ViewGroup.MarginLayoutParams; +import android.view.ViewPropertyAnimator; +import android.view.accessibility.AccessibilityManager; +import android.widget.AbsListView; +import android.widget.ImageView; + +import com.android.messaging.R; +import com.android.messaging.annotation.VisibleForAnimation; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.binding.Binding; +import com.android.messaging.datamodel.binding.BindingBase; +import com.android.messaging.datamodel.data.ConversationListData; +import com.android.messaging.datamodel.data.ConversationListData.ConversationListDataListener; +import com.android.messaging.datamodel.data.ConversationListItemData; +import com.android.messaging.ui.BugleAnimationTags; +import com.android.messaging.ui.ListEmptyView; +import com.android.messaging.ui.SnackBarInteraction; +import com.android.messaging.ui.UIIntents; +import com.android.messaging.util.AccessibilityUtil; +import com.android.messaging.util.Assert; +import com.android.messaging.util.ImeUtil; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.UiUtils; +import com.google.common.annotations.VisibleForTesting; + +import java.util.ArrayList; +import java.util.List; + +/** + * Shows a list of conversations. + */ +public class ConversationListFragment extends Fragment implements ConversationListDataListener, + ConversationListItemView.HostInterface { + private static final String BUNDLE_ARCHIVED_MODE = "archived_mode"; + private static final String BUNDLE_FORWARD_MESSAGE_MODE = "forward_message_mode"; + private static final boolean VERBOSE = false; + + private MenuItem mShowBlockedMenuItem; + private boolean mArchiveMode; + private boolean mBlockedAvailable; + private boolean mForwardMessageMode; + + public interface ConversationListFragmentHost { + public void onConversationClick(final ConversationListData listData, + final ConversationListItemData conversationListItemData, + final boolean isLongClick, + final ConversationListItemView conversationView); + public void onCreateConversationClick(); + public boolean isConversationSelected(final String conversationId); + public boolean isSwipeAnimatable(); + public boolean isSelectionMode(); + public boolean hasWindowFocus(); + } + + private ConversationListFragmentHost mHost; + private RecyclerView mRecyclerView; + private ImageView mStartNewConversationButton; + private ListEmptyView mEmptyListMessageView; + private ConversationListAdapter mAdapter; + + // Saved Instance State Data - only for temporal data which is nice to maintain but not + // critical for correctness. + private static final String SAVED_INSTANCE_STATE_LIST_VIEW_STATE_KEY = + "conversationListViewState"; + private Parcelable mListState; + + @VisibleForTesting + final Binding<ConversationListData> mListBinding = BindingBase.createBinding(this); + + public static ConversationListFragment createArchivedConversationListFragment() { + return createConversationListFragment(BUNDLE_ARCHIVED_MODE); + } + + public static ConversationListFragment createForwardMessageConversationListFragment() { + return createConversationListFragment(BUNDLE_FORWARD_MESSAGE_MODE); + } + + public static ConversationListFragment createConversationListFragment(String modeKeyName) { + final ConversationListFragment fragment = new ConversationListFragment(); + final Bundle bundle = new Bundle(); + bundle.putBoolean(modeKeyName, true); + fragment.setArguments(bundle); + return fragment; + } + + /** + * {@inheritDoc} from Fragment + */ + @Override + public void onCreate(final Bundle bundle) { + super.onCreate(bundle); + mListBinding.getData().init(getLoaderManager(), mListBinding); + mAdapter = new ConversationListAdapter(getActivity(), null, this); + } + + @Override + public void onResume() { + super.onResume(); + + Assert.notNull(mHost); + setScrolledToNewestConversationIfNeeded(); + + updateUi(); + } + + public void setScrolledToNewestConversationIfNeeded() { + if (!mArchiveMode + && !mForwardMessageMode + && isScrolledToFirstConversation() + && mHost.hasWindowFocus()) { + mListBinding.getData().setScrolledToNewestConversation(true); + } + } + + private boolean isScrolledToFirstConversation() { + int firstItemPosition = ((LinearLayoutManager) mRecyclerView.getLayoutManager()) + .findFirstCompletelyVisibleItemPosition(); + return firstItemPosition == 0; + } + + /** + * {@inheritDoc} from Fragment + */ + @Override + public void onDestroy() { + super.onDestroy(); + mListBinding.unbind(); + mHost = null; + } + + /** + * {@inheritDoc} from Fragment + */ + @Override + public View onCreateView(final LayoutInflater inflater, final ViewGroup container, + final Bundle savedInstanceState) { + final ViewGroup rootView = (ViewGroup) inflater.inflate(R.layout.conversation_list_fragment, + container, false); + mRecyclerView = (RecyclerView) rootView.findViewById(android.R.id.list); + mEmptyListMessageView = (ListEmptyView) rootView.findViewById(R.id.no_conversations_view); + mEmptyListMessageView.setImageHint(R.drawable.ic_oobe_conv_list); + // The default behavior for default layout param generation by LinearLayoutManager is to + // provide width and height of WRAP_CONTENT, but this is not desirable for + // ConversationListFragment; the view in each row should be a width of MATCH_PARENT so that + // the entire row is tappable. + final Activity activity = getActivity(); + final LinearLayoutManager manager = new LinearLayoutManager(activity) { + @Override + public RecyclerView.LayoutParams generateDefaultLayoutParams() { + return new RecyclerView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT); + } + }; + mRecyclerView.setLayoutManager(manager); + mRecyclerView.setHasFixedSize(true); + mRecyclerView.setAdapter(mAdapter); + mRecyclerView.setOnScrollListener(new RecyclerView.OnScrollListener() { + int mCurrentState = AbsListView.OnScrollListener.SCROLL_STATE_IDLE; + + @Override + public void onScrolled(final RecyclerView recyclerView, final int dx, final int dy) { + if (mCurrentState == AbsListView.OnScrollListener.SCROLL_STATE_TOUCH_SCROLL + || mCurrentState == AbsListView.OnScrollListener.SCROLL_STATE_FLING) { + ImeUtil.get().hideImeKeyboard(getActivity(), mRecyclerView); + } + + if (isScrolledToFirstConversation()) { + setScrolledToNewestConversationIfNeeded(); + } else { + mListBinding.getData().setScrolledToNewestConversation(false); + } + } + + @Override + public void onScrollStateChanged(final RecyclerView recyclerView, final int newState) { + mCurrentState = newState; + } + }); + mRecyclerView.addOnItemTouchListener(new ConversationListSwipeHelper(mRecyclerView)); + + if (savedInstanceState != null) { + mListState = savedInstanceState.getParcelable(SAVED_INSTANCE_STATE_LIST_VIEW_STATE_KEY); + } + + mStartNewConversationButton = (ImageView) rootView.findViewById( + R.id.start_new_conversation_button); + if (mArchiveMode) { + mStartNewConversationButton.setVisibility(View.GONE); + } else { + mStartNewConversationButton.setVisibility(View.VISIBLE); + mStartNewConversationButton.setOnClickListener(new OnClickListener() { + @Override + public void onClick(final View clickView) { + mHost.onCreateConversationClick(); + } + }); + } + ViewCompat.setTransitionName(mStartNewConversationButton, BugleAnimationTags.TAG_FABICON); + + // The root view has a non-null background, which by default is deemed by the framework + // to be a "transition group," where all child views are animated together during an + // activity transition. However, we want each individual items in the recycler view to + // show explode animation themselves, so we explicitly tag the root view to be a non-group. + ViewGroupCompat.setTransitionGroup(rootView, false); + + setHasOptionsMenu(true); + return rootView; + } + + @Override + public void onAttach(final Activity activity) { + super.onAttach(activity); + if (VERBOSE) { + LogUtil.v(LogUtil.BUGLE_TAG, "Attaching List"); + } + final Bundle arguments = getArguments(); + if (arguments != null) { + mArchiveMode = arguments.getBoolean(BUNDLE_ARCHIVED_MODE, false); + mForwardMessageMode = arguments.getBoolean(BUNDLE_FORWARD_MESSAGE_MODE, false); + } + mListBinding.bind(DataModel.get().createConversationListData(activity, this, mArchiveMode)); + } + + + @Override + public void onSaveInstanceState(final Bundle outState) { + super.onSaveInstanceState(outState); + if (mListState != null) { + outState.putParcelable(SAVED_INSTANCE_STATE_LIST_VIEW_STATE_KEY, mListState); + } + } + + @Override + public void onPause() { + super.onPause(); + mListState = mRecyclerView.getLayoutManager().onSaveInstanceState(); + mListBinding.getData().setScrolledToNewestConversation(false); + } + + /** + * Call this immediately after attaching the fragment + */ + public void setHost(final ConversationListFragmentHost host) { + Assert.isNull(mHost); + mHost = host; + } + + @Override + public void onConversationListCursorUpdated(final ConversationListData data, + final Cursor cursor) { + mListBinding.ensureBound(data); + final Cursor oldCursor = mAdapter.swapCursor(cursor); + updateEmptyListUi(cursor == null || cursor.getCount() == 0); + if (mListState != null && cursor != null && oldCursor == null) { + mRecyclerView.getLayoutManager().onRestoreInstanceState(mListState); + } + } + + @Override + public void setBlockedParticipantsAvailable(final boolean blockedAvailable) { + mBlockedAvailable = blockedAvailable; + if (mShowBlockedMenuItem != null) { + mShowBlockedMenuItem.setVisible(blockedAvailable); + } + } + + public void updateUi() { + mAdapter.notifyDataSetChanged(); + } + + @Override + public void onPrepareOptionsMenu(final Menu menu) { + super.onPrepareOptionsMenu(menu); + final MenuItem startNewConversationMenuItem = + menu.findItem(R.id.action_start_new_conversation); + if (startNewConversationMenuItem != null) { + // It is recommended for the Floating Action button functionality to be duplicated as a + // menu + AccessibilityManager accessibilityManager = (AccessibilityManager) + getActivity().getSystemService(Context.ACCESSIBILITY_SERVICE); + startNewConversationMenuItem.setVisible(accessibilityManager + .isTouchExplorationEnabled()); + } + + final MenuItem archive = menu.findItem(R.id.action_show_archived); + if (archive != null) { + archive.setVisible(true); + } + } + + @Override + public void onCreateOptionsMenu(final Menu menu, final MenuInflater inflater) { + if (!isAdded()) { + // Guard against being called before we're added to the activity + return; + } + + mShowBlockedMenuItem = menu.findItem(R.id.action_show_blocked_contacts); + if (mShowBlockedMenuItem != null) { + mShowBlockedMenuItem.setVisible(mBlockedAvailable); + } + } + + /** + * {@inheritDoc} from ConversationListItemView.HostInterface + */ + @Override + public void onConversationClicked(final ConversationListItemData conversationListItemData, + final boolean isLongClick, final ConversationListItemView conversationView) { + final ConversationListData listData = mListBinding.getData(); + mHost.onConversationClick(listData, conversationListItemData, isLongClick, + conversationView); + } + + /** + * {@inheritDoc} from ConversationListItemView.HostInterface + */ + @Override + public boolean isConversationSelected(final String conversationId) { + return mHost.isConversationSelected(conversationId); + } + + @Override + public boolean isSwipeAnimatable() { + return mHost.isSwipeAnimatable(); + } + + // Show and hide empty list UI as needed with appropriate text based on view specifics + private void updateEmptyListUi(final boolean isEmpty) { + if (isEmpty) { + int emptyListText; + if (!mListBinding.getData().getHasFirstSyncCompleted()) { + emptyListText = R.string.conversation_list_first_sync_text; + } else if (mArchiveMode) { + emptyListText = R.string.archived_conversation_list_empty_text; + } else { + emptyListText = R.string.conversation_list_empty_text; + } + mEmptyListMessageView.setTextHint(emptyListText); + mEmptyListMessageView.setVisibility(View.VISIBLE); + mEmptyListMessageView.setIsImageVisible(true); + mEmptyListMessageView.setIsVerticallyCentered(true); + } else { + mEmptyListMessageView.setVisibility(View.GONE); + } + } + + @Override + public List<SnackBarInteraction> getSnackBarInteractions() { + final List<SnackBarInteraction> interactions = new ArrayList<SnackBarInteraction>(1); + final SnackBarInteraction fabInteraction = + new SnackBarInteraction.BasicSnackBarInteraction(mStartNewConversationButton); + interactions.add(fabInteraction); + return interactions; + } + + private ViewPropertyAnimator getNormalizedFabAnimator() { + return mStartNewConversationButton.animate() + .setInterpolator(UiUtils.DEFAULT_INTERPOLATOR) + .setDuration(getActivity().getResources().getInteger( + R.integer.fab_animation_duration_ms)); + } + + public ViewPropertyAnimator dismissFab() { + // To prevent clicking while animating. + mStartNewConversationButton.setEnabled(false); + final MarginLayoutParams lp = + (MarginLayoutParams) mStartNewConversationButton.getLayoutParams(); + final float fabWidthWithLeftRightMargin = mStartNewConversationButton.getWidth() + + lp.leftMargin + lp.rightMargin; + final int direction = AccessibilityUtil.isLayoutRtl(mStartNewConversationButton) ? -1 : 1; + return getNormalizedFabAnimator().translationX(direction * fabWidthWithLeftRightMargin); + } + + public ViewPropertyAnimator showFab() { + return getNormalizedFabAnimator().translationX(0).withEndAction(new Runnable() { + @Override + public void run() { + // Re-enable clicks after the animation. + mStartNewConversationButton.setEnabled(true); + } + }); + } + + public View getHeroElementForTransition() { + return mArchiveMode ? null : mStartNewConversationButton; + } + + @VisibleForAnimation + public RecyclerView getRecyclerView() { + return mRecyclerView; + } + + @Override + public void startFullScreenPhotoViewer( + final Uri initialPhoto, final Rect initialPhotoBounds, final Uri photosUri) { + UIIntents.get().launchFullScreenPhotoViewer( + getActivity(), initialPhoto, initialPhotoBounds, photosUri); + } + + @Override + public void startFullScreenVideoViewer(final Uri videoUri) { + UIIntents.get().launchFullScreenVideoViewer(getActivity(), videoUri); + } + + @Override + public boolean isSelectionMode() { + return mHost != null && mHost.isSelectionMode(); + } +} diff --git a/src/com/android/messaging/ui/conversationlist/ConversationListItemView.java b/src/com/android/messaging/ui/conversationlist/ConversationListItemView.java new file mode 100644 index 0000000..7525182 --- /dev/null +++ b/src/com/android/messaging/ui/conversationlist/ConversationListItemView.java @@ -0,0 +1,643 @@ +/* + * 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.ui.conversationlist; + +import android.content.Context; +import android.content.res.Resources; +import android.database.Cursor; +import android.graphics.Color; +import android.graphics.Rect; +import android.graphics.Typeface; +import android.net.Uri; +import android.support.v4.text.BidiFormatter; +import android.support.v4.text.TextDirectionHeuristicsCompat; +import android.text.TextPaint; +import android.text.TextUtils; +import android.util.AttributeSet; +import android.view.View; +import android.view.View.OnClickListener; +import android.view.View.OnLayoutChangeListener; +import android.view.View.OnLongClickListener; +import android.view.ViewGroup; +import android.widget.FrameLayout; +import android.widget.ImageView; +import android.widget.TextView; + +import com.android.messaging.Factory; +import com.android.messaging.R; +import com.android.messaging.annotation.VisibleForAnimation; +import com.android.messaging.datamodel.MessagingContentProvider; +import com.android.messaging.datamodel.action.UpdateConversationArchiveStatusAction; +import com.android.messaging.datamodel.data.ConversationListItemData; +import com.android.messaging.datamodel.data.MessageData; +import com.android.messaging.datamodel.media.UriImageRequestDescriptor; +import com.android.messaging.sms.MmsUtils; +import com.android.messaging.ui.AsyncImageView; +import com.android.messaging.ui.AudioAttachmentView; +import com.android.messaging.ui.ContactIconView; +import com.android.messaging.ui.SnackBar; +import com.android.messaging.ui.SnackBarInteraction; +import com.android.messaging.util.Assert; +import com.android.messaging.util.ContentType; +import com.android.messaging.util.ImageUtils; +import com.android.messaging.util.OsUtil; +import com.android.messaging.util.PhoneUtils; +import com.android.messaging.util.Typefaces; +import com.android.messaging.util.UiUtils; +import com.android.messaging.util.UriUtil; + +import java.util.List; + +/** + * The view for a single entry in a conversation list. + */ +public class ConversationListItemView extends FrameLayout implements OnClickListener, + OnLongClickListener, OnLayoutChangeListener { + static final int UNREAD_SNIPPET_LINE_COUNT = 3; + static final int NO_UNREAD_SNIPPET_LINE_COUNT = 1; + private int mListItemReadColor; + private int mListItemUnreadColor; + private Typeface mListItemReadTypeface; + private Typeface mListItemUnreadTypeface; + private static String sPlusOneString; + private static String sPlusNString; + + public interface HostInterface { + boolean isConversationSelected(final String conversationId); + void onConversationClicked(final ConversationListItemData conversationListItemData, + boolean isLongClick, final ConversationListItemView conversationView); + boolean isSwipeAnimatable(); + List<SnackBarInteraction> getSnackBarInteractions(); + void startFullScreenPhotoViewer(final Uri initialPhoto, final Rect initialPhotoBounds, + final Uri photosUri); + void startFullScreenVideoViewer(final Uri videoUri); + boolean isSelectionMode(); + } + + private final OnClickListener fullScreenPreviewClickListener = new OnClickListener() { + @Override + public void onClick(final View v) { + final String previewType = mData.getShowDraft() ? + mData.getDraftPreviewContentType() : mData.getPreviewContentType(); + Assert.isTrue(ContentType.isImageType(previewType) || + ContentType.isVideoType(previewType)); + + final Uri previewUri = mData.getShowDraft() ? + mData.getDraftPreviewUri() : mData.getPreviewUri(); + if (ContentType.isImageType(previewType)) { + final Uri imagesUri = mData.getShowDraft() ? + MessagingContentProvider.buildDraftImagesUri(mData.getConversationId()) : + MessagingContentProvider + .buildConversationImagesUri(mData.getConversationId()); + final Rect previewImageBounds = UiUtils.getMeasuredBoundsOnScreen(v); + mHostInterface.startFullScreenPhotoViewer( + previewUri, previewImageBounds, imagesUri); + } else { + mHostInterface.startFullScreenVideoViewer(previewUri); + } + } + }; + + private final ConversationListItemData mData; + + private int mAnimatingCount; + private ViewGroup mSwipeableContainer; + private ViewGroup mCrossSwipeBackground; + private ViewGroup mSwipeableContent; + private TextView mConversationNameView; + private TextView mSnippetTextView; + private TextView mSubjectTextView; + private TextView mTimestampTextView; + private ContactIconView mContactIconView; + private ImageView mContactCheckmarkView; + private ImageView mNotificationBellView; + private ImageView mFailedStatusIconView; + private ImageView mCrossSwipeArchiveLeftImageView; + private ImageView mCrossSwipeArchiveRightImageView; + private AsyncImageView mImagePreviewView; + private AudioAttachmentView mAudioAttachmentView; + private HostInterface mHostInterface; + + public ConversationListItemView(final Context context, final AttributeSet attrs) { + super(context, attrs); + mData = new ConversationListItemData(); + final Resources res = context.getResources(); + } + + @Override + protected void onFinishInflate() { + mSwipeableContainer = (ViewGroup) findViewById(R.id.swipeableContainer); + mCrossSwipeBackground = (ViewGroup) findViewById(R.id.crossSwipeBackground); + mSwipeableContent = (ViewGroup) findViewById(R.id.swipeableContent); + mConversationNameView = (TextView) findViewById(R.id.conversation_name); + mSnippetTextView = (TextView) findViewById(R.id.conversation_snippet); + mSubjectTextView = (TextView) findViewById(R.id.conversation_subject); + mTimestampTextView = (TextView) findViewById(R.id.conversation_timestamp); + mContactIconView = (ContactIconView) findViewById(R.id.conversation_icon); + mContactCheckmarkView = (ImageView) findViewById(R.id.conversation_checkmark); + mNotificationBellView = (ImageView) findViewById(R.id.conversation_notification_bell); + mFailedStatusIconView = (ImageView) findViewById(R.id.conversation_failed_status_icon); + mCrossSwipeArchiveLeftImageView = (ImageView) findViewById(R.id.crossSwipeArchiveIconLeft); + mCrossSwipeArchiveRightImageView = + (ImageView) findViewById(R.id.crossSwipeArchiveIconRight); + mImagePreviewView = (AsyncImageView) findViewById(R.id.conversation_image_preview); + mAudioAttachmentView = (AudioAttachmentView) findViewById(R.id.audio_attachment_view); + mConversationNameView.addOnLayoutChangeListener(this); + mSnippetTextView.addOnLayoutChangeListener(this); + + final Resources resources = getContext().getResources(); + mListItemReadColor = resources.getColor(R.color.conversation_list_item_read); + mListItemUnreadColor = resources.getColor(R.color.conversation_list_item_unread); + + mListItemReadTypeface = Typefaces.getRobotoNormal(); + mListItemUnreadTypeface = Typefaces.getRobotoBold(); + + if (OsUtil.isAtLeastL()) { + setTransitionGroup(true); + } + } + + @Override + public void onLayoutChange(final View v, final int left, final int top, final int right, + final int bottom, final int oldLeft, final int oldTop, final int oldRight, + final int oldBottom) { + if (v == mConversationNameView) { + setConversationName(); + } else if (v == mSnippetTextView) { + setSnippet(); + } else if (v == mSubjectTextView) { + setSubject(); + } + } + + private void setConversationName() { + if (mData.getIsRead() || mData.getShowDraft()) { + mConversationNameView.setTextColor(mListItemReadColor); + mConversationNameView.setTypeface(mListItemReadTypeface); + } else { + mConversationNameView.setTextColor(mListItemUnreadColor); + mConversationNameView.setTypeface(mListItemUnreadTypeface); + } + + final String conversationName = mData.getName(); + + // For group conversations, ellipsize the group members that do not fit + final CharSequence ellipsizedName = UiUtils.commaEllipsize( + conversationName, + mConversationNameView.getPaint(), + mConversationNameView.getMeasuredWidth(), + getPlusOneString(), + getPlusNString()); + // RTL : To format conversation name if it happens to be phone number. + final BidiFormatter bidiFormatter = BidiFormatter.getInstance(); + final String bidiFormattedName = bidiFormatter.unicodeWrap( + ellipsizedName.toString(), + TextDirectionHeuristicsCompat.LTR); + + mConversationNameView.setText(bidiFormattedName); + } + + private static String getPlusOneString() { + if (sPlusOneString == null) { + sPlusOneString = Factory.get().getApplicationContext().getResources() + .getString(R.string.plus_one); + } + return sPlusOneString; + } + + private static String getPlusNString() { + if (sPlusNString == null) { + sPlusNString = Factory.get().getApplicationContext().getResources() + .getString(R.string.plus_n); + } + return sPlusNString; + } + + private void setSubject() { + final String subjectText = mData.getShowDraft() ? + mData.getDraftSubject() : + MmsUtils.cleanseMmsSubject(getContext().getResources(), mData.getSubject()); + if (!TextUtils.isEmpty(subjectText)) { + final String subjectPrepend = getResources().getString(R.string.subject_label); + mSubjectTextView.setText(TextUtils.concat(subjectPrepend, subjectText)); + mSubjectTextView.setVisibility(VISIBLE); + } else { + mSubjectTextView.setVisibility(GONE); + } + } + + private void setSnippet() { + mSnippetTextView.setText(getSnippetText()); + } + + // Resource Ids of content descriptions prefixes for different message status. + private static final int [][][] sPrimaryContentDescriptions = { + // 1:1 conversation + { + // Incoming message + { + R.string.one_on_one_incoming_failed_message_prefix, + R.string.one_on_one_incoming_successful_message_prefix + }, + // Outgoing message + { + R.string.one_on_one_outgoing_failed_message_prefix, + R.string.one_on_one_outgoing_successful_message_prefix, + R.string.one_on_one_outgoing_draft_message_prefix, + R.string.one_on_one_outgoing_sending_message_prefix, + } + }, + + // Group conversation + { + // Incoming message + { + R.string.group_incoming_failed_message_prefix, + R.string.group_incoming_successful_message_prefix, + }, + // Outgoing message + { + R.string.group_outgoing_failed_message_prefix, + R.string.group_outgoing_successful_message_prefix, + R.string.group_outgoing_draft_message_prefix, + R.string.group_outgoing_sending_message_prefix, + } + } + }; + + // Resource Id of the secondary part of the content description for an edge case of a message + // which is in both draft status and failed status. + private static final int sSecondaryContentDescription = + R.string.failed_message_content_description; + + // 1:1 versus group + private static final int CONV_TYPE_ONE_ON_ONE_INDEX = 0; + private static final int CONV_TYPE_ONE_GROUP_INDEX = 1; + // Direction + private static final int DIRECTION_INCOMING_INDEX = 0; + private static final int DIRECTION_OUTGOING_INDEX = 1; + // Message status + private static final int MESSAGE_STATUS_FAILED_INDEX = 0; + private static final int MESSAGE_STATUS_SUCCESSFUL_INDEX = 1; + private static final int MESSAGE_STATUS_DRAFT_INDEX = 2; + private static final int MESSAGE_STATUS_SENDING_INDEX = 3; + + private static final int WIDTH_FOR_ACCESSIBLE_CONVERSATION_NAME = 600; + + public static String buildContentDescription(final Resources resources, + final ConversationListItemData data, final TextPaint conversationNameViewPaint) { + int messageStatusIndex; + boolean outgoingSnippet = data.getIsMessageTypeOutgoing() || data.getShowDraft(); + if (outgoingSnippet) { + if (data.getShowDraft()) { + messageStatusIndex = MESSAGE_STATUS_DRAFT_INDEX; + } else if (data.getIsSendRequested()) { + messageStatusIndex = MESSAGE_STATUS_SENDING_INDEX; + } else { + messageStatusIndex = data.getIsFailedStatus() ? MESSAGE_STATUS_FAILED_INDEX + : MESSAGE_STATUS_SUCCESSFUL_INDEX; + } + } else { + messageStatusIndex = data.getIsFailedStatus() ? MESSAGE_STATUS_FAILED_INDEX + : MESSAGE_STATUS_SUCCESSFUL_INDEX; + } + + int resId = sPrimaryContentDescriptions + [data.getIsGroup() ? CONV_TYPE_ONE_GROUP_INDEX : CONV_TYPE_ONE_ON_ONE_INDEX] + [outgoingSnippet ? DIRECTION_OUTGOING_INDEX : DIRECTION_INCOMING_INDEX] + [messageStatusIndex]; + + final String snippetText = data.getShowDraft() ? + data.getDraftSnippetText() : data.getSnippetText(); + + final String conversationName = data.getName(); + String senderOrConvName = outgoingSnippet ? conversationName : data.getSnippetSenderName(); + + String primaryContentDescription = resources.getString(resId, senderOrConvName, + snippetText == null ? "" : snippetText, + data.getFormattedTimestamp(), + // This is used only for incoming group messages + conversationName); + String contentDescription = primaryContentDescription; + + // An edge case : for an outgoing message, it might be in both draft status and + // failed status. + if (outgoingSnippet && data.getShowDraft() && data.getIsFailedStatus()) { + StringBuilder contentDescriptionBuilder = new StringBuilder(); + contentDescriptionBuilder.append(primaryContentDescription); + + String secondaryContentDescription = + resources.getString(sSecondaryContentDescription); + contentDescriptionBuilder.append(" "); + contentDescriptionBuilder.append(secondaryContentDescription); + contentDescription = contentDescriptionBuilder.toString(); + } + return contentDescription; + } + + /** + * Fills in the data associated with this view. + * + * @param cursor The cursor from a ConversationList that this view is in, pointing to its + * entry. + */ + public void bind(final Cursor cursor, final HostInterface hostInterface) { + // Update our UI model + mHostInterface = hostInterface; + mData.bind(cursor); + + resetAnimatingState(); + + mSwipeableContainer.setOnClickListener(this); + mSwipeableContainer.setOnLongClickListener(this); + + final Resources resources = getContext().getResources(); + + int color; + final int maxLines; + final Typeface typeface; + final int typefaceStyle = mData.getShowDraft() ? Typeface.ITALIC : Typeface.NORMAL; + final String snippetText = getSnippetText(); + + if (mData.getIsRead() || mData.getShowDraft()) { + maxLines = TextUtils.isEmpty(snippetText) ? 0 : NO_UNREAD_SNIPPET_LINE_COUNT; + color = mListItemReadColor; + typeface = mListItemReadTypeface; + } else { + maxLines = TextUtils.isEmpty(snippetText) ? 0 : UNREAD_SNIPPET_LINE_COUNT; + color = mListItemUnreadColor; + typeface = mListItemUnreadTypeface; + } + + mSnippetTextView.setMaxLines(maxLines); + mSnippetTextView.setTextColor(color); + mSnippetTextView.setTypeface(typeface, typefaceStyle); + mSubjectTextView.setTextColor(color); + mSubjectTextView.setTypeface(typeface, typefaceStyle); + + setSnippet(); + setConversationName(); + setSubject(); + setContentDescription(buildContentDescription(resources, mData, + mConversationNameView.getPaint())); + + final boolean isDefaultSmsApp = PhoneUtils.getDefault().isDefaultSmsApp(); + // don't show the error state unless we're the default sms app + if (mData.getIsFailedStatus() && isDefaultSmsApp) { + mTimestampTextView.setTextColor(resources.getColor(R.color.conversation_list_error)); + mTimestampTextView.setTypeface(mListItemReadTypeface, typefaceStyle); + int failureMessageId = R.string.message_status_download_failed; + if (mData.getIsMessageTypeOutgoing()) { + failureMessageId = MmsUtils.mapRawStatusToErrorResourceId(mData.getMessageStatus(), + mData.getMessageRawTelephonyStatus()); + } + mTimestampTextView.setText(resources.getString(failureMessageId)); + } else if (mData.getShowDraft() + || mData.getMessageStatus() == MessageData.BUGLE_STATUS_OUTGOING_DRAFT + // also check for unknown status which we get because sometimes the conversation + // row is left with a latest_message_id of a no longer existing message and + // therefore the join values come back as null (or in this case zero). + || mData.getMessageStatus() == MessageData.BUGLE_STATUS_UNKNOWN) { + mTimestampTextView.setTextColor(mListItemReadColor); + mTimestampTextView.setTypeface(mListItemReadTypeface, typefaceStyle); + mTimestampTextView.setText(resources.getString( + R.string.conversation_list_item_view_draft_message)); + } else { + mTimestampTextView.setTextColor(mListItemReadColor); + mTimestampTextView.setTypeface(mListItemReadTypeface, typefaceStyle); + final String formattedTimestamp = mData.getFormattedTimestamp(); + if (mData.getIsSendRequested()) { + mTimestampTextView.setText(R.string.message_status_sending); + } else { + mTimestampTextView.setText(formattedTimestamp); + } + } + + final boolean isSelected = mHostInterface.isConversationSelected(mData.getConversationId()); + setSelected(isSelected); + Uri iconUri = null; + int contactIconVisibility = GONE; + int checkmarkVisiblity = GONE; + int failStatusVisiblity = GONE; + if (isSelected) { + checkmarkVisiblity = VISIBLE; + } else { + contactIconVisibility = VISIBLE; + // Only show the fail icon if it is not a group conversation. + // And also require that we be the default sms app. + if (mData.getIsFailedStatus() && !mData.getIsGroup() && isDefaultSmsApp) { + failStatusVisiblity = VISIBLE; + } + } + if (mData.getIcon() != null) { + iconUri = Uri.parse(mData.getIcon()); + } + mContactIconView.setImageResourceUri(iconUri, mData.getParticipantContactId(), + mData.getParticipantLookupKey(), mData.getOtherParticipantNormalizedDestination()); + mContactIconView.setVisibility(contactIconVisibility); + mContactIconView.setOnLongClickListener(this); + mContactIconView.setClickable(!mHostInterface.isSelectionMode()); + mContactIconView.setLongClickable(!mHostInterface.isSelectionMode()); + + mContactCheckmarkView.setVisibility(checkmarkVisiblity); + mFailedStatusIconView.setVisibility(failStatusVisiblity); + + final Uri previewUri = mData.getShowDraft() ? + mData.getDraftPreviewUri() : mData.getPreviewUri(); + final String previewContentType = mData.getShowDraft() ? + mData.getDraftPreviewContentType() : mData.getPreviewContentType(); + OnClickListener previewClickListener = null; + Uri previewImageUri = null; + int previewImageVisibility = GONE; + int audioPreviewVisiblity = GONE; + if (previewUri != null && !TextUtils.isEmpty(previewContentType)) { + if (ContentType.isAudioType(previewContentType)) { + mAudioAttachmentView.bind(previewUri, false); + audioPreviewVisiblity = VISIBLE; + } else if (ContentType.isVideoType(previewContentType)) { + previewImageUri = UriUtil.getUriForResourceId( + getContext(), R.drawable.ic_preview_play); + previewClickListener = fullScreenPreviewClickListener; + previewImageVisibility = VISIBLE; + } else if (ContentType.isImageType(previewContentType)) { + previewImageUri = previewUri; + previewClickListener = fullScreenPreviewClickListener; + previewImageVisibility = VISIBLE; + } + } + + final int imageSize = resources.getDimensionPixelSize( + R.dimen.conversation_list_image_preview_size); + mImagePreviewView.setImageResourceId( + new UriImageRequestDescriptor(previewImageUri, imageSize, imageSize, + true /* allowCompression */, false /* isStatic */, false /*cropToCircle*/, + ImageUtils.DEFAULT_CIRCLE_BACKGROUND_COLOR /* circleBackgroundColor */, + ImageUtils.DEFAULT_CIRCLE_STROKE_COLOR /* circleStrokeColor */)); + mImagePreviewView.setOnLongClickListener(this); + mImagePreviewView.setVisibility(previewImageVisibility); + mImagePreviewView.setOnClickListener(previewClickListener); + mAudioAttachmentView.setOnLongClickListener(this); + mAudioAttachmentView.setVisibility(audioPreviewVisiblity); + + final int notificationBellVisiblity = mData.getNotificationEnabled() ? GONE : VISIBLE; + mNotificationBellView.setVisibility(notificationBellVisiblity); + } + + public boolean isSwipeAnimatable() { + return mHostInterface.isSwipeAnimatable(); + } + + @VisibleForAnimation + public float getSwipeTranslationX() { + return mSwipeableContainer.getTranslationX(); + } + + @VisibleForAnimation + public void setSwipeTranslationX(final float translationX) { + mSwipeableContainer.setTranslationX(translationX); + if (translationX == 0) { + mCrossSwipeBackground.setVisibility(View.GONE); + mCrossSwipeArchiveLeftImageView.setVisibility(GONE); + mCrossSwipeArchiveRightImageView.setVisibility(GONE); + + mSwipeableContainer.setBackgroundColor(Color.TRANSPARENT); + } else { + mCrossSwipeBackground.setVisibility(View.VISIBLE); + if (translationX > 0) { + mCrossSwipeArchiveLeftImageView.setVisibility(VISIBLE); + mCrossSwipeArchiveRightImageView.setVisibility(GONE); + } else { + mCrossSwipeArchiveLeftImageView.setVisibility(GONE); + mCrossSwipeArchiveRightImageView.setVisibility(VISIBLE); + } + mSwipeableContainer.setBackgroundResource(R.drawable.swipe_shadow_drag); + } + } + + public void onSwipeComplete() { + final String conversationId = mData.getConversationId(); + UpdateConversationArchiveStatusAction.archiveConversation(conversationId); + + final Runnable undoRunnable = new Runnable() { + @Override + public void run() { + UpdateConversationArchiveStatusAction.unarchiveConversation(conversationId); + } + }; + final String message = getResources().getString(R.string.archived_toast_message, 1); + UiUtils.showSnackBar(getContext(), getRootView(), message, undoRunnable, + SnackBar.Action.SNACK_BAR_UNDO, + mHostInterface.getSnackBarInteractions()); + } + + private void setShortAndLongClickable(final boolean clickable) { + setClickable(clickable); + setLongClickable(clickable); + } + + private void resetAnimatingState() { + mAnimatingCount = 0; + setShortAndLongClickable(true); + setSwipeTranslationX(0); + } + + /** + * Notifies this view that it is undergoing animation. This view should disable its click + * targets. + * + * The animating counter is used to reset the swipe controller when the counter becomes 0. A + * positive counter also makes the view not clickable. + */ + public final void setAnimating(final boolean animating) { + final int oldAnimatingCount = mAnimatingCount; + if (animating) { + mAnimatingCount++; + } else { + mAnimatingCount--; + if (mAnimatingCount < 0) { + mAnimatingCount = 0; + } + } + + if (mAnimatingCount == 0) { + // New count is 0. All animations ended. + setShortAndLongClickable(true); + } else if (oldAnimatingCount == 0) { + // New count is > 0. Waiting for some animations to end. + setShortAndLongClickable(false); + } + } + + public boolean isAnimating() { + return mAnimatingCount > 0; + } + + /** + * {@inheritDoc} from OnClickListener + */ + @Override + public void onClick(final View v) { + processClick(v, false); + } + + /** + * {@inheritDoc} from OnLongClickListener + */ + @Override + public boolean onLongClick(final View v) { + return processClick(v, true); + } + + private boolean processClick(final View v, final boolean isLongClick) { + Assert.isTrue(v == mSwipeableContainer || v == mContactIconView || v == mImagePreviewView); + Assert.notNull(mData.getName()); + + if (mHostInterface != null) { + mHostInterface.onConversationClicked(mData, isLongClick, this); + return true; + } + return false; + } + + public View getSwipeableContent() { + return mSwipeableContent; + } + + public View getContactIconView() { + return mContactIconView; + } + + private String getSnippetText() { + String snippetText = mData.getShowDraft() ? + mData.getDraftSnippetText() : mData.getSnippetText(); + final String previewContentType = mData.getShowDraft() ? + mData.getDraftPreviewContentType() : mData.getPreviewContentType(); + if (TextUtils.isEmpty(snippetText)) { + Resources resources = getResources(); + // Use the attachment type as a snippet so the preview doesn't look odd + if (ContentType.isAudioType(previewContentType)) { + snippetText = resources.getString(R.string.conversation_list_snippet_audio_clip); + } else if (ContentType.isImageType(previewContentType)) { + snippetText = resources.getString(R.string.conversation_list_snippet_picture); + } else if (ContentType.isVideoType(previewContentType)) { + snippetText = resources.getString(R.string.conversation_list_snippet_video); + } else if (ContentType.isVCardType(previewContentType)) { + snippetText = resources.getString(R.string.conversation_list_snippet_vcard); + } + } + return snippetText; + } +} diff --git a/src/com/android/messaging/ui/conversationlist/ConversationListSwipeHelper.java b/src/com/android/messaging/ui/conversationlist/ConversationListSwipeHelper.java new file mode 100644 index 0000000..4988259 --- /dev/null +++ b/src/com/android/messaging/ui/conversationlist/ConversationListSwipeHelper.java @@ -0,0 +1,462 @@ +/* + * 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.ui.conversationlist; + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.animation.ObjectAnimator; +import android.animation.TimeInterpolator; +import android.content.Context; +import android.content.res.Resources; +import android.support.v4.view.ViewCompat; +import android.support.v7.widget.RecyclerView; +import android.support.v7.widget.RecyclerView.OnItemTouchListener; +import android.view.MotionEvent; +import android.view.VelocityTracker; +import android.view.View; +import android.view.ViewConfiguration; + +import com.android.messaging.R; +import com.android.messaging.util.Assert; +import com.android.messaging.util.UiUtils; + +/** + * Animation and touch helper class for Conversation List swipe. + */ +public class ConversationListSwipeHelper implements OnItemTouchListener { + private static final int UNIT_SECONDS = 1000; + private static final boolean ANIMATING = true; + + private static final float ERROR_FACTOR_MULTIPLIER = 1.2f; + private static final float PERCENTAGE_OF_WIDTH_TO_DISMISS = 0.4f; + private static final float FLING_PERCENTAGE_OF_WIDTH_TO_DISMISS = 0.05f; + + private static final int SWIPE_DIRECTION_NONE = 0; + private static final int SWIPE_DIRECTION_LEFT = 1; + private static final int SWIPE_DIRECTION_RIGHT = 2; + + private final RecyclerView mRecyclerView; + private final long mDefaultRestoreAnimationDuration; + private final long mDefaultDismissAnimationDuration; + private final long mMaxTranslationAnimationDuration; + private final int mTouchSlop; + private final int mMinimumFlingVelocity; + private final int mMaximumFlingVelocity; + + /* Valid throughout a single gesture. */ + private VelocityTracker mVelocityTracker; + private float mInitialX; + private float mInitialY; + private boolean mIsSwiping; + private ConversationListItemView mListItemView; + + public ConversationListSwipeHelper(final RecyclerView recyclerView) { + mRecyclerView = recyclerView; + + final Context context = mRecyclerView.getContext(); + final Resources res = context.getResources(); + mDefaultRestoreAnimationDuration = res.getInteger(R.integer.swipe_duration_ms); + mDefaultDismissAnimationDuration = res.getInteger(R.integer.swipe_duration_ms); + mMaxTranslationAnimationDuration = res.getInteger(R.integer.swipe_duration_ms); + + final ViewConfiguration viewConfiguration = ViewConfiguration.get(context); + mTouchSlop = viewConfiguration.getScaledPagingTouchSlop(); + mMaximumFlingVelocity = Math.min( + viewConfiguration.getScaledMaximumFlingVelocity(), + res.getInteger(R.integer.swipe_max_fling_velocity_px_per_s)); + mMinimumFlingVelocity = viewConfiguration.getScaledMinimumFlingVelocity(); + } + + @Override + public boolean onInterceptTouchEvent(final RecyclerView recyclerView, final MotionEvent event) { + if (event.getPointerCount() > 1) { + // Ignore subsequent pointers. + return false; + } + + // We are not yet tracking a swipe gesture. Begin detection by spying on + // touch events bubbling down to our children. + final int action = event.getActionMasked(); + switch (action) { + case MotionEvent.ACTION_DOWN: + if (!hasGestureSwipeTarget()) { + onGestureStart(); + + mVelocityTracker.addMovement(event); + mInitialX = event.getX(); + mInitialY = event.getY(); + + final View viewAtPoint = mRecyclerView.findChildViewUnder(mInitialX, mInitialY); + final ConversationListItemView child = (ConversationListItemView) viewAtPoint; + if (viewAtPoint instanceof ConversationListItemView && + child != null && child.isSwipeAnimatable()) { + // Begin detecting swipe on the target for the rest of the gesture. + mListItemView = child; + if (mListItemView.isAnimating()) { + mListItemView = null; + } + } else { + mListItemView = null; + } + } + break; + case MotionEvent.ACTION_MOVE: + if (hasValidGestureSwipeTarget()) { + mVelocityTracker.addMovement(event); + + final int historicalCount = event.getHistorySize(); + // First consume the historical events, then consume the current ones. + for (int i = 0; i < historicalCount + 1; i++) { + float currX; + float currY; + if (i < historicalCount) { + currX = event.getHistoricalX(i); + currY = event.getHistoricalY(i); + } else { + currX = event.getX(); + currY = event.getY(); + } + final float deltaX = currX - mInitialX; + final float deltaY = currY - mInitialY; + final float absDeltaX = Math.abs(deltaX); + final float absDeltaY = Math.abs(deltaY); + + if (!mIsSwiping && absDeltaY > mTouchSlop + && absDeltaY > (ERROR_FACTOR_MULTIPLIER * absDeltaX)) { + // Stop detecting swipe for the remainder of this gesture. + onGestureEnd(); + return false; + } + + if (absDeltaX > mTouchSlop) { + // Swipe detected. Return true so we can handle the gesture in + // onTouchEvent. + mIsSwiping = true; + + // We don't want to suddenly jump the slop distance. + mInitialX = event.getX(); + mInitialY = event.getY(); + + onSwipeGestureStart(mListItemView); + return true; + } + } + } + break; + case MotionEvent.ACTION_UP: + case MotionEvent.ACTION_CANCEL: + if (hasGestureSwipeTarget()) { + onGestureEnd(); + } + break; + } + + // Start intercepting touch events from children if we detect a swipe. + return mIsSwiping; + } + + @Override + public void onTouchEvent(final RecyclerView recyclerView, final MotionEvent event) { + // We should only be here if we intercepted the touch due to swipe. + Assert.isTrue(mIsSwiping); + + // We are now tracking a swipe gesture. + mVelocityTracker.addMovement(event); + + final int action = event.getActionMasked(); + switch (action) { + case MotionEvent.ACTION_OUTSIDE: + case MotionEvent.ACTION_MOVE: + if (hasValidGestureSwipeTarget()) { + mListItemView.setSwipeTranslationX(event.getX() - mInitialX); + } + break; + case MotionEvent.ACTION_UP: + if (hasValidGestureSwipeTarget()) { + final float maxVelocity = mMaximumFlingVelocity; + mVelocityTracker.computeCurrentVelocity(UNIT_SECONDS, maxVelocity); + final float velocityX = getLastComputedXVelocity(); + + final float translationX = mListItemView.getSwipeTranslationX(); + + int swipeDirection = SWIPE_DIRECTION_NONE; + if (translationX != 0) { + swipeDirection = + translationX > 0 ? SWIPE_DIRECTION_RIGHT : SWIPE_DIRECTION_LEFT; + } else if (velocityX != 0) { + swipeDirection = + velocityX > 0 ? SWIPE_DIRECTION_RIGHT : SWIPE_DIRECTION_LEFT; + } + + final boolean fastEnough = isTargetSwipedFastEnough(); + final boolean farEnough = isTargetSwipedFarEnough(); + + final boolean shouldDismiss = (fastEnough || farEnough); + + if (shouldDismiss) { + if (fastEnough) { + animateDismiss(mListItemView, velocityX); + } else { + animateDismiss(mListItemView, swipeDirection); + } + } else { + animateRestore(mListItemView, velocityX); + } + + onSwipeGestureEnd(mListItemView, + shouldDismiss ? swipeDirection : SWIPE_DIRECTION_NONE); + } else { + onGestureEnd(); + } + break; + case MotionEvent.ACTION_CANCEL: + if (hasValidGestureSwipeTarget()) { + animateRestore(mListItemView, 0f); + onSwipeGestureEnd(mListItemView, SWIPE_DIRECTION_NONE); + } else { + onGestureEnd(); + } + break; + } + } + + + @Override + public void onRequestDisallowInterceptTouchEvent(boolean disallowIntercept) { + } + + /** + * We have started to intercept a series of touch events. + */ + private void onGestureStart() { + mIsSwiping = false; + // Work around bug in RecyclerView that sends two identical ACTION_DOWN + // events to #onInterceptTouchEvent. + if (mVelocityTracker == null) { + mVelocityTracker = VelocityTracker.obtain(); + } + mVelocityTracker.clear(); + } + + /** + * The series of touch events has been detected as a swipe. + * + * Now that the gesture is a swipe, we will begin translating the view of the + * given viewHolder. + */ + private void onSwipeGestureStart(final ConversationListItemView itemView) { + mRecyclerView.getParent().requestDisallowInterceptTouchEvent(true); + setHardwareAnimatingLayerType(itemView, ANIMATING); + itemView.setAnimating(true); + } + + /** + * The current swipe gesture is complete. + */ + private void onSwipeGestureEnd(final ConversationListItemView itemView, + final int swipeDirection) { + if (swipeDirection == SWIPE_DIRECTION_RIGHT || swipeDirection == SWIPE_DIRECTION_LEFT) { + itemView.onSwipeComplete(); + } + + // Balances out onSwipeGestureStart. + itemView.setAnimating(false); + + onGestureEnd(); + } + + /** + * The series of touch events has ended in an {@link MotionEvent#ACTION_UP} + * or {@link MotionEvent#ACTION_CANCEL}. + */ + private void onGestureEnd() { + mVelocityTracker.recycle(); + mVelocityTracker = null; + mIsSwiping = false; + mListItemView = null; + } + + /** + * A swipe animation has started. + */ + private void onSwipeAnimationStart(final ConversationListItemView itemView) { + // Disallow interactions. + itemView.setAnimating(true); + ViewCompat.setHasTransientState(itemView, true); + setHardwareAnimatingLayerType(itemView, ANIMATING); + } + + /** + * The swipe animation has ended. + */ + private void onSwipeAnimationEnd(final ConversationListItemView itemView) { + // Restore interactions. + itemView.setAnimating(false); + ViewCompat.setHasTransientState(itemView, false); + setHardwareAnimatingLayerType(itemView, !ANIMATING); + } + + /** + * Animate the dismissal of the given item. The given velocityX is taken into consideration for + * the animation duration. Whether the item is dismissed to the left or right is dependent on + * the given velocityX. + */ + private void animateDismiss(final ConversationListItemView itemView, final float velocityX) { + Assert.isTrue(velocityX != 0); + final int direction = velocityX > 0 ? SWIPE_DIRECTION_RIGHT : SWIPE_DIRECTION_LEFT; + animateDismiss(itemView, direction, velocityX); + } + + /** + * Animate the dismissal of the given item. The velocityX is assumed to be 0. + */ + private void animateDismiss(final ConversationListItemView itemView, final int swipeDirection) { + animateDismiss(itemView, swipeDirection, 0f); + } + + /** + * Animate the dismissal of the given item. + */ + private void animateDismiss(final ConversationListItemView itemView, + final int swipeDirection, final float velocityX) { + Assert.isTrue(swipeDirection != SWIPE_DIRECTION_NONE); + + onSwipeAnimationStart(itemView); + + final float animateTo = (swipeDirection == SWIPE_DIRECTION_RIGHT) ? + mRecyclerView.getWidth() : -mRecyclerView.getWidth(); + final long duration; + if (velocityX != 0) { + final float deltaX = animateTo - itemView.getSwipeTranslationX(); + duration = calculateTranslationDuration(deltaX, velocityX); + } else { + duration = mDefaultDismissAnimationDuration; + } + + final ObjectAnimator animator = getSwipeTranslationXAnimator( + itemView, animateTo, duration, UiUtils.DEFAULT_INTERPOLATOR); + animator.addListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(final Animator animation) { + onSwipeAnimationEnd(itemView); + } + }); + animator.start(); + } + + /** + * Animate the bounce back of the given item. + */ + private void animateRestore(final ConversationListItemView itemView, + final float velocityX) { + onSwipeAnimationStart(itemView); + + final float translationX = itemView.getSwipeTranslationX(); + final long duration; + if (velocityX != 0 // Has velocity. + && velocityX > 0 != translationX > 0) { // Right direction. + duration = calculateTranslationDuration(translationX, velocityX); + } else { + duration = mDefaultRestoreAnimationDuration; + } + final ObjectAnimator animator = getSwipeTranslationXAnimator( + itemView, 0f, duration, UiUtils.DEFAULT_INTERPOLATOR); + animator.addListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(final Animator animation) { + onSwipeAnimationEnd(itemView); + } + }); + animator.start(); + } + + /** + * Create and start an animator that animates the given view's translationX + * from its current value to the value given by animateTo. + */ + private ObjectAnimator getSwipeTranslationXAnimator(final ConversationListItemView itemView, + final float animateTo, final long duration, final TimeInterpolator interpolator) { + final ObjectAnimator animator = + ObjectAnimator.ofFloat(itemView, "swipeTranslationX", animateTo); + animator.setDuration(duration); + animator.setInterpolator(interpolator); + return animator; + } + + /** + * Determine if the swipe has enough velocity to be dismissed. + */ + private boolean isTargetSwipedFastEnough() { + final float velocityX = getLastComputedXVelocity(); + final float velocityY = mVelocityTracker.getYVelocity(); + final float minVelocity = mMinimumFlingVelocity; + final float translationX = mListItemView.getSwipeTranslationX(); + final float width = mListItemView.getWidth(); + return (Math.abs(velocityX) > minVelocity) // Fast enough. + && (Math.abs(velocityX) > Math.abs(velocityY)) // Not unintentional. + && (velocityX > 0) == (translationX > 0) // Right direction. + && Math.abs(translationX) > + FLING_PERCENTAGE_OF_WIDTH_TO_DISMISS * width; // Enough movement. + } + + /** + * Only used during a swipe gesture. Determine if the swipe has enough distance to be + * dismissed. + */ + private boolean isTargetSwipedFarEnough() { + final float velocityX = getLastComputedXVelocity(); + + final float translationX = mListItemView.getSwipeTranslationX(); + final float width = mListItemView.getWidth(); + + return (velocityX >= 0) == (translationX > 0) // Right direction. + && Math.abs(translationX) > + PERCENTAGE_OF_WIDTH_TO_DISMISS * width; // Enough movement. + } + + private long calculateTranslationDuration(final float deltaPosition, final float velocity) { + Assert.isTrue(velocity != 0); + final float durationInSeconds = Math.abs(deltaPosition / velocity); + return Math.min((int) (durationInSeconds * UNIT_SECONDS), mMaxTranslationAnimationDuration); + } + + private boolean hasGestureSwipeTarget() { + return mListItemView != null; + } + + private boolean hasValidGestureSwipeTarget() { + return hasGestureSwipeTarget() && mListItemView.getParent() == mRecyclerView; + } + + /** + * Enable a hardware layer for the it view and build that layer. + */ + private void setHardwareAnimatingLayerType(final ConversationListItemView itemView, + final boolean animating) { + if (animating) { + itemView.setLayerType(View.LAYER_TYPE_HARDWARE, null); + if (itemView.getWindowToken() != null) { + itemView.buildLayer(); + } + } else { + itemView.setLayerType(View.LAYER_TYPE_NONE, null); + } + } + + private float getLastComputedXVelocity() { + return mVelocityTracker.getXVelocity(); + } +}
\ No newline at end of file diff --git a/src/com/android/messaging/ui/conversationlist/ForwardMessageActivity.java b/src/com/android/messaging/ui/conversationlist/ForwardMessageActivity.java new file mode 100644 index 0000000..61e3640 --- /dev/null +++ b/src/com/android/messaging/ui/conversationlist/ForwardMessageActivity.java @@ -0,0 +1,81 @@ +/* + * 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.ui.conversationlist; + +import android.app.Fragment; +import android.os.Bundle; + +import com.android.messaging.datamodel.data.ConversationListData; +import com.android.messaging.datamodel.data.ConversationListItemData; +import com.android.messaging.datamodel.data.MessageData; +import com.android.messaging.ui.BaseBugleActivity; +import com.android.messaging.ui.UIIntents; +import com.android.messaging.ui.conversationlist.ConversationListFragment.ConversationListFragmentHost; +import com.android.messaging.util.Assert; + +/** + * An activity that lets the user forward a SMS/MMS message by picking from a conversation in the + * conversation list. + */ +public class ForwardMessageActivity extends BaseBugleActivity + implements ConversationListFragmentHost { + private MessageData mDraftMessage; + + @Override + protected void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + final ConversationListFragment fragment = + ConversationListFragment.createForwardMessageConversationListFragment(); + getFragmentManager().beginTransaction().add(android.R.id.content, fragment).commit(); + mDraftMessage = getIntent().getParcelableExtra(UIIntents.UI_INTENT_EXTRA_DRAFT_DATA); + } + + @Override + public void onAttachFragment(final Fragment fragment) { + Assert.isTrue(fragment instanceof ConversationListFragment); + final ConversationListFragment clf = (ConversationListFragment) fragment; + clf.setHost(this); + } + + @Override + public void onConversationClick(final ConversationListData listData, + final ConversationListItemData conversationListItemData, + final boolean isLongClick, final ConversationListItemView converastionView) { + UIIntents.get().launchConversationActivity( + this, conversationListItemData.getConversationId(), mDraftMessage); + } + + @Override + public void onCreateConversationClick() { + UIIntents.get().launchCreateNewConversationActivity(this, mDraftMessage); + } + + @Override + public boolean isConversationSelected(final String conversationId) { + return false; + } + + @Override + public boolean isSwipeAnimatable() { + return false; + } + + @Override + public boolean isSelectionMode() { + return false; + } +} diff --git a/src/com/android/messaging/ui/conversationlist/MultiSelectActionModeCallback.java b/src/com/android/messaging/ui/conversationlist/MultiSelectActionModeCallback.java new file mode 100644 index 0000000..bfeec51 --- /dev/null +++ b/src/com/android/messaging/ui/conversationlist/MultiSelectActionModeCallback.java @@ -0,0 +1,219 @@ +/* + * 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.ui.conversationlist; + +import android.support.v4.util.ArrayMap; +import android.text.TextUtils; +import android.view.ActionMode; +import android.view.ActionMode.Callback; +import android.view.Menu; +import android.view.MenuItem; + +import com.android.messaging.R; +import com.android.messaging.datamodel.data.ConversationListData; +import com.android.messaging.datamodel.data.ConversationListItemData; +import com.android.messaging.util.Assert; + +import java.util.Collection; +import java.util.HashSet; + +public class MultiSelectActionModeCallback implements Callback { + private HashSet<String> mBlockedSet; + + public interface Listener { + void onActionBarDelete(Collection<SelectedConversation> conversations); + void onActionBarArchive(Iterable<SelectedConversation> conversations, + boolean isToArchive); + void onActionBarNotification(Iterable<SelectedConversation> conversations, + boolean isNotificationOn); + void onActionBarAddContact(final SelectedConversation conversation); + void onActionBarBlock(final SelectedConversation conversation); + void onActionBarHome(); + } + + static class SelectedConversation { + public final String conversationId; + public final long timestamp; + public final String icon; + public final String otherParticipantNormalizedDestination; + public final CharSequence participantLookupKey; + public final boolean isGroup; + public final boolean isArchived; + public final boolean notificationEnabled; + public SelectedConversation(ConversationListItemData data) { + conversationId = data.getConversationId(); + timestamp = data.getTimestamp(); + icon = data.getIcon(); + otherParticipantNormalizedDestination = data.getOtherParticipantNormalizedDestination(); + participantLookupKey = data.getParticipantLookupKey(); + isGroup = data.getIsGroup(); + isArchived = data.getIsArchived(); + notificationEnabled = data.getNotificationEnabled(); + } + } + + private final ArrayMap<String, SelectedConversation> mSelectedConversations; + + private Listener mListener; + private MenuItem mArchiveMenuItem; + private MenuItem mUnarchiveMenuItem; + private MenuItem mAddContactMenuItem; + private MenuItem mBlockMenuItem; + private MenuItem mNotificationOnMenuItem; + private MenuItem mNotificationOffMenuItem; + private boolean mHasInflated; + + public MultiSelectActionModeCallback(final Listener listener) { + mListener = listener; + mSelectedConversations = new ArrayMap<>(); + + } + + @Override + public boolean onCreateActionMode(ActionMode actionMode, Menu menu) { + actionMode.getMenuInflater().inflate(R.menu.conversation_list_fragment_select_menu, menu); + mArchiveMenuItem = menu.findItem(R.id.action_archive); + mUnarchiveMenuItem = menu.findItem(R.id.action_unarchive); + mAddContactMenuItem = menu.findItem(R.id.action_add_contact); + mBlockMenuItem = menu.findItem(R.id.action_block); + mNotificationOffMenuItem = menu.findItem(R.id.action_notification_off); + mNotificationOnMenuItem = menu.findItem(R.id.action_notification_on); + mHasInflated = true; + updateActionIconsVisiblity(); + return true; + } + + @Override + public boolean onPrepareActionMode(ActionMode actionMode, Menu menu) { + return true; + } + + @Override + public boolean onActionItemClicked(ActionMode actionMode, MenuItem menuItem) { + switch(menuItem.getItemId()) { + case R.id.action_delete: + mListener.onActionBarDelete(mSelectedConversations.values()); + return true; + case R.id.action_archive: + mListener.onActionBarArchive(mSelectedConversations.values(), true); + return true; + case R.id.action_unarchive: + mListener.onActionBarArchive(mSelectedConversations.values(), false); + return true; + case R.id.action_notification_off: + mListener.onActionBarNotification(mSelectedConversations.values(), false); + return true; + case R.id.action_notification_on: + mListener.onActionBarNotification(mSelectedConversations.values(), true); + return true; + case R.id.action_add_contact: + Assert.isTrue(mSelectedConversations.size() == 1); + mListener.onActionBarAddContact(mSelectedConversations.valueAt(0)); + return true; + case R.id.action_block: + Assert.isTrue(mSelectedConversations.size() == 1); + mListener.onActionBarBlock(mSelectedConversations.valueAt(0)); + return true; + case android.R.id.home: + mListener.onActionBarHome(); + return true; + default: + return false; + } + } + + @Override + public void onDestroyActionMode(ActionMode actionMode) { + mListener = null; + mSelectedConversations.clear(); + mHasInflated = false; + } + + public void toggleSelect(final ConversationListData listData, + final ConversationListItemData conversationListItemData) { + Assert.notNull(conversationListItemData); + mBlockedSet = listData.getBlockedParticipants(); + final String id = conversationListItemData.getConversationId(); + if (mSelectedConversations.containsKey(id)) { + mSelectedConversations.remove(id); + } else { + mSelectedConversations.put(id, new SelectedConversation(conversationListItemData)); + } + + if (mSelectedConversations.isEmpty()) { + mListener.onActionBarHome(); + } else { + updateActionIconsVisiblity(); + } + } + + public boolean isSelected(final String selectedId) { + return mSelectedConversations.containsKey(selectedId); + } + + private void updateActionIconsVisiblity() { + if (!mHasInflated) { + return; + } + + if (mSelectedConversations.size() == 1) { + final SelectedConversation conversation = mSelectedConversations.valueAt(0); + // The look up key is a key given to us by contacts app, so if we have a look up key, + // we know that the participant is already in contacts. + final boolean isInContacts = !TextUtils.isEmpty(conversation.participantLookupKey); + mAddContactMenuItem.setVisible(!conversation.isGroup && !isInContacts); + // ParticipantNormalizedDestination is always null for group conversations. + final String otherParticipant = conversation.otherParticipantNormalizedDestination; + mBlockMenuItem.setVisible(otherParticipant != null + && !mBlockedSet.contains(otherParticipant)); + } else { + mBlockMenuItem.setVisible(false); + mAddContactMenuItem.setVisible(false); + } + + boolean hasCurrentlyArchived = false; + boolean hasCurrentlyUnarchived = false; + boolean hasCurrentlyOnNotification = false; + boolean hasCurrentlyOffNotification = false; + final Iterable<SelectedConversation> conversations = mSelectedConversations.values(); + for (final SelectedConversation conversation : conversations) { + if (conversation.notificationEnabled) { + hasCurrentlyOnNotification = true; + } else { + hasCurrentlyOffNotification = true; + } + + if (conversation.isArchived) { + hasCurrentlyArchived = true; + } else { + hasCurrentlyUnarchived = true; + } + + // If we found at least one of each example we don't need to keep looping. + if (hasCurrentlyOffNotification && hasCurrentlyOnNotification && + hasCurrentlyArchived && hasCurrentlyUnarchived) { + break; + } + } + // If we have notification off conversations we show on button, if we have notification on + // conversation we show off button. We can show both if we have a mixture. + mNotificationOffMenuItem.setVisible(hasCurrentlyOnNotification); + mNotificationOnMenuItem.setVisible(hasCurrentlyOffNotification); + + mArchiveMenuItem.setVisible(hasCurrentlyUnarchived); + mUnarchiveMenuItem.setVisible(hasCurrentlyArchived); + } +} diff --git a/src/com/android/messaging/ui/conversationlist/ShareIntentActivity.java b/src/com/android/messaging/ui/conversationlist/ShareIntentActivity.java new file mode 100644 index 0000000..ef7fcef --- /dev/null +++ b/src/com/android/messaging/ui/conversationlist/ShareIntentActivity.java @@ -0,0 +1,177 @@ +/* + * 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.ui.conversationlist; + +import android.app.Fragment; +import android.content.ContentResolver; +import android.content.Intent; +import android.media.MediaMetadataRetriever; +import android.net.Uri; +import android.os.Bundle; +import android.text.TextUtils; + +import com.android.messaging.Factory; +import com.android.messaging.datamodel.data.ConversationListItemData; +import com.android.messaging.datamodel.data.MessageData; +import com.android.messaging.datamodel.data.PendingAttachmentData; +import com.android.messaging.ui.BaseBugleActivity; +import com.android.messaging.ui.UIIntents; +import com.android.messaging.util.Assert; +import com.android.messaging.util.ContentType; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.MediaMetadataRetrieverWrapper; + +import java.io.IOException; +import java.util.ArrayList; + +public class ShareIntentActivity extends BaseBugleActivity implements + ShareIntentFragment.HostInterface { + + private MessageData mDraftMessage; + + @Override + protected void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + final Intent intent = getIntent(); + if (Intent.ACTION_SEND.equals(intent.getAction()) && + (!TextUtils.isEmpty(intent.getStringExtra("address")) || + !TextUtils.isEmpty(intent.getStringExtra(Intent.EXTRA_EMAIL)))) { + // This is really more like a SENDTO intent because a destination is supplied. + // It's coming through the SEND intent because that's the intent that is used + // when invoking the chooser with Intent.createChooser(). + final Intent convIntent = UIIntents.get().getLaunchConversationActivityIntent(this); + // Copy the important items from the original intent to the new intent. + convIntent.putExtras(intent); + convIntent.setAction(Intent.ACTION_SENDTO); + convIntent.setDataAndType(intent.getData(), intent.getType()); + // We have to fire off the intent and finish before trying to show the fragment, + // otherwise we get some flashing. + startActivity(convIntent); + finish(); + return; + } + new ShareIntentFragment().show(getFragmentManager(), "ShareIntentFragment"); + } + + @Override + public void onAttachFragment(final Fragment fragment) { + final Intent intent = getIntent(); + final String action = intent.getAction(); + if (Intent.ACTION_SEND.equals(action)) { + final Uri contentUri = (Uri) intent.getParcelableExtra(Intent.EXTRA_STREAM); + final String contentType = extractContentType(contentUri, intent.getType()); + if (LogUtil.isLoggable(LogUtil.BUGLE_TAG, LogUtil.DEBUG)) { + LogUtil.d(LogUtil.BUGLE_TAG, String.format( + "onAttachFragment: contentUri=%s, intent.getType()=%s, inferredType=%s", + contentUri, intent.getType(), contentType)); + } + if (ContentType.TEXT_PLAIN.equals(contentType)) { + final String sharedText = intent.getStringExtra(Intent.EXTRA_TEXT); + if (sharedText != null) { + mDraftMessage = MessageData.createSharedMessage(sharedText); + } else { + mDraftMessage = null; + } + } else if (ContentType.isImageType(contentType) || + ContentType.isVCardType(contentType) || + ContentType.isAudioType(contentType) || + ContentType.isVideoType(contentType)) { + if (contentUri != null) { + mDraftMessage = MessageData.createSharedMessage(null); + addSharedImagePartToDraft(contentType, contentUri); + } else { + mDraftMessage = null; + } + } else { + // Unsupported content type. + Assert.fail("Unsupported shared content type for " + contentUri + ": " + contentType + + " (" + intent.getType() + ")"); + } + } else if (Intent.ACTION_SEND_MULTIPLE.equals(action)) { + final String contentType = intent.getType(); + if (ContentType.isImageType(contentType)) { + // Handle sharing multiple images. + final ArrayList<Uri> imageUris = intent.getParcelableArrayListExtra( + Intent.EXTRA_STREAM); + if (imageUris != null && imageUris.size() > 0) { + mDraftMessage = MessageData.createSharedMessage(null); + for (final Uri imageUri : imageUris) { + final String actualContentType = extractContentType(imageUri, contentType); + addSharedImagePartToDraft(actualContentType, imageUri); + } + } else { + mDraftMessage = null; + } + } else { + // Unsupported content type. + Assert.fail("Unsupported shared content type: " + contentType); + } + } else { + // Unsupported action. + Assert.fail("Unsupported action type for sharing: " + action); + } + } + + private static String extractContentType(final Uri uri, final String contentType) { + if (uri == null) { + return contentType; + } + // First try looking at file extension. This is less reliable in some ways but it's + // recommended by + // https://developer.android.com/training/secure-file-sharing/retrieve-info.html + // Some implementations of MediaMetadataRetriever get things horribly + // wrong for common formats such as jpeg (reports as video/ffmpeg) + final ContentResolver resolver = Factory.get().getApplicationContext().getContentResolver(); + final String typeFromExtension = resolver.getType(uri); + if (typeFromExtension != null) { + return typeFromExtension; + } + final MediaMetadataRetrieverWrapper retriever = new MediaMetadataRetrieverWrapper(); + try { + retriever.setDataSource(uri); + final String extractedType = retriever.extractMetadata( + MediaMetadataRetriever.METADATA_KEY_MIMETYPE); + if (extractedType != null) { + return extractedType; + } + } catch (final IOException e) { + LogUtil.i(LogUtil.BUGLE_TAG, "Could not determine type of " + uri, e); + } finally { + retriever.release(); + } + return contentType; + } + + private void addSharedImagePartToDraft(final String contentType, final Uri imageUri) { + mDraftMessage.addPart(PendingAttachmentData.createPendingAttachmentData(contentType, + imageUri)); + } + + @Override + public void onConversationClick(final ConversationListItemData conversationListItemData) { + UIIntents.get().launchConversationActivity( + this, conversationListItemData.getConversationId(), mDraftMessage); + finish(); + } + + @Override + public void onCreateConversationClick() { + UIIntents.get().launchCreateNewConversationActivity(this, mDraftMessage); + finish(); + } +} diff --git a/src/com/android/messaging/ui/conversationlist/ShareIntentAdapter.java b/src/com/android/messaging/ui/conversationlist/ShareIntentAdapter.java new file mode 100644 index 0000000..e894145 --- /dev/null +++ b/src/com/android/messaging/ui/conversationlist/ShareIntentAdapter.java @@ -0,0 +1,138 @@ +/* + * 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.ui.conversationlist; + +import android.content.Context; +import android.content.Intent; +import android.database.Cursor; +import android.net.Uri; +import android.support.v7.widget.RecyclerView; +import android.view.LayoutInflater; +import android.view.ViewGroup; + +import com.android.messaging.R; +import com.android.messaging.datamodel.data.ConversationListItemData; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.datamodel.data.PersonItemData; +import com.android.messaging.ui.CursorRecyclerAdapter; +import com.android.messaging.ui.PersonItemView; +import com.android.messaging.ui.PersonItemView.PersonItemViewListener; +import com.android.messaging.util.PhoneUtils; + +/** + * Turn conversation rows into PeopleItemViews + */ +public class ShareIntentAdapter + extends CursorRecyclerAdapter<ShareIntentAdapter.ShareIntentViewHolder> { + + public interface HostInterface { + void onConversationClicked(final ConversationListItemData conversationListItemData); + } + + private final HostInterface mHostInterface; + + public ShareIntentAdapter(final Context context, final Cursor cursor, + final HostInterface hostInterface) { + super(context, cursor, 0); + mHostInterface = hostInterface; + setHasStableIds(true); + } + + @Override + public void bindViewHolder(final ShareIntentViewHolder holder, final Context context, + final Cursor cursor) { + holder.bind(cursor); + } + + @Override + public ShareIntentViewHolder createViewHolder(final Context context, + final ViewGroup parent, final int viewType) { + final PersonItemView itemView = (PersonItemView) LayoutInflater.from(context).inflate( + R.layout.people_list_item_view, null); + return new ShareIntentViewHolder(itemView); + } + + /** + * Holds a PersonItemView and keeps it synced with a ConversationListItemData. + */ + public class ShareIntentViewHolder extends RecyclerView.ViewHolder implements + PersonItemView.PersonItemViewListener { + private final ConversationListItemData mData = new ConversationListItemData(); + private final PersonItemData mItemData = new PersonItemData() { + @Override + public Uri getAvatarUri() { + return mData.getIcon() == null ? null : Uri.parse(mData.getIcon()); + } + + @Override + public String getDisplayName() { + return mData.getName(); + } + + @Override + public String getDetails() { + final String conversationName = mData.getName(); + final String conversationPhone = PhoneUtils.getDefault().formatForDisplay( + mData.getOtherParticipantNormalizedDestination()); + if (conversationPhone == null || conversationPhone.equals(conversationName)) { + return null; + } + return conversationPhone; + } + + @Override + public Intent getClickIntent() { + return null; + } + + @Override + public long getContactId() { + return ParticipantData.PARTICIPANT_CONTACT_ID_NOT_RESOLVED; + } + + @Override + public String getLookupKey() { + return null; + } + + @Override + public String getNormalizedDestination() { + return null; + } + }; + + public ShareIntentViewHolder(final PersonItemView itemView) { + super(itemView); + itemView.setListener(this); + } + + public void bind(Cursor cursor) { + mData.bind(cursor); + ((PersonItemView) itemView).bind(mItemData); + } + + @Override + public void onPersonClicked(PersonItemData data) { + mHostInterface.onConversationClicked(mData); + } + + @Override + public boolean onPersonLongClicked(PersonItemData data) { + return false; + } + } +} diff --git a/src/com/android/messaging/ui/conversationlist/ShareIntentFragment.java b/src/com/android/messaging/ui/conversationlist/ShareIntentFragment.java new file mode 100644 index 0000000..bc549ea --- /dev/null +++ b/src/com/android/messaging/ui/conversationlist/ShareIntentFragment.java @@ -0,0 +1,163 @@ +/* + * 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.ui.conversationlist; + +import android.app.Activity; +import android.app.AlertDialog; +import android.app.AlertDialog.Builder; +import android.app.Dialog; +import android.app.DialogFragment; +import android.content.DialogInterface; +import android.content.DialogInterface.OnClickListener; +import android.database.Cursor; +import android.os.Bundle; +import android.support.v7.widget.LinearLayoutManager; +import android.support.v7.widget.RecyclerView; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import com.android.messaging.R; +import com.android.messaging.datamodel.binding.Binding; +import com.android.messaging.datamodel.binding.BindingBase; +import com.android.messaging.datamodel.data.ConversationListData; +import com.android.messaging.datamodel.data.ConversationListItemData; +import com.android.messaging.datamodel.data.ConversationListData.ConversationListDataListener; +import com.android.messaging.ui.ListEmptyView; +import com.android.messaging.datamodel.DataModel; + +/** + * Allow user to pick conversation to which an incoming attachment will be shared. + */ +public class ShareIntentFragment extends DialogFragment implements ConversationListDataListener, + ShareIntentAdapter.HostInterface { + public static final String HIDE_NEW_CONVERSATION_BUTTON_KEY = "hide_conv_button_key"; + + public interface HostInterface { + public void onConversationClick(final ConversationListItemData conversationListItemData); + public void onCreateConversationClick(); + } + + private final Binding<ConversationListData> mListBinding = BindingBase.createBinding(this); + private RecyclerView mRecyclerView; + private ListEmptyView mEmptyListMessageView; + private ShareIntentAdapter mAdapter; + private HostInterface mHost; + private boolean mDismissed; + + /** + * {@inheritDoc} from Fragment + */ + @Override + public Dialog onCreateDialog(final Bundle bundle) { + final Activity activity = getActivity(); + final LayoutInflater inflater = activity.getLayoutInflater(); + View view = inflater.inflate(R.layout.share_intent_conversation_list_view, null); + mEmptyListMessageView = (ListEmptyView) view.findViewById(R.id.no_conversations_view); + mEmptyListMessageView.setImageHint(R.drawable.ic_oobe_conv_list); + // The default behavior for default layout param generation by LinearLayoutManager is to + // provide width and height of WRAP_CONTENT, but this is not desirable for + // ShareIntentFragment; the view in each row should be a width of MATCH_PARENT so that + // the entire row is tappable. + final LinearLayoutManager manager = new LinearLayoutManager(activity) { + @Override + public RecyclerView.LayoutParams generateDefaultLayoutParams() { + return new RecyclerView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT); + } + }; + mListBinding.getData().init(getLoaderManager(), mListBinding); + mAdapter = new ShareIntentAdapter(activity, null, this); + mRecyclerView = (RecyclerView) view.findViewById(android.R.id.list); + mRecyclerView.setLayoutManager(manager); + mRecyclerView.setHasFixedSize(true); + mRecyclerView.setAdapter(mAdapter); + final Builder dialogBuilder = new AlertDialog.Builder(activity) + .setView(view) + .setTitle(R.string.share_intent_activity_label); + + final Bundle arguments = getArguments(); + if (arguments == null || !arguments.getBoolean(HIDE_NEW_CONVERSATION_BUTTON_KEY)) { + dialogBuilder.setPositiveButton(R.string.share_new_message, new OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + mDismissed = true; + mHost.onCreateConversationClick(); + } + }); + } + return dialogBuilder.setNegativeButton(R.string.share_cancel, null) + .create(); + } + + @Override + public void onDismiss(DialogInterface dialog) { + if (!mDismissed) { + final Activity activity = getActivity(); + if (activity != null) { + activity.finish(); + } + } + } + + /** + * {@inheritDoc} from Fragment + */ + @Override + public void onDestroy() { + super.onDestroy(); + mListBinding.unbind(); + } + + @Override + public void onAttach(final Activity activity) { + super.onAttach(activity); + if (activity instanceof HostInterface) { + mHost = (HostInterface) activity; + } + mListBinding.bind(DataModel.get().createConversationListData(activity, this, false)); + } + + @Override + public void onConversationListCursorUpdated(final ConversationListData data, + final Cursor cursor) { + mListBinding.ensureBound(data); + mAdapter.swapCursor(cursor); + updateEmptyListUi(cursor == null || cursor.getCount() == 0); + } + + /** + * {@inheritDoc} from SharIntentItemView.HostInterface + */ + @Override + public void onConversationClicked(final ConversationListItemData conversationListItemData) { + mHost.onConversationClick(conversationListItemData); + } + + // Show and hide empty list UI as needed with appropriate text based on view specifics + private void updateEmptyListUi(final boolean isEmpty) { + if (isEmpty) { + mEmptyListMessageView.setTextHint(R.string.conversation_list_empty_text); + mEmptyListMessageView.setVisibility(View.VISIBLE); + } else { + mEmptyListMessageView.setVisibility(View.GONE); + } + } + + @Override + public void setBlockedParticipantsAvailable(boolean blockedAvailable) { + } +} diff --git a/src/com/android/messaging/ui/conversationsettings/CopyContactDetailDialog.java b/src/com/android/messaging/ui/conversationsettings/CopyContactDetailDialog.java new file mode 100644 index 0000000..d727001 --- /dev/null +++ b/src/com/android/messaging/ui/conversationsettings/CopyContactDetailDialog.java @@ -0,0 +1,66 @@ +/* + * 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.ui.conversationsettings; + +import android.app.AlertDialog; +import android.content.ClipData; +import android.content.ClipboardManager; +import android.content.Context; +import android.content.DialogInterface; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.TextView; + +import com.android.messaging.R; +import com.android.messaging.util.AccessibilityUtil; + +public class CopyContactDetailDialog implements DialogInterface.OnClickListener { + + private final Context mContext; + private final String mContactDetail; + + public CopyContactDetailDialog(final Context context, final String contactDetail) { + mContext = context; + mContactDetail = contactDetail; + } + + public void show() { + new AlertDialog.Builder(mContext) + .setView(createBodyView()) + .setTitle(R.string.copy_to_clipboard_dialog_title) + .setPositiveButton(R.string.copy_to_clipboard, this) + .show(); + } + + @Override + public void onClick(final DialogInterface dialog, final int which) { + final ClipboardManager clipboard = + (ClipboardManager) mContext.getSystemService(Context.CLIPBOARD_SERVICE); + clipboard.setPrimaryClip(ClipData.newPlainText(null /* label */, mContactDetail)); + } + + private View createBodyView() { + LayoutInflater inflater = (LayoutInflater) mContext + .getSystemService(Context.LAYOUT_INFLATER_SERVICE); + TextView textView = (TextView) inflater.inflate(R.layout.copy_contact_dialog_view, null, + false); + textView.setText(mContactDetail); + final String vocalizedDisplayName = AccessibilityUtil.getVocalizedPhoneNumber( + mContext.getResources(), mContactDetail); + textView.setContentDescription(vocalizedDisplayName); + return textView; + } +} diff --git a/src/com/android/messaging/ui/conversationsettings/PeopleAndOptionsActivity.java b/src/com/android/messaging/ui/conversationsettings/PeopleAndOptionsActivity.java new file mode 100644 index 0000000..f017328 --- /dev/null +++ b/src/com/android/messaging/ui/conversationsettings/PeopleAndOptionsActivity.java @@ -0,0 +1,66 @@ +/* + * 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.ui.conversationsettings; + +import android.app.Fragment; +import android.os.Bundle; +import android.view.MenuItem; + +import com.android.messaging.R; +import com.android.messaging.ui.BugleActionBarActivity; +import com.android.messaging.ui.UIIntents; +import com.android.messaging.util.Assert; + +/** + * Shows a list of participants in a conversation. + */ +public class PeopleAndOptionsActivity extends BugleActionBarActivity { + + @Override + protected void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.people_and_options_activity); + + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + } + + @Override + public void onAttachFragment(final Fragment fragment) { + if (fragment instanceof PeopleAndOptionsFragment) { + final String conversationId = + getIntent().getStringExtra(UIIntents.UI_INTENT_EXTRA_CONVERSATION_ID); + Assert.notNull(conversationId); + final PeopleAndOptionsFragment peopleAndOptionsFragment = + (PeopleAndOptionsFragment) fragment; + peopleAndOptionsFragment.setConversationId(conversationId); + } + } + + @Override + public boolean onOptionsItemSelected(final MenuItem item) { + switch (item.getItemId()) { + case android.R.id.home: + // Treat the home press as back press so that when we go back to + // ConversationActivity, it doesn't lose its original intent (conversation id etc.) + onBackPressed(); + return true; + + default: + return super.onOptionsItemSelected(item); + } + } +} diff --git a/src/com/android/messaging/ui/conversationsettings/PeopleAndOptionsFragment.java b/src/com/android/messaging/ui/conversationsettings/PeopleAndOptionsFragment.java new file mode 100644 index 0000000..b86d952 --- /dev/null +++ b/src/com/android/messaging/ui/conversationsettings/PeopleAndOptionsFragment.java @@ -0,0 +1,329 @@ +/* + * 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.ui.conversationsettings; + +import android.app.Activity; +import android.app.AlertDialog; +import android.app.Fragment; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.content.res.Resources; +import android.database.Cursor; +import android.media.RingtoneManager; +import android.os.Bundle; +import android.os.Parcelable; +import android.provider.Settings; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ArrayAdapter; +import android.widget.BaseAdapter; +import android.widget.ListView; +import android.widget.TextView; + +import com.android.messaging.R; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.binding.Binding; +import com.android.messaging.datamodel.binding.BindingBase; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.datamodel.data.ParticipantListItemData; +import com.android.messaging.datamodel.data.PeopleAndOptionsData; +import com.android.messaging.datamodel.data.PeopleAndOptionsData.PeopleAndOptionsDataListener; +import com.android.messaging.datamodel.data.PeopleOptionsItemData; +import com.android.messaging.datamodel.data.PersonItemData; +import com.android.messaging.ui.CompositeAdapter; +import com.android.messaging.ui.PersonItemView; +import com.android.messaging.ui.UIIntents; +import com.android.messaging.ui.conversation.ConversationActivity; +import com.android.messaging.util.Assert; + +import java.util.ArrayList; +import java.util.List; + +/** + * Shows a list of participants of a conversation and displays options. + */ +public class PeopleAndOptionsFragment extends Fragment + implements PeopleAndOptionsDataListener, PeopleOptionsItemView.HostInterface { + private ListView mListView; + private OptionsListAdapter mOptionsListAdapter; + private PeopleListAdapter mPeopleListAdapter; + private final Binding<PeopleAndOptionsData> mBinding = + BindingBase.createBinding(this); + + private static final int REQUEST_CODE_RINGTONE_PICKER = 1000; + + @Override + public void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + mBinding.getData().init(getLoaderManager(), mBinding); + } + + @Override + public View onCreateView(final LayoutInflater inflater, final ViewGroup container, + final Bundle savedInstanceState) { + final View view = inflater.inflate(R.layout.people_and_options_fragment, container, false); + mListView = (ListView) view.findViewById(android.R.id.list); + mPeopleListAdapter = new PeopleListAdapter(getActivity()); + mOptionsListAdapter = new OptionsListAdapter(); + final CompositeAdapter compositeAdapter = new CompositeAdapter(getActivity()); + compositeAdapter.addPartition(new PeopleAndOptionsPartition(mOptionsListAdapter, + R.string.general_settings_title, false)); + compositeAdapter.addPartition(new PeopleAndOptionsPartition(mPeopleListAdapter, + R.string.participant_list_title, true)); + mListView.setAdapter(compositeAdapter); + return view; + } + + @Override + public void onActivityResult(final int requestCode, final int resultCode, final Intent data) { + super.onActivityResult(requestCode, resultCode, data); + if (resultCode == Activity.RESULT_OK && requestCode == REQUEST_CODE_RINGTONE_PICKER) { + final Parcelable pick = data.getParcelableExtra( + RingtoneManager.EXTRA_RINGTONE_PICKED_URI); + final String pickedUri = pick == null ? "" : pick.toString(); + mBinding.getData().setConversationNotificationSound(mBinding, pickedUri); + } + } + + @Override + public void onDestroy() { + super.onDestroy(); + mBinding.unbind(); + } + + public void setConversationId(final String conversationId) { + Assert.isTrue(getView() == null); + Assert.notNull(conversationId); + mBinding.bind(DataModel.get().createPeopleAndOptionsData(conversationId, getActivity(), + this)); + } + + @Override + public void onOptionsCursorUpdated(final PeopleAndOptionsData data, final Cursor cursor) { + Assert.isTrue(cursor == null || cursor.getCount() == 1); + mBinding.ensureBound(data); + mOptionsListAdapter.swapCursor(cursor); + } + + @Override + public void onParticipantsListLoaded(final PeopleAndOptionsData data, + final List<ParticipantData> participants) { + mBinding.ensureBound(data); + mPeopleListAdapter.updateParticipants(participants); + final ParticipantData otherParticipant = participants.size() == 1 ? + participants.get(0) : null; + mOptionsListAdapter.setOtherParticipant(otherParticipant); + } + + @Override + public void onOptionsItemViewClicked(final PeopleOptionsItemData item, + final boolean isChecked) { + switch (item.getItemId()) { + case PeopleOptionsItemData.SETTING_NOTIFICATION_ENABLED: + mBinding.getData().enableConversationNotifications(mBinding, isChecked); + break; + + case PeopleOptionsItemData.SETTING_NOTIFICATION_SOUND_URI: + final Intent ringtonePickerIntent = UIIntents.get().getRingtonePickerIntent( + getString(R.string.notification_sound_pref_title), + item.getRingtoneUri(), Settings.System.DEFAULT_NOTIFICATION_URI, + RingtoneManager.TYPE_NOTIFICATION); + startActivityForResult(ringtonePickerIntent, REQUEST_CODE_RINGTONE_PICKER); + break; + + case PeopleOptionsItemData.SETTING_NOTIFICATION_VIBRATION: + mBinding.getData().enableConversationNotificationVibration(mBinding, + isChecked); + break; + + case PeopleOptionsItemData.SETTING_BLOCKED: + if (item.getOtherParticipant().isBlocked()) { + mBinding.getData().setDestinationBlocked(mBinding, false); + break; + } + final Resources res = getResources(); + final Activity activity = getActivity(); + new AlertDialog.Builder(activity) + .setTitle(res.getString(R.string.block_confirmation_title, + item.getOtherParticipant().getDisplayDestination())) + .setMessage(res.getString(R.string.block_confirmation_message)) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(android.R.string.ok, + new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface arg0, int arg1) { + mBinding.getData().setDestinationBlocked(mBinding, true); + activity.setResult(ConversationActivity.FINISH_RESULT_CODE); + activity.finish(); + } + }) + .create() + .show(); + break; + } + } + + /** + * A simple adapter that takes a conversation metadata cursor and binds + * PeopleAndOptionsItemViews to individual COLUMNS of the first cursor record. (Note + * that this is not a CursorAdapter because it treats individual columns of the cursor as + * separate options to display for the conversation, e.g. notification settings). + */ + private class OptionsListAdapter extends BaseAdapter { + private Cursor mOptionsCursor; + private ParticipantData mOtherParticipantData; + + public Cursor swapCursor(final Cursor newCursor) { + final Cursor oldCursor = mOptionsCursor; + if (newCursor != oldCursor) { + mOptionsCursor = newCursor; + notifyDataSetChanged(); + } + return oldCursor; + } + + public void setOtherParticipant(final ParticipantData participantData) { + if (mOtherParticipantData != participantData) { + mOtherParticipantData = participantData; + notifyDataSetChanged(); + } + } + + @Override + public int getCount() { + int count = PeopleOptionsItemData.SETTINGS_COUNT; + if (mOtherParticipantData == null) { + count--; + } + return mOptionsCursor == null ? 0 : count; + } + + @Override + public Object getItem(final int position) { + return null; + } + + @Override + public long getItemId(final int position) { + return 0; + } + + @Override + public View getView(final int position, final View convertView, final ViewGroup parent) { + final PeopleOptionsItemView itemView; + if (convertView != null && convertView instanceof PeopleOptionsItemView) { + itemView = (PeopleOptionsItemView) convertView; + } else { + final LayoutInflater inflater = (LayoutInflater) getActivity() + .getSystemService(Context.LAYOUT_INFLATER_SERVICE); + itemView = (PeopleOptionsItemView) + inflater.inflate(R.layout.people_options_item_view, parent, false); + } + mOptionsCursor.moveToFirst(); + itemView.bind(mOptionsCursor, position, mOtherParticipantData, + PeopleAndOptionsFragment.this); + return itemView; + } + } + + /** + * An adapter that takes a list of ParticipantData and displays them as a list of + * ParticipantListItemViews. + */ + private class PeopleListAdapter extends ArrayAdapter<ParticipantData> { + public PeopleListAdapter(final Context context) { + super(context, R.layout.people_list_item_view, new ArrayList<ParticipantData>()); + } + + public void updateParticipants(final List<ParticipantData> newList) { + clear(); + addAll(newList); + notifyDataSetChanged(); + } + + @Override + public View getView(final int position, final View convertView, final ViewGroup parent) { + PersonItemView itemView; + final ParticipantData item = getItem(position); + if (convertView != null && convertView instanceof PersonItemView) { + itemView = (PersonItemView) convertView; + } else { + final LayoutInflater inflater = (LayoutInflater) getContext() + .getSystemService(Context.LAYOUT_INFLATER_SERVICE); + itemView = (PersonItemView) inflater.inflate(R.layout.people_list_item_view, parent, + false); + } + final ParticipantListItemData itemData = + DataModel.get().createParticipantListItemData(item); + itemView.bind(itemData); + + // Any click on the row should have the same effect as clicking the avatar icon + final PersonItemView itemViewClosure = itemView; + itemView.setListener(new PersonItemView.PersonItemViewListener() { + @Override + public void onPersonClicked(final PersonItemData data) { + itemViewClosure.performClickOnAvatar(); + } + + @Override + public boolean onPersonLongClicked(final PersonItemData data) { + if (mBinding.isBound()) { + final CopyContactDetailDialog dialog = new CopyContactDetailDialog( + getContext(), data.getDetails()); + dialog.show(); + return true; + } + return false; + } + }); + return itemView; + } + } + + /** + * Represents a partition/section in the People & Options list (e.g. "general options" and + * "people in this conversation" sections). + */ + private class PeopleAndOptionsPartition extends CompositeAdapter.Partition { + private final int mHeaderResId; + private final boolean mNeedDivider; + + public PeopleAndOptionsPartition(final BaseAdapter adapter, final int headerResId, + final boolean needDivider) { + super(true /* showIfEmpty */, true /* hasHeader */, adapter); + mHeaderResId = headerResId; + mNeedDivider = needDivider; + } + + @Override + public View getHeaderView(final View convertView, final ViewGroup parentView) { + View view = null; + if (convertView != null && convertView.getId() == R.id.people_and_options_header) { + view = convertView; + } else { + view = LayoutInflater.from(getActivity()).inflate( + R.layout.people_and_options_section_header, parentView, false); + } + final TextView text = (TextView) view.findViewById(R.id.header_text); + final View divider = view.findViewById(R.id.divider); + text.setText(mHeaderResId); + divider.setVisibility(mNeedDivider ? View.VISIBLE : View.GONE); + return view; + } + } +} diff --git a/src/com/android/messaging/ui/conversationsettings/PeopleOptionsItemView.java b/src/com/android/messaging/ui/conversationsettings/PeopleOptionsItemView.java new file mode 100644 index 0000000..42ecfeb --- /dev/null +++ b/src/com/android/messaging/ui/conversationsettings/PeopleOptionsItemView.java @@ -0,0 +1,99 @@ +/* + * 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.ui.conversationsettings; + +import android.content.Context; +import android.database.Cursor; +import android.support.v7.widget.SwitchCompat; +import android.text.TextUtils; +import android.util.AttributeSet; +import android.view.View; +import android.widget.LinearLayout; +import android.widget.TextView; + +import com.android.messaging.R; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.datamodel.data.PeopleOptionsItemData; +import com.android.messaging.util.Assert; + +/** + * The view for a single entry in the options section of people & options activity. + */ +public class PeopleOptionsItemView extends LinearLayout { + /** + * Implemented by the host of this view that handles options click event. + */ + public interface HostInterface { + void onOptionsItemViewClicked(PeopleOptionsItemData item, boolean isChecked); + } + + private TextView mTitle; + private TextView mSubtitle; + private SwitchCompat mSwitch; + private final PeopleOptionsItemData mData; + private HostInterface mHostInterface; + + public PeopleOptionsItemView(final Context context, final AttributeSet attrs) { + super(context, attrs); + mData = DataModel.get().createPeopleOptionsItemData(context); + } + + @Override + protected void onFinishInflate () { + mTitle = (TextView) findViewById(R.id.title); + mSubtitle = (TextView) findViewById(R.id.subtitle); + mSwitch = (SwitchCompat) findViewById(R.id.switch_button); + setOnClickListener(new OnClickListener() { + @Override + public void onClick(final View v) { + mHostInterface.onOptionsItemViewClicked(mData, !mData.getChecked()); + } + }); + } + + public void bind(final Cursor cursor, final int columnIndex, ParticipantData otherParticipant, + final HostInterface hostInterface) { + Assert.isTrue(columnIndex < PeopleOptionsItemData.SETTINGS_COUNT && columnIndex >= 0); + mData.bind(cursor, otherParticipant, columnIndex); + mHostInterface = hostInterface; + + mTitle.setText(mData.getTitle()); + final String subtitle = mData.getSubtitle(); + if (TextUtils.isEmpty(subtitle)) { + mSubtitle.setVisibility(GONE); + } else { + mSubtitle.setVisibility(VISIBLE); + mSubtitle.setText(subtitle); + } + + if (mData.getCheckable()) { + mSwitch.setVisibility(VISIBLE); + mSwitch.setChecked(mData.getChecked()); + } else { + mSwitch.setVisibility(GONE); + } + + final boolean enabled = mData.getEnabled(); + if (enabled != isEnabled()) { + mTitle.setEnabled(enabled); + mSubtitle.setEnabled(enabled); + mSwitch.setEnabled(enabled); + setAlpha(enabled ? 1.0f : 0.5f); + setEnabled(enabled); + } + } +} diff --git a/src/com/android/messaging/ui/debug/DebugMmsConfigActivity.java b/src/com/android/messaging/ui/debug/DebugMmsConfigActivity.java new file mode 100644 index 0000000..485dcf7 --- /dev/null +++ b/src/com/android/messaging/ui/debug/DebugMmsConfigActivity.java @@ -0,0 +1,34 @@ +/* + * 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.ui.debug; + +import android.os.Bundle; + +import com.android.messaging.R; +import com.android.messaging.ui.BaseBugleActivity; + +/** + * Show list of all MmsConfig key/value pairs and allow editing. + */ +public class DebugMmsConfigActivity extends BaseBugleActivity { + + @Override + protected void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.debug_mmsconfig_activity); + } +} diff --git a/src/com/android/messaging/ui/debug/DebugMmsConfigFragment.java b/src/com/android/messaging/ui/debug/DebugMmsConfigFragment.java new file mode 100644 index 0000000..7c54db5 --- /dev/null +++ b/src/com/android/messaging/ui/debug/DebugMmsConfigFragment.java @@ -0,0 +1,147 @@ +/* + * 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.ui.debug; + +import android.app.Fragment; +import android.content.Context; +import android.os.Bundle; +import android.telephony.SubscriptionInfo; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.AdapterView; +import android.widget.AdapterView.OnItemSelectedListener; +import android.widget.ArrayAdapter; +import android.widget.BaseAdapter; +import android.widget.ListView; +import android.widget.Spinner; +import android.widget.TextView; + +import com.android.messaging.R; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.sms.MmsConfig; +import com.android.messaging.ui.debug.DebugMmsConfigItemView.MmsConfigItemListener; +import com.android.messaging.util.OsUtil; +import com.android.messaging.util.PhoneUtils; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Show list of all MmsConfig key/value pairs and allow editing. + */ +public class DebugMmsConfigFragment extends Fragment { + @Override + public View onCreateView(final LayoutInflater inflater, final ViewGroup container, + final Bundle savedInstanceState) { + final View fragmentView = inflater.inflate(R.layout.mms_config_debug_fragment, container, + false); + final ListView listView = (ListView) fragmentView.findViewById(android.R.id.list); + final Spinner spinner = (Spinner) fragmentView.findViewById(R.id.sim_selector); + final Integer[] subIdArray = getActiveSubIds(); + ArrayAdapter<Integer> spinnerAdapter = new ArrayAdapter<Integer>(getActivity(), + android.R.layout.simple_spinner_item, subIdArray); + spinnerAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); + spinner.setAdapter(spinnerAdapter); + spinner.setOnItemSelectedListener(new OnItemSelectedListener() { + @Override + public void onItemSelected(AdapterView<?> parent, View view, int position, long id) { + listView.setAdapter(new MmsConfigAdapter(getActivity(), subIdArray[position])); + + final int[] mccmnc = PhoneUtils.get(subIdArray[position]).getMccMnc(); + // Set the title with the mcc/mnc + final TextView title = (TextView) fragmentView.findViewById(R.id.sim_title); + title.setText("(" + mccmnc[0] + "/" + mccmnc[1] + ") " + + getActivity().getString(R.string.debug_sub_id_spinner_text)); + } + + @Override + public void onNothingSelected(AdapterView<?> parent) { + } + }); + + return fragmentView; + } + + public static Integer[] getActiveSubIds() { + if (!OsUtil.isAtLeastL_MR1()) { + return new Integer[] { ParticipantData.DEFAULT_SELF_SUB_ID }; + } + final List<SubscriptionInfo> subRecords = + PhoneUtils.getDefault().toLMr1().getActiveSubscriptionInfoList(); + if (subRecords == null) { + return new Integer[0]; + } + final Integer[] retArray = new Integer[subRecords.size()]; + for (int i = 0; i < subRecords.size(); i++) { + retArray[i] = subRecords.get(i).getSubscriptionId(); + } + return retArray; + } + + private class MmsConfigAdapter extends BaseAdapter implements + DebugMmsConfigItemView.MmsConfigItemListener { + private final LayoutInflater mInflater; + private final List<String> mKeys; + private final MmsConfig mMmsConfig; + + public MmsConfigAdapter(Context context, int subId) { + mInflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE); + mMmsConfig = MmsConfig.get(subId); + mKeys = new ArrayList<>(mMmsConfig.keySet()); + Collections.sort(mKeys); + } + + @Override + public View getView(final int position, final View convertView, final ViewGroup parent) { + final DebugMmsConfigItemView view; + if (convertView != null && convertView instanceof DebugMmsConfigItemView) { + view = (DebugMmsConfigItemView) convertView; + } else { + view = (DebugMmsConfigItemView) mInflater.inflate( + R.layout.debug_mmsconfig_item_view, parent, false); + } + final String key = mKeys.get(position); + view.bind(key, + MmsConfig.getKeyType(key), + String.valueOf(mMmsConfig.getValue(key)), + this); + return view; + } + + @Override + public void onValueChanged(String key, String keyType, String value) { + mMmsConfig.update(key, value, keyType); + notifyDataSetChanged(); + } + + @Override + public int getCount() { + return mKeys.size(); + } + + @Override + public Object getItem(int position) { + return mKeys.get(position); + } + + @Override + public long getItemId(int position) { + return position; + } + } +} diff --git a/src/com/android/messaging/ui/debug/DebugMmsConfigItemView.java b/src/com/android/messaging/ui/debug/DebugMmsConfigItemView.java new file mode 100644 index 0000000..7b899c0 --- /dev/null +++ b/src/com/android/messaging/ui/debug/DebugMmsConfigItemView.java @@ -0,0 +1,134 @@ +/* + * 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.ui.debug; + +import android.app.AlertDialog; +import android.content.Context; +import android.content.DialogInterface; +import android.content.DialogInterface.OnShowListener; +import android.text.InputType; +import android.util.AttributeSet; +import android.view.View; +import android.view.View.OnClickListener; +import android.view.inputmethod.InputMethodManager; +import android.widget.CompoundButton; +import android.widget.CompoundButton.OnCheckedChangeListener; +import android.widget.EditText; +import android.widget.LinearLayout; +import android.widget.Switch; +import android.widget.TextView; + +import com.android.messaging.R; +import com.android.messaging.sms.MmsConfig; +import com.android.messaging.util.LogUtil; + +public class DebugMmsConfigItemView extends LinearLayout implements OnClickListener, + OnCheckedChangeListener, DialogInterface.OnClickListener { + + public interface MmsConfigItemListener { + void onValueChanged(String key, String keyType, String value); + } + + private TextView mTitle; + private TextView mTextValue; + private Switch mSwitch; + private String mKey; + private String mKeyType; + private MmsConfigItemListener mListener; + private EditText mEditText; + + public DebugMmsConfigItemView(Context context, AttributeSet attributeSet) { + super(context, attributeSet); + } + + @Override + protected void onFinishInflate () { + mTitle = (TextView) findViewById(R.id.title); + mTextValue = (TextView) findViewById(R.id.text_value); + mSwitch = (Switch) findViewById(R.id.switch_button); + setOnClickListener(this); + mSwitch.setOnCheckedChangeListener(this); + } + + public void bind(final String key, final String keyType, final String value, + final MmsConfigItemListener listener) { + mListener = listener; + mKey = key; + mKeyType = keyType; + mTitle.setText(key); + + switch (keyType) { + case MmsConfig.KEY_TYPE_BOOL: + mSwitch.setVisibility(View.VISIBLE); + mTextValue.setVisibility(View.GONE); + mSwitch.setChecked(Boolean.valueOf(value)); + break; + case MmsConfig.KEY_TYPE_STRING: + case MmsConfig.KEY_TYPE_INT: + mTextValue.setVisibility(View.VISIBLE); + mSwitch.setVisibility(View.GONE); + mTextValue.setText(value); + break; + default: + mTextValue.setVisibility(View.GONE); + mSwitch.setVisibility(View.GONE); + LogUtil.e(LogUtil.BUGLE_TAG, "Unexpected keytype: " + keyType); + break; + } + } + + @Override + public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { + mListener.onValueChanged(mKey, mKeyType, String.valueOf(isChecked)); + } + + @Override + public void onClick(View v) { + if (MmsConfig.KEY_TYPE_BOOL.equals(mKeyType)) { + return; + } + final Context context = getContext(); + mEditText = new EditText(context); + mEditText.setText(mTextValue.getText()); + mEditText.setFocusable(true); + if (MmsConfig.KEY_TYPE_INT.equals(mKeyType)) { + mEditText.setInputType(InputType.TYPE_CLASS_PHONE); + } else { + mEditText.setInputType(InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS); + } + final AlertDialog dialog = new AlertDialog.Builder(context) + .setTitle(mKey) + .setView(mEditText) + .setPositiveButton(android.R.string.ok, this) + .setNegativeButton(android.R.string.cancel, null) + .create(); + dialog.setOnShowListener(new OnShowListener() { + @Override + public void onShow(DialogInterface dialog) { + mEditText.requestFocus(); + mEditText.selectAll(); + ((InputMethodManager) context.getSystemService(Context.INPUT_METHOD_SERVICE)) + .toggleSoftInput(InputMethodManager.SHOW_IMPLICIT, 0); + } + }); + dialog.show(); + } + + @Override + public void onClick(DialogInterface dialog, int which) { + mListener.onValueChanged(mKey, mKeyType, mEditText.getText().toString()); + } +} diff --git a/src/com/android/messaging/ui/debug/DebugSmsMmsFromDumpFileDialogFragment.java b/src/com/android/messaging/ui/debug/DebugSmsMmsFromDumpFileDialogFragment.java new file mode 100644 index 0000000..1aa8be3 --- /dev/null +++ b/src/com/android/messaging/ui/debug/DebugSmsMmsFromDumpFileDialogFragment.java @@ -0,0 +1,171 @@ +/* + * 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.ui.debug; + +import android.app.AlertDialog; +import android.app.Dialog; +import android.app.DialogFragment; +import android.content.Context; +import android.content.Intent; +import android.content.res.Resources; +import android.net.Uri; +import android.os.Bundle; +import android.os.Environment; +import android.telephony.SmsMessage; +import android.view.LayoutInflater; +import android.view.View; +import android.view.View.OnClickListener; +import android.view.ViewGroup; +import android.widget.ArrayAdapter; +import android.widget.ListView; +import android.widget.TextView; + +import com.android.messaging.R; +import com.android.messaging.datamodel.action.ReceiveMmsMessageAction; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.receiver.SmsReceiver; +import com.android.messaging.sms.MmsUtils; +import com.android.messaging.util.DebugUtils; +import com.android.messaging.util.LogUtil; + +/** + * Class that displays UI for choosing SMS/MMS dump files for debugging + */ +public class DebugSmsMmsFromDumpFileDialogFragment extends DialogFragment { + public static final String APPLICATION_OCTET_STREAM = "application/octet-stream"; + public static final String KEY_DUMP_FILES = "dump_files"; + public static final String KEY_ACTION = "action"; + + public static final String ACTION_LOAD = "load"; + public static final String ACTION_EMAIL = "email"; + + private String[] mDumpFiles; + private String mAction; + + public static DebugSmsMmsFromDumpFileDialogFragment newInstance(final String[] dumpFiles, + final String action) { + final DebugSmsMmsFromDumpFileDialogFragment frag = + new DebugSmsMmsFromDumpFileDialogFragment(); + final Bundle args = new Bundle(); + args.putSerializable(KEY_DUMP_FILES, dumpFiles); + args.putString(KEY_ACTION, action); + frag.setArguments(args); + return frag; + } + + @Override + public Dialog onCreateDialog(final Bundle savedInstanceState) { + final Bundle args = getArguments(); + mDumpFiles = (String[]) args.getSerializable(KEY_DUMP_FILES); + mAction = args.getString(KEY_ACTION); + + final LayoutInflater inflater = getActivity().getLayoutInflater(); + final View layout = inflater.inflate( + R.layout.debug_sms_mms_from_dump_file_dialog, null/*root*/); + final ListView list = (ListView) layout.findViewById(R.id.dump_file_list); + list.setAdapter(new DumpFileListAdapter(getActivity(), mDumpFiles)); + final AlertDialog.Builder builder = new AlertDialog.Builder(getActivity()); + final Resources resources = getResources(); + if (ACTION_LOAD.equals(mAction)) { + builder.setTitle(resources.getString( + R.string.load_sms_mms_from_dump_file_dialog_title)); + } else if (ACTION_EMAIL.equals(mAction)) { + builder.setTitle(resources.getString( + R.string.email_sms_mms_from_dump_file_dialog_title)); + } + builder.setView(layout); + return builder.create(); + } + + private class DumpFileListAdapter extends ArrayAdapter<String> { + public DumpFileListAdapter(final Context context, final String[] dumpFiles) { + super(context, R.layout.sms_mms_dump_file_list_item, dumpFiles); + } + + @Override + public View getView(final int position, final View view, final ViewGroup parent) { + TextView actionItemView; + if (view == null || !(view instanceof TextView)) { + final LayoutInflater inflater = LayoutInflater.from(getContext()); + actionItemView = (TextView) inflater.inflate( + R.layout.sms_mms_dump_file_list_item, parent, false); + } else { + actionItemView = (TextView) view; + } + + final String file = getItem(position); + actionItemView.setText(file); + actionItemView.setOnClickListener(new OnClickListener() { + @Override + public void onClick(final View view) { + dismiss(); + if (ACTION_LOAD.equals(mAction)) { + receiveFromDumpFile(file); + } else if (ACTION_EMAIL.equals(mAction)) { + emailDumpFile(file); + } + } + }); + return actionItemView; + } + } + + /** + * Load MMS/SMS from the dump file + */ + private void receiveFromDumpFile(final String dumpFileName) { + if (dumpFileName.startsWith(MmsUtils.SMS_DUMP_PREFIX)) { + final SmsMessage[] messages = DebugUtils.retreiveSmsFromDumpFile(dumpFileName); + if (messages != null) { + SmsReceiver.deliverSmsMessages(getActivity(), ParticipantData.DEFAULT_SELF_SUB_ID, + 0, messages); + } else { + LogUtil.e(LogUtil.BUGLE_TAG, + "receiveFromDumpFile: invalid sms dump file " + dumpFileName); + } + } else if (dumpFileName.startsWith(MmsUtils.MMS_DUMP_PREFIX)) { + final byte[] data = MmsUtils.createDebugNotificationInd(dumpFileName); + if (data != null) { + final ReceiveMmsMessageAction action = new ReceiveMmsMessageAction( + ParticipantData.DEFAULT_SELF_SUB_ID, data); + action.start(); + } else { + LogUtil.e(LogUtil.BUGLE_TAG, + "receiveFromDumpFile: invalid mms dump file " + dumpFileName); + } + } else { + LogUtil.e(LogUtil.BUGLE_TAG, + "receiveFromDumpFile: invalid dump file name " + dumpFileName); + } + } + + /** + * Launch email app to send the dump file + */ + private void emailDumpFile(final String file) { + final Resources resources = getResources(); + final String fileLocation = "file://" + + Environment.getExternalStorageDirectory() + "/" + file; + final Intent sharingIntent = new Intent(Intent.ACTION_SEND); + sharingIntent.setType(APPLICATION_OCTET_STREAM); + sharingIntent.putExtra(Intent.EXTRA_STREAM, Uri.parse(fileLocation)); + sharingIntent.putExtra(Intent.EXTRA_SUBJECT, + resources.getString(R.string.email_sms_mms_dump_file_subject)); + getActivity().startActivity(Intent.createChooser(sharingIntent, + resources.getString(R.string.email_sms_mms_dump_file_chooser_title))); + } +} diff --git a/src/com/android/messaging/ui/mediapicker/AudioLevelSource.java b/src/com/android/messaging/ui/mediapicker/AudioLevelSource.java new file mode 100644 index 0000000..a211058 --- /dev/null +++ b/src/com/android/messaging/ui/mediapicker/AudioLevelSource.java @@ -0,0 +1,73 @@ +/* + * 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.ui.mediapicker; + +import com.google.common.base.Preconditions; + +import javax.annotation.concurrent.ThreadSafe; + +/** + * Keeps track of the speech level as last observed by the recognition + * engine as microphone data flows through it. Can be polled by the UI to + * animate its views. + */ +@ThreadSafe +public class AudioLevelSource { + private volatile int mSpeechLevel; + private volatile Listener mListener; + + public static final int LEVEL_UNKNOWN = -1; + + public interface Listener { + void onSpeechLevel(int speechLevel); + } + + public void setSpeechLevel(int speechLevel) { + Preconditions.checkArgument(speechLevel >= 0 && speechLevel <= 100 || + speechLevel == LEVEL_UNKNOWN); + mSpeechLevel = speechLevel; + maybeNotify(); + } + + public int getSpeechLevel() { + return mSpeechLevel; + } + + public void reset() { + setSpeechLevel(LEVEL_UNKNOWN); + } + + public boolean isValid() { + return mSpeechLevel > 0; + } + + private void maybeNotify() { + final Listener l = mListener; + if (l != null) { + l.onSpeechLevel(mSpeechLevel); + } + } + + public synchronized void setListener(Listener listener) { + mListener = listener; + } + + public synchronized void clearListener(Listener listener) { + if (mListener == listener) { + mListener = null; + } + } +} diff --git a/src/com/android/messaging/ui/mediapicker/AudioMediaChooser.java b/src/com/android/messaging/ui/mediapicker/AudioMediaChooser.java new file mode 100644 index 0000000..5d79293 --- /dev/null +++ b/src/com/android/messaging/ui/mediapicker/AudioMediaChooser.java @@ -0,0 +1,130 @@ +/* + * 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.ui.mediapicker; + +import android.Manifest; +import android.content.pm.PackageManager; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import com.android.messaging.R; +import com.android.messaging.datamodel.data.MessagePartData; +import com.android.messaging.util.OsUtil; + +/** + * Chooser which allows the user to record audio + */ +class AudioMediaChooser extends MediaChooser implements + AudioRecordView.HostInterface { + private View mEnabledView; + private View mMissingPermissionView; + + AudioMediaChooser(final MediaPicker mediaPicker) { + super(mediaPicker); + } + + @Override + public int getSupportedMediaTypes() { + return MediaPicker.MEDIA_TYPE_AUDIO; + } + + @Override + public int getIconResource() { + return R.drawable.ic_audio_light; + } + + @Override + public int getIconDescriptionResource() { + return R.string.mediapicker_audioChooserDescription; + } + + @Override + public void onAudioRecorded(final MessagePartData item) { + mMediaPicker.dispatchItemsSelected(item, true); + } + + @Override + public void setThemeColor(final int color) { + if (mView != null) { + ((AudioRecordView) mView).setThemeColor(color); + } + } + + @Override + protected View createView(final ViewGroup container) { + final LayoutInflater inflater = getLayoutInflater(); + final AudioRecordView view = (AudioRecordView) inflater.inflate( + R.layout.mediapicker_audio_chooser, + container /* root */, + false /* attachToRoot */); + view.setHostInterface(this); + view.setThemeColor(mMediaPicker.getConversationThemeColor()); + mEnabledView = view.findViewById(R.id.mediapicker_enabled); + mMissingPermissionView = view.findViewById(R.id.missing_permission_view); + return view; + } + + @Override + int getActionBarTitleResId() { + return R.string.mediapicker_audio_title; + } + + @Override + public boolean isHandlingTouch() { + // Whenever the user is in the process of recording audio, we want to allow the user + // to move the finger within the panel without interpreting that as dragging the media + // picker panel. + return ((AudioRecordView) mView).shouldHandleTouch(); + } + + @Override + public void stopTouchHandling() { + ((AudioRecordView) mView).stopTouchHandling(); + } + + @Override + public void onPause() { + super.onPause(); + if (mView != null) { + ((AudioRecordView) mView).onPause(); + } + } + + @Override + protected void setSelected(final boolean selected) { + super.setSelected(selected); + if (selected && !OsUtil.hasRecordAudioPermission()) { + requestRecordAudioPermission(); + } + } + + private void requestRecordAudioPermission() { + mMediaPicker.requestPermissions(new String[] { Manifest.permission.RECORD_AUDIO }, + MediaPicker.RECORD_AUDIO_PERMISSION_REQUEST_CODE); + } + + @Override + protected void onRequestPermissionsResult( + final int requestCode, final String permissions[], final int[] grantResults) { + if (requestCode == MediaPicker.RECORD_AUDIO_PERMISSION_REQUEST_CODE) { + final boolean permissionGranted = grantResults[0] == PackageManager.PERMISSION_GRANTED; + mEnabledView.setVisibility(permissionGranted ? View.VISIBLE : View.GONE); + mMissingPermissionView.setVisibility(permissionGranted ? View.GONE : View.VISIBLE); + } + } +} diff --git a/src/com/android/messaging/ui/mediapicker/AudioRecordView.java b/src/com/android/messaging/ui/mediapicker/AudioRecordView.java new file mode 100644 index 0000000..fba493f --- /dev/null +++ b/src/com/android/messaging/ui/mediapicker/AudioRecordView.java @@ -0,0 +1,351 @@ +/* + * 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.ui.mediapicker; + +import android.content.Context; +import android.graphics.Color; +import android.graphics.PorterDuff; +import android.graphics.Rect; +import android.graphics.Typeface; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.GradientDrawable; +import android.media.MediaRecorder; +import android.net.Uri; +import android.util.AttributeSet; +import android.view.MotionEvent; +import android.view.View; +import android.widget.FrameLayout; +import android.widget.ImageView; +import android.widget.TextView; + +import com.android.messaging.Factory; +import com.android.messaging.R; +import com.android.messaging.datamodel.data.DraftMessageData.DraftMessageSubscriptionDataProvider; +import com.android.messaging.datamodel.data.MediaPickerMessagePartData; +import com.android.messaging.datamodel.data.MessagePartData; +import com.android.messaging.sms.MmsConfig; +import com.android.messaging.util.Assert; +import com.android.messaging.util.ContentType; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.MediaUtil; +import com.android.messaging.util.MediaUtil.OnCompletionListener; +import com.android.messaging.util.SafeAsyncTask; +import com.android.messaging.util.ThreadUtil; +import com.android.messaging.util.UiUtils; +import com.google.common.annotations.VisibleForTesting; + +/** + * Hosts an audio recorder with tap and hold to record functionality. + */ +public class AudioRecordView extends FrameLayout implements + MediaRecorder.OnErrorListener, + MediaRecorder.OnInfoListener { + /** + * An interface that communicates with the hosted AudioRecordView. + */ + public interface HostInterface extends DraftMessageSubscriptionDataProvider { + void onAudioRecorded(final MessagePartData item); + } + + /** The initial state, the user may press and hold to start recording */ + private static final int MODE_IDLE = 1; + + /** The user has pressed the record button and we are playing the sound indicating the + * start of recording session. Don't record yet since we don't want the beeping sound + * to get into the recording. */ + private static final int MODE_STARTING = 2; + + /** When the user is actively recording */ + private static final int MODE_RECORDING = 3; + + /** When the user has finished recording, we need to record for some additional time. */ + private static final int MODE_STOPPING = 4; + + // Bug: 16020175: The framework's MediaRecorder would cut off the ending portion of the + // recorded audio by about half a second. To mitigate this issue, we continue the recording + // for some extra time before stopping it. + private static final int AUDIO_RECORD_ENDING_BUFFER_MILLIS = 500; + + /** + * The minimum duration of any recording. Below this threshold, it will be treated as if the + * user clicked the record button and inform the user to tap and hold to record. + */ + private static final int AUDIO_RECORD_MINIMUM_DURATION_MILLIS = 300; + + // For accessibility, the touchable record button is bigger than the record button visual. + private ImageView mRecordButtonVisual; + private View mRecordButton; + private SoundLevels mSoundLevels; + private TextView mHintTextView; + private PausableChronometer mTimerTextView; + private LevelTrackingMediaRecorder mMediaRecorder; + private long mAudioRecordStartTimeMillis; + + private int mCurrentMode = MODE_IDLE; + private HostInterface mHostInterface; + private int mThemeColor; + + public AudioRecordView(final Context context, final AttributeSet attrs) { + super(context, attrs); + mMediaRecorder = new LevelTrackingMediaRecorder(); + } + + public void setHostInterface(final HostInterface hostInterface) { + mHostInterface = hostInterface; + } + + @VisibleForTesting + public void testSetMediaRecorder(final LevelTrackingMediaRecorder recorder) { + mMediaRecorder = recorder; + } + + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + mSoundLevels = (SoundLevels) findViewById(R.id.sound_levels); + mRecordButtonVisual = (ImageView) findViewById(R.id.record_button_visual); + mRecordButton = findViewById(R.id.record_button); + mHintTextView = (TextView) findViewById(R.id.hint_text); + mTimerTextView = (PausableChronometer) findViewById(R.id.timer_text); + mSoundLevels.setLevelSource(mMediaRecorder.getLevelSource()); + mRecordButton.setOnTouchListener(new OnTouchListener() { + @Override + public boolean onTouch(final View v, final MotionEvent event) { + final int action = event.getActionMasked(); + switch (action) { + case MotionEvent.ACTION_DOWN: + onRecordButtonTouchDown(); + + // Don't let the record button handle the down event to let it fall through + // so that we can handle it for the entire panel in onTouchEvent(). This is + // done so that: 1) the user taps on the record button to start recording + // 2) the entire panel owns the touch event so we'd keep recording even + // if the user moves outside the button region. + return false; + } + return false; + } + }); + } + + @Override + public boolean onTouchEvent(final MotionEvent event) { + final int action = event.getActionMasked(); + switch (action) { + case MotionEvent.ACTION_DOWN: + return shouldHandleTouch(); + + case MotionEvent.ACTION_MOVE: + return true; + + case MotionEvent.ACTION_UP: + case MotionEvent.ACTION_CANCEL: + return onRecordButtonTouchUp(); + } + return super.onTouchEvent(event); + } + + public void onPause() { + // The conversation draft cannot take any updates when it's paused. Therefore, forcibly + // stop recording on pause. + stopRecording(); + } + + @Override + protected void onDetachedFromWindow() { + super.onDetachedFromWindow(); + stopRecording(); + } + + private boolean isRecording() { + return mMediaRecorder.isRecording() && mCurrentMode == MODE_RECORDING; + } + + public boolean shouldHandleTouch() { + return mCurrentMode != MODE_IDLE; + } + + public void stopTouchHandling() { + setMode(MODE_IDLE); + stopRecording(); + } + + private void setMode(final int mode) { + if (mCurrentMode != mode) { + mCurrentMode = mode; + updateVisualState(); + } + } + + private void updateVisualState() { + switch (mCurrentMode) { + case MODE_IDLE: + mHintTextView.setVisibility(VISIBLE); + mHintTextView.setTypeface(null, Typeface.NORMAL); + mTimerTextView.setVisibility(GONE); + mSoundLevels.setEnabled(false); + mTimerTextView.stop(); + break; + + case MODE_RECORDING: + case MODE_STOPPING: + mHintTextView.setVisibility(GONE); + mTimerTextView.setVisibility(VISIBLE); + mSoundLevels.setEnabled(true); + mTimerTextView.restart(); + break; + + case MODE_STARTING: + break; // No-Op. + + default: + Assert.fail("invalid mode for AudioRecordView!"); + break; + } + updateRecordButtonAppearance(); + } + + public void setThemeColor(final int color) { + mThemeColor = color; + updateRecordButtonAppearance(); + } + + private void updateRecordButtonAppearance() { + final Drawable foregroundDrawable = getResources().getDrawable(R.drawable.ic_mp_audio_mic); + final GradientDrawable backgroundDrawable = ((GradientDrawable) getResources() + .getDrawable(R.drawable.audio_record_control_button_background)); + if (isRecording()) { + foregroundDrawable.setColorFilter(Color.WHITE, PorterDuff.Mode.SRC_ATOP); + backgroundDrawable.setColor(mThemeColor); + } else { + foregroundDrawable.setColorFilter(mThemeColor, PorterDuff.Mode.SRC_ATOP); + backgroundDrawable.setColor(Color.WHITE); + } + mRecordButtonVisual.setImageDrawable(foregroundDrawable); + mRecordButtonVisual.setBackground(backgroundDrawable); + } + + @VisibleForTesting + boolean onRecordButtonTouchDown() { + if (!mMediaRecorder.isRecording() && mCurrentMode == MODE_IDLE) { + setMode(MODE_STARTING); + playAudioStartSound(new OnCompletionListener() { + @Override + public void onCompletion() { + // Double-check the current mode before recording since the user may have + // lifted finger from the button before the beeping sound is played through. + final int maxSize = MmsConfig.get(mHostInterface.getConversationSelfSubId()) + .getMaxMessageSize(); + if (mCurrentMode == MODE_STARTING && + mMediaRecorder.startRecording(AudioRecordView.this, + AudioRecordView.this, maxSize)) { + setMode(MODE_RECORDING); + } + } + }); + mAudioRecordStartTimeMillis = System.currentTimeMillis(); + return true; + } + return false; + } + + @VisibleForTesting + boolean onRecordButtonTouchUp() { + if (System.currentTimeMillis() - mAudioRecordStartTimeMillis < + AUDIO_RECORD_MINIMUM_DURATION_MILLIS) { + // The recording is too short, bolden the hint text to instruct the user to + // "tap+hold" to record audio. + final Uri outputUri = stopRecording(); + if (outputUri != null) { + SafeAsyncTask.executeOnThreadPool(new Runnable() { + @Override + public void run() { + Factory.get().getApplicationContext().getContentResolver().delete( + outputUri, null, null); + } + }); + } + setMode(MODE_IDLE); + mHintTextView.setTypeface(null, Typeface.BOLD); + } else if (isRecording()) { + // Record for some extra time to ensure the ending part is saved. + setMode(MODE_STOPPING); + ThreadUtil.getMainThreadHandler().postDelayed(new Runnable() { + @Override + public void run() { + onFinishedRecording(); + } + }, AUDIO_RECORD_ENDING_BUFFER_MILLIS); + } else { + setMode(MODE_IDLE); + } + return true; + } + + private Uri stopRecording() { + if (mMediaRecorder.isRecording()) { + return mMediaRecorder.stopRecording(); + } + return null; + } + + @Override // From MediaRecorder.OnInfoListener + public void onInfo(final MediaRecorder mr, final int what, final int extra) { + if (what == MediaRecorder.MEDIA_RECORDER_INFO_MAX_FILESIZE_REACHED) { + // Max size reached. Finish recording immediately. + LogUtil.i(LogUtil.BUGLE_TAG, "Max size reached while recording audio"); + onFinishedRecording(); + } else { + // These are unknown errors. + onErrorWhileRecording(what, extra); + } + } + + @Override // From MediaRecorder.OnErrorListener + public void onError(final MediaRecorder mr, final int what, final int extra) { + onErrorWhileRecording(what, extra); + } + + private void onErrorWhileRecording(final int what, final int extra) { + LogUtil.e(LogUtil.BUGLE_TAG, "Error occurred during audio recording what=" + what + + ", extra=" + extra); + UiUtils.showToastAtBottom(R.string.audio_recording_error); + setMode(MODE_IDLE); + stopRecording(); + } + + private void onFinishedRecording() { + final Uri outputUri = stopRecording(); + if (outputUri != null) { + final Rect startRect = new Rect(); + mRecordButtonVisual.getGlobalVisibleRect(startRect); + final MediaPickerMessagePartData audioItem = + new MediaPickerMessagePartData(startRect, + ContentType.AUDIO_3GPP, outputUri, 0, 0); + mHostInterface.onAudioRecorded(audioItem); + } + playAudioEndSound(); + setMode(MODE_IDLE); + } + + private void playAudioStartSound(final OnCompletionListener completionListener) { + MediaUtil.get().playSound(getContext(), R.raw.audio_initiate, completionListener); + } + + private void playAudioEndSound() { + MediaUtil.get().playSound(getContext(), R.raw.audio_end, null); + } +} diff --git a/src/com/android/messaging/ui/mediapicker/CameraManager.java b/src/com/android/messaging/ui/mediapicker/CameraManager.java new file mode 100644 index 0000000..166ebd7 --- /dev/null +++ b/src/com/android/messaging/ui/mediapicker/CameraManager.java @@ -0,0 +1,1200 @@ +/* + * 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.ui.mediapicker; + +import android.Manifest; +import android.app.Activity; +import android.content.Context; +import android.content.pm.ActivityInfo; +import android.content.res.Configuration; +import android.content.res.Resources; +import android.hardware.Camera; +import android.hardware.Camera.CameraInfo; +import android.media.MediaRecorder; +import android.net.Uri; +import android.os.AsyncTask; +import android.os.Looper; +import android.support.annotation.NonNull; +import android.text.TextUtils; +import android.util.DisplayMetrics; +import android.view.MotionEvent; +import android.view.OrientationEventListener; +import android.view.Surface; +import android.view.View; +import android.view.WindowManager; + +import com.android.messaging.datamodel.data.DraftMessageData.DraftMessageSubscriptionDataProvider; +import com.android.messaging.Factory; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.datamodel.media.ImageRequest; +import com.android.messaging.sms.MmsConfig; +import com.android.messaging.ui.mediapicker.camerafocus.FocusOverlayManager; +import com.android.messaging.ui.mediapicker.camerafocus.RenderOverlay; +import com.android.messaging.util.Assert; +import com.android.messaging.util.BugleGservices; +import com.android.messaging.util.BugleGservicesKeys; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.OsUtil; +import com.android.messaging.util.UiUtils; +import com.google.common.annotations.VisibleForTesting; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; + +/** + * Class which manages interactions with the camera, but does not do any UI. This class is + * designed to be a singleton to ensure there is one component managing the camera and releasing + * the native resources. + * In order to acquire a camera, a caller must: + * <ul> + * <li>Call selectCamera to select front or back camera + * <li>Call setSurface to control where the preview is shown + * <li>Call openCamera to request the camera start preview + * </ul> + * Callers should call onPause and onResume to ensure that the camera is release while the activity + * is not active. + * This class is not thread safe. It should only be called from one thread (the UI thread or test + * thread) + */ +class CameraManager implements FocusOverlayManager.Listener { + /** + * Wrapper around the framework camera API to allow mocking different hardware scenarios while + * unit testing + */ + interface CameraWrapper { + int getNumberOfCameras(); + void getCameraInfo(int index, CameraInfo cameraInfo); + Camera open(int cameraId); + /** Add a wrapper for release because a final method cannot be mocked */ + void release(Camera camera); + } + + /** + * Callbacks for the camera manager listener + */ + interface CameraManagerListener { + void onCameraError(int errorCode, Exception e); + void onCameraChanged(); + } + + /** + * Callback when taking image or video + */ + interface MediaCallback { + static final int MEDIA_CAMERA_CHANGED = 1; + static final int MEDIA_NO_DATA = 2; + + void onMediaReady(Uri uriToMedia, String contentType, int width, int height); + void onMediaFailed(Exception exception); + void onMediaInfo(int what); + } + + // Error codes + static final int ERROR_OPENING_CAMERA = 1; + static final int ERROR_SHOWING_PREVIEW = 2; + static final int ERROR_INITIALIZING_VIDEO = 3; + static final int ERROR_STORAGE_FAILURE = 4; + static final int ERROR_RECORDING_VIDEO = 5; + static final int ERROR_HARDWARE_ACCELERATION_DISABLED = 6; + static final int ERROR_TAKING_PICTURE = 7; + + private static final String TAG = LogUtil.BUGLE_TAG; + private static final int NO_CAMERA_SELECTED = -1; + + private static CameraManager sInstance; + + /** Default camera wrapper which directs calls to the framework APIs */ + private static CameraWrapper sCameraWrapper = new CameraWrapper() { + @Override + public int getNumberOfCameras() { + return Camera.getNumberOfCameras(); + } + + @Override + public void getCameraInfo(final int index, final CameraInfo cameraInfo) { + Camera.getCameraInfo(index, cameraInfo); + } + + @Override + public Camera open(final int cameraId) { + return Camera.open(cameraId); + } + + @Override + public void release(final Camera camera) { + camera.release(); + } + }; + + /** The CameraInfo for the currently selected camera */ + private final CameraInfo mCameraInfo; + + /** + * The index of the selected camera or NO_CAMERA_SELECTED if a camera hasn't been selected yet + */ + private int mCameraIndex; + + /** True if the device has front and back cameras */ + private final boolean mHasFrontAndBackCamera; + + /** True if the camera should be open (may not yet be actually open) */ + private boolean mOpenRequested; + + /** True if the camera is requested to be in video mode */ + private boolean mVideoModeRequested; + + /** The media recorder for video mode */ + private MmsVideoRecorder mMediaRecorder; + + /** Callback to call with video recording updates */ + private MediaCallback mVideoCallback; + + /** The preview view to show the preview on */ + private CameraPreview mCameraPreview; + + /** The helper classs to handle orientation changes */ + private OrientationHandler mOrientationHandler; + + /** Tracks whether the preview has hardware acceleration */ + private boolean mIsHardwareAccelerationSupported; + + /** + * The task for opening the camera, so it doesn't block the UI thread + * Using AsyncTask rather than SafeAsyncTask because the tasks need to be serialized, but don't + * need to be on the UI thread + * TODO: If we have other AyncTasks (not SafeAsyncTasks) this may contend and we may + * need to create a dedicated thread, or synchronize the threads in the thread pool + */ + private AsyncTask<Integer, Void, Camera> mOpenCameraTask; + + /** + * The camera index that is queued to be opened, but not completed yet, or NO_CAMERA_SELECTED if + * no open task is pending + */ + private int mPendingOpenCameraIndex = NO_CAMERA_SELECTED; + + /** The instance of the currently opened camera */ + private Camera mCamera; + + /** The rotation of the screen relative to the camera's natural orientation */ + private int mRotation; + + /** The callback to notify when errors or other events occur */ + private CameraManagerListener mListener; + + /** True if the camera is currently in the process of taking an image */ + private boolean mTakingPicture; + + /** Provides subscription-related data to access per-subscription configurations. */ + private DraftMessageSubscriptionDataProvider mSubscriptionDataProvider; + + /** Manages auto focus visual and behavior */ + private final FocusOverlayManager mFocusOverlayManager; + + private CameraManager() { + mCameraInfo = new CameraInfo(); + mCameraIndex = NO_CAMERA_SELECTED; + + // Check to see if a front and back camera exist + boolean hasFrontCamera = false; + boolean hasBackCamera = false; + final CameraInfo cameraInfo = new CameraInfo(); + final int cameraCount = sCameraWrapper.getNumberOfCameras(); + try { + for (int i = 0; i < cameraCount; i++) { + sCameraWrapper.getCameraInfo(i, cameraInfo); + if (cameraInfo.facing == CameraInfo.CAMERA_FACING_FRONT) { + hasFrontCamera = true; + } else if (cameraInfo.facing == CameraInfo.CAMERA_FACING_BACK) { + hasBackCamera = true; + } + if (hasFrontCamera && hasBackCamera) { + break; + } + } + } catch (final RuntimeException e) { + LogUtil.e(TAG, "Unable to load camera info", e); + } + mHasFrontAndBackCamera = hasFrontCamera && hasBackCamera; + mFocusOverlayManager = new FocusOverlayManager(this, Looper.getMainLooper()); + + // Assume the best until we are proven otherwise + mIsHardwareAccelerationSupported = true; + } + + /** Gets the singleton instance */ + static CameraManager get() { + if (sInstance == null) { + sInstance = new CameraManager(); + } + return sInstance; + } + + /** Allows tests to inject a custom camera wrapper */ + @VisibleForTesting + static void setCameraWrapper(final CameraWrapper cameraWrapper) { + sCameraWrapper = cameraWrapper; + sInstance = null; + } + + /** + * Sets the surface to use to display the preview + * This must only be called AFTER the CameraPreview has a texture ready + * @param preview The preview surface view + */ + void setSurface(final CameraPreview preview) { + if (preview == mCameraPreview) { + return; + } + + if (preview != null) { + Assert.isTrue(preview.isValid()); + preview.setOnTouchListener(new View.OnTouchListener() { + @Override + public boolean onTouch(final View view, final MotionEvent motionEvent) { + if ((motionEvent.getActionMasked() & MotionEvent.ACTION_UP) == + MotionEvent.ACTION_UP) { + mFocusOverlayManager.setPreviewSize(view.getWidth(), view.getHeight()); + mFocusOverlayManager.onSingleTapUp( + (int) motionEvent.getX() + view.getLeft(), + (int) motionEvent.getY() + view.getTop()); + } + return true; + } + }); + } + mCameraPreview = preview; + tryShowPreview(); + } + + void setRenderOverlay(final RenderOverlay renderOverlay) { + mFocusOverlayManager.setFocusRenderer(renderOverlay != null ? + renderOverlay.getPieRenderer() : null); + } + + /** Convenience function to swap between front and back facing cameras */ + void swapCamera() { + Assert.isTrue(mCameraIndex >= 0); + selectCamera(mCameraInfo.facing == CameraInfo.CAMERA_FACING_FRONT ? + CameraInfo.CAMERA_FACING_BACK : + CameraInfo.CAMERA_FACING_FRONT); + } + + /** + * Selects the first camera facing the desired direction, or the first camera if there is no + * camera in the desired direction + * @param desiredFacing One of the CameraInfo.CAMERA_FACING_* constants + * @return True if a camera was selected, or false if selecting a camera failed + */ + boolean selectCamera(final int desiredFacing) { + try { + // We already selected a camera facing that direction + if (mCameraIndex >= 0 && mCameraInfo.facing == desiredFacing) { + return true; + } + + final int cameraCount = sCameraWrapper.getNumberOfCameras(); + Assert.isTrue(cameraCount > 0); + + mCameraIndex = NO_CAMERA_SELECTED; + setCamera(null); + final CameraInfo cameraInfo = new CameraInfo(); + for (int i = 0; i < cameraCount; i++) { + sCameraWrapper.getCameraInfo(i, cameraInfo); + if (cameraInfo.facing == desiredFacing) { + mCameraIndex = i; + sCameraWrapper.getCameraInfo(i, mCameraInfo); + break; + } + } + + // There's no camera in the desired facing direction, just select the first camera + // regardless of direction + if (mCameraIndex < 0) { + mCameraIndex = 0; + sCameraWrapper.getCameraInfo(0, mCameraInfo); + } + + if (mOpenRequested) { + // The camera is open, so reopen with the newly selected camera + openCamera(); + } + return true; + } catch (final RuntimeException e) { + LogUtil.e(TAG, "RuntimeException in CameraManager.selectCamera", e); + if (mListener != null) { + mListener.onCameraError(ERROR_OPENING_CAMERA, e); + } + return false; + } + } + + int getCameraIndex() { + return mCameraIndex; + } + + void selectCameraByIndex(final int cameraIndex) { + if (mCameraIndex == cameraIndex) { + return; + } + + try { + mCameraIndex = cameraIndex; + sCameraWrapper.getCameraInfo(mCameraIndex, mCameraInfo); + if (mOpenRequested) { + openCamera(); + } + } catch (final RuntimeException e) { + LogUtil.e(TAG, "RuntimeException in CameraManager.selectCameraByIndex", e); + if (mListener != null) { + mListener.onCameraError(ERROR_OPENING_CAMERA, e); + } + } + } + + @VisibleForTesting + CameraInfo getCameraInfo() { + if (mCameraIndex == NO_CAMERA_SELECTED) { + return null; + } + return mCameraInfo; + } + + /** @return True if this device has camera capabilities */ + boolean hasAnyCamera() { + return sCameraWrapper.getNumberOfCameras() > 0; + } + + /** @return True if the device has both a front and back camera */ + boolean hasFrontAndBackCamera() { + return mHasFrontAndBackCamera; + } + + /** + * Opens the camera on a separate thread and initiates the preview if one is available + */ + void openCamera() { + if (mCameraIndex == NO_CAMERA_SELECTED) { + // Ensure a selected camera if none is currently selected. This may happen if the + // camera chooser is not the default media chooser. + selectCamera(CameraInfo.CAMERA_FACING_BACK); + } + mOpenRequested = true; + // We're already opening the camera or already have the camera handle, nothing more to do + if (mPendingOpenCameraIndex == mCameraIndex || mCamera != null) { + return; + } + + // True if the task to open the camera has to be delayed until the current one completes + boolean delayTask = false; + + // Cancel any previous open camera tasks + if (mOpenCameraTask != null) { + mPendingOpenCameraIndex = NO_CAMERA_SELECTED; + delayTask = true; + } + + mPendingOpenCameraIndex = mCameraIndex; + mOpenCameraTask = new AsyncTask<Integer, Void, Camera>() { + private Exception mException; + + @Override + protected Camera doInBackground(final Integer... params) { + try { + final int cameraIndex = params[0]; + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "Opening camera " + mCameraIndex); + } + return sCameraWrapper.open(cameraIndex); + } catch (final Exception e) { + LogUtil.e(TAG, "Exception while opening camera", e); + mException = e; + return null; + } + } + + @Override + protected void onPostExecute(final Camera camera) { + // If we completed, but no longer want this camera, then release the camera + if (mOpenCameraTask != this || !mOpenRequested) { + releaseCamera(camera); + cleanup(); + return; + } + + cleanup(); + + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "Opened camera " + mCameraIndex + " " + (camera != null)); + } + + setCamera(camera); + if (camera == null) { + if (mListener != null) { + mListener.onCameraError(ERROR_OPENING_CAMERA, mException); + } + LogUtil.e(TAG, "Error opening camera"); + } + } + + @Override + protected void onCancelled() { + super.onCancelled(); + cleanup(); + } + + private void cleanup() { + mPendingOpenCameraIndex = NO_CAMERA_SELECTED; + if (mOpenCameraTask != null && mOpenCameraTask.getStatus() == Status.PENDING) { + // If there's another task waiting on this one to complete, start it now + mOpenCameraTask.execute(mCameraIndex); + } else { + mOpenCameraTask = null; + } + + } + }; + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "Start opening camera " + mCameraIndex); + } + + if (!delayTask) { + mOpenCameraTask.execute(mCameraIndex); + } + } + + boolean isVideoMode() { + return mVideoModeRequested; + } + + boolean isRecording() { + return mVideoModeRequested && mVideoCallback != null; + } + + void setVideoMode(final boolean videoMode) { + if (mVideoModeRequested == videoMode) { + return; + } + mVideoModeRequested = videoMode; + tryInitOrCleanupVideoMode(); + } + + /** Closes the camera releasing the resources it uses */ + void closeCamera() { + mOpenRequested = false; + setCamera(null); + } + + /** Temporarily closes the camera if it is open */ + void onPause() { + setCamera(null); + } + + /** Reopens the camera if it was opened when onPause was called */ + void onResume() { + if (mOpenRequested) { + openCamera(); + } + } + + /** + * Sets the listener which will be notified of errors or other events in the camera + * @param listener The listener to notify + */ + void setListener(final CameraManagerListener listener) { + Assert.isMainThread(); + mListener = listener; + if (!mIsHardwareAccelerationSupported && mListener != null) { + mListener.onCameraError(ERROR_HARDWARE_ACCELERATION_DISABLED, null); + } + } + + void setSubscriptionDataProvider(final DraftMessageSubscriptionDataProvider provider) { + mSubscriptionDataProvider = provider; + } + + void takePicture(final float heightPercent, @NonNull final MediaCallback callback) { + Assert.isTrue(!mVideoModeRequested); + Assert.isTrue(!mTakingPicture); + Assert.notNull(callback); + if (mCamera == null) { + // The caller should have checked isCameraAvailable first, but just in case, protect + // against a null camera by notifying the callback that taking the picture didn't work + callback.onMediaFailed(null); + return; + } + final Camera.PictureCallback jpegCallback = new Camera.PictureCallback() { + @Override + public void onPictureTaken(final byte[] bytes, final Camera camera) { + mTakingPicture = false; + if (mCamera != camera) { + // This may happen if the camera was changed between front/back while the + // picture is being taken. + callback.onMediaInfo(MediaCallback.MEDIA_CAMERA_CHANGED); + return; + } + + if (bytes == null) { + callback.onMediaInfo(MediaCallback.MEDIA_NO_DATA); + return; + } + + final Camera.Size size = camera.getParameters().getPictureSize(); + int width; + int height; + if (mRotation == 90 || mRotation == 270) { + width = size.height; + height = size.width; + } else { + width = size.width; + height = size.height; + } + new ImagePersistTask( + width, height, heightPercent, bytes, mCameraPreview.getContext(), callback) + .executeOnThreadPool(); + } + }; + + mTakingPicture = true; + try { + mCamera.takePicture( + null /* shutter */, + null /* raw */, + null /* postView */, + jpegCallback); + } catch (final RuntimeException e) { + LogUtil.e(TAG, "RuntimeException in CameraManager.takePicture", e); + mTakingPicture = false; + if (mListener != null) { + mListener.onCameraError(ERROR_TAKING_PICTURE, e); + } + } + } + + void startVideo(final MediaCallback callback) { + Assert.notNull(callback); + Assert.isTrue(!isRecording()); + mVideoCallback = callback; + tryStartVideoCapture(); + } + + /** + * Asynchronously releases a camera + * @param camera The camera to release + */ + private void releaseCamera(final Camera camera) { + if (camera == null) { + return; + } + + mFocusOverlayManager.onCameraReleased(); + + new AsyncTask<Void, Void, Void>() { + @Override + protected Void doInBackground(final Void... params) { + if (LogUtil.isLoggable(TAG, LogUtil.VERBOSE)) { + LogUtil.v(TAG, "Releasing camera " + mCameraIndex); + } + sCameraWrapper.release(camera); + return null; + } + }.execute(); + } + + private void releaseMediaRecorder(final boolean cleanupFile) { + if (mMediaRecorder == null) { + return; + } + mVideoModeRequested = false; + + if (cleanupFile) { + mMediaRecorder.cleanupTempFile(); + if (mVideoCallback != null) { + final MediaCallback callback = mVideoCallback; + mVideoCallback = null; + // Notify the callback that we've stopped recording + callback.onMediaReady(null /*uri*/, null /*contentType*/, 0 /*width*/, + 0 /*height*/); + } + } + + mMediaRecorder.release(); + mMediaRecorder = null; + + if (mCamera != null) { + try { + mCamera.reconnect(); + } catch (final IOException e) { + LogUtil.e(TAG, "IOException in CameraManager.releaseMediaRecorder", e); + if (mListener != null) { + mListener.onCameraError(ERROR_OPENING_CAMERA, e); + } + } catch (final RuntimeException e) { + LogUtil.e(TAG, "RuntimeException in CameraManager.releaseMediaRecorder", e); + if (mListener != null) { + mListener.onCameraError(ERROR_OPENING_CAMERA, e); + } + } + } + restoreRequestedOrientation(); + } + + /** Updates the orientation of the camera to match the orientation of the device */ + private void updateCameraOrientation() { + if (mCamera == null || mCameraPreview == null || mTakingPicture) { + return; + } + + final WindowManager windowManager = + (WindowManager) mCameraPreview.getContext().getSystemService( + Context.WINDOW_SERVICE); + + int degrees = 0; + switch (windowManager.getDefaultDisplay().getRotation()) { + case Surface.ROTATION_0: degrees = 0; break; + case Surface.ROTATION_90: degrees = 90; break; + case Surface.ROTATION_180: degrees = 180; break; + case Surface.ROTATION_270: degrees = 270; break; + } + + // The display orientation of the camera (this controls the preview image). + int orientation; + + // The clockwise rotation angle relative to the orientation of the camera. This affects + // pictures returned by the camera in Camera.PictureCallback. + int rotation; + if (mCameraInfo.facing == Camera.CameraInfo.CAMERA_FACING_FRONT) { + orientation = (mCameraInfo.orientation + degrees) % 360; + rotation = orientation; + // compensate the mirror but only for orientation + orientation = (360 - orientation) % 360; + } else { // back-facing + orientation = (mCameraInfo.orientation - degrees + 360) % 360; + rotation = orientation; + } + mRotation = rotation; + if (mMediaRecorder == null) { + try { + mCamera.setDisplayOrientation(orientation); + final Camera.Parameters params = mCamera.getParameters(); + params.setRotation(rotation); + mCamera.setParameters(params); + } catch (final RuntimeException e) { + LogUtil.e(TAG, "RuntimeException in CameraManager.updateCameraOrientation", e); + if (mListener != null) { + mListener.onCameraError(ERROR_OPENING_CAMERA, e); + } + } + } + } + + /** Sets the current camera, releasing any previously opened camera */ + private void setCamera(final Camera camera) { + if (mCamera == camera) { + return; + } + + releaseMediaRecorder(true /* cleanupFile */); + releaseCamera(mCamera); + mCamera = camera; + tryShowPreview(); + if (mListener != null) { + mListener.onCameraChanged(); + } + } + + /** Shows the preview if the camera is open and the preview is loaded */ + private void tryShowPreview() { + if (mCameraPreview == null || mCamera == null) { + if (mOrientationHandler != null) { + mOrientationHandler.disable(); + mOrientationHandler = null; + } + releaseMediaRecorder(true /* cleanupFile */); + mFocusOverlayManager.onPreviewStopped(); + return; + } + try { + mCamera.stopPreview(); + updateCameraOrientation(); + + final Camera.Parameters params = mCamera.getParameters(); + final Camera.Size pictureSize = chooseBestPictureSize(); + final Camera.Size previewSize = chooseBestPreviewSize(pictureSize); + params.setPreviewSize(previewSize.width, previewSize.height); + params.setPictureSize(pictureSize.width, pictureSize.height); + logCameraSize("Setting preview size: ", previewSize); + logCameraSize("Setting picture size: ", pictureSize); + mCameraPreview.setSize(previewSize, mCameraInfo.orientation); + for (final String focusMode : params.getSupportedFocusModes()) { + if (TextUtils.equals(focusMode, Camera.Parameters.FOCUS_MODE_CONTINUOUS_PICTURE)) { + // Use continuous focus if available + params.setFocusMode(focusMode); + break; + } + } + + mCamera.setParameters(params); + mCameraPreview.startPreview(mCamera); + mCamera.startPreview(); + mCamera.setAutoFocusMoveCallback(new Camera.AutoFocusMoveCallback() { + @Override + public void onAutoFocusMoving(final boolean start, final Camera camera) { + mFocusOverlayManager.onAutoFocusMoving(start); + } + }); + mFocusOverlayManager.setParameters(mCamera.getParameters()); + mFocusOverlayManager.setMirror(mCameraInfo.facing == CameraInfo.CAMERA_FACING_BACK); + mFocusOverlayManager.onPreviewStarted(); + tryInitOrCleanupVideoMode(); + if (mOrientationHandler == null) { + mOrientationHandler = new OrientationHandler(mCameraPreview.getContext()); + mOrientationHandler.enable(); + } + } catch (final IOException e) { + LogUtil.e(TAG, "IOException in CameraManager.tryShowPreview", e); + if (mListener != null) { + mListener.onCameraError(ERROR_SHOWING_PREVIEW, e); + } + } catch (final RuntimeException e) { + LogUtil.e(TAG, "RuntimeException in CameraManager.tryShowPreview", e); + if (mListener != null) { + mListener.onCameraError(ERROR_SHOWING_PREVIEW, e); + } + } + } + + private void tryInitOrCleanupVideoMode() { + if (!mVideoModeRequested || mCamera == null || mCameraPreview == null) { + releaseMediaRecorder(true /* cleanupFile */); + return; + } + + if (mMediaRecorder != null) { + return; + } + + try { + mCamera.unlock(); + final int maxMessageSize = getMmsConfig().getMaxMessageSize(); + mMediaRecorder = new MmsVideoRecorder(mCamera, mCameraIndex, mRotation, maxMessageSize); + mMediaRecorder.prepare(); + } catch (final FileNotFoundException e) { + LogUtil.e(TAG, "FileNotFoundException in CameraManager.tryInitOrCleanupVideoMode", e); + if (mListener != null) { + mListener.onCameraError(ERROR_STORAGE_FAILURE, e); + } + setVideoMode(false); + return; + } catch (final IOException e) { + LogUtil.e(TAG, "IOException in CameraManager.tryInitOrCleanupVideoMode", e); + if (mListener != null) { + mListener.onCameraError(ERROR_INITIALIZING_VIDEO, e); + } + setVideoMode(false); + return; + } catch (final RuntimeException e) { + LogUtil.e(TAG, "RuntimeException in CameraManager.tryInitOrCleanupVideoMode", e); + if (mListener != null) { + mListener.onCameraError(ERROR_INITIALIZING_VIDEO, e); + } + setVideoMode(false); + return; + } + + tryStartVideoCapture(); + } + + private void tryStartVideoCapture() { + if (mMediaRecorder == null || mVideoCallback == null) { + return; + } + + mMediaRecorder.setOnErrorListener(new MediaRecorder.OnErrorListener() { + @Override + public void onError(final MediaRecorder mediaRecorder, final int what, + final int extra) { + if (mListener != null) { + mListener.onCameraError(ERROR_RECORDING_VIDEO, null); + } + restoreRequestedOrientation(); + } + }); + + mMediaRecorder.setOnInfoListener(new MediaRecorder.OnInfoListener() { + @Override + public void onInfo(final MediaRecorder mediaRecorder, final int what, final int extra) { + if (what == MediaRecorder.MEDIA_RECORDER_INFO_MAX_DURATION_REACHED || + what == MediaRecorder.MEDIA_RECORDER_INFO_MAX_FILESIZE_REACHED) { + stopVideo(); + } + } + }); + + try { + mMediaRecorder.start(); + final Activity activity = UiUtils.getActivity(mCameraPreview.getContext()); + activity.getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + lockOrientation(); + } catch (final IllegalStateException e) { + LogUtil.e(TAG, "IllegalStateException in CameraManager.tryStartVideoCapture", e); + if (mListener != null) { + mListener.onCameraError(ERROR_RECORDING_VIDEO, e); + } + setVideoMode(false); + restoreRequestedOrientation(); + } catch (final RuntimeException e) { + LogUtil.e(TAG, "RuntimeException in CameraManager.tryStartVideoCapture", e); + if (mListener != null) { + mListener.onCameraError(ERROR_RECORDING_VIDEO, e); + } + setVideoMode(false); + restoreRequestedOrientation(); + } + } + + void stopVideo() { + int width = ImageRequest.UNSPECIFIED_SIZE; + int height = ImageRequest.UNSPECIFIED_SIZE; + Uri uri = null; + String contentType = null; + try { + final Activity activity = UiUtils.getActivity(mCameraPreview.getContext()); + activity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + mMediaRecorder.stop(); + width = mMediaRecorder.getVideoWidth(); + height = mMediaRecorder.getVideoHeight(); + uri = mMediaRecorder.getVideoUri(); + contentType = mMediaRecorder.getContentType(); + } catch (final RuntimeException e) { + // MediaRecorder.stop will throw a RuntimeException if the video was too short, let the + // finally clause call the callback with null uri and handle cleanup + LogUtil.e(TAG, "RuntimeException in CameraManager.stopVideo", e); + } finally { + final MediaCallback videoCallback = mVideoCallback; + mVideoCallback = null; + releaseMediaRecorder(false /* cleanupFile */); + if (uri == null) { + tryInitOrCleanupVideoMode(); + } + videoCallback.onMediaReady(uri, contentType, width, height); + } + } + + boolean isCameraAvailable() { + return mCamera != null && !mTakingPicture && mIsHardwareAccelerationSupported; + } + + /** + * External components call into this to report if hardware acceleration is supported. When + * hardware acceleration isn't supported, we need to report an error through the listener + * interface + * @param isHardwareAccelerationSupported True if the preview is rendering in a hardware + * accelerated view. + */ + void reportHardwareAccelerationSupported(final boolean isHardwareAccelerationSupported) { + Assert.isMainThread(); + if (mIsHardwareAccelerationSupported == isHardwareAccelerationSupported) { + // If the value hasn't changed nothing more to do + return; + } + + mIsHardwareAccelerationSupported = isHardwareAccelerationSupported; + if (!isHardwareAccelerationSupported) { + LogUtil.e(TAG, "Software rendering - cannot open camera"); + if (mListener != null) { + mListener.onCameraError(ERROR_HARDWARE_ACCELERATION_DISABLED, null); + } + } + } + + /** Returns the scale factor to scale the width/height to max allowed in MmsConfig */ + private float getScaleFactorForMaxAllowedSize(final int width, final int height, + final int maxWidth, final int maxHeight) { + if (maxWidth <= 0 || maxHeight <= 0) { + // MmsConfig initialization runs asynchronously on application startup, so there's a + // chance (albeit a very slight one) that we don't have it yet. + LogUtil.w(LogUtil.BUGLE_TAG, "Max image size not loaded in MmsConfig"); + return 1.0f; + } + + if (width <= maxWidth && height <= maxHeight) { + // Already meeting requirements. + return 1.0f; + } + + return Math.min(maxWidth * 1.0f / width, maxHeight * 1.0f / height); + } + + private MmsConfig getMmsConfig() { + final int subId = mSubscriptionDataProvider != null ? + mSubscriptionDataProvider.getConversationSelfSubId() : + ParticipantData.DEFAULT_SELF_SUB_ID; + return MmsConfig.get(subId); + } + + /** + * Choose the best picture size by trying to find a size close to the MmsConfig's max size, + * which is closest to the screen aspect ratio + */ + private Camera.Size chooseBestPictureSize() { + final Context context = mCameraPreview.getContext(); + final Resources resources = context.getResources(); + final DisplayMetrics displayMetrics = resources.getDisplayMetrics(); + final int displayOrientation = resources.getConfiguration().orientation; + int cameraOrientation = mCameraInfo.orientation; + + int screenWidth; + int screenHeight; + if (displayOrientation == Configuration.ORIENTATION_LANDSCAPE) { + // Rotate the camera orientation 90 degrees to compensate for the rotated display + // metrics. Direction doesn't matter because we're just using it for width/height + cameraOrientation += 90; + } + + // Check the camera orientation relative to the display. + // For 0, 180, 360, the screen width/height are the display width/height + // For 90, 270, the screen width/height are inverted from the display + if (cameraOrientation % 180 == 0) { + screenWidth = displayMetrics.widthPixels; + screenHeight = displayMetrics.heightPixels; + } else { + screenWidth = displayMetrics.heightPixels; + screenHeight = displayMetrics.widthPixels; + } + + final MmsConfig mmsConfig = getMmsConfig(); + final int maxWidth = mmsConfig.getMaxImageWidth(); + final int maxHeight = mmsConfig.getMaxImageHeight(); + + // Constrain the size within the max width/height defined by MmsConfig. + final float scaleFactor = getScaleFactorForMaxAllowedSize(screenWidth, screenHeight, + maxWidth, maxHeight); + screenWidth *= scaleFactor; + screenHeight *= scaleFactor; + + final float aspectRatio = BugleGservices.get().getFloat( + BugleGservicesKeys.CAMERA_ASPECT_RATIO, + screenWidth / (float) screenHeight); + final List<Camera.Size> sizes = new ArrayList<Camera.Size>( + mCamera.getParameters().getSupportedPictureSizes()); + final int maxPixels = maxWidth * maxHeight; + + // Sort the sizes so the best size is first + Collections.sort(sizes, new SizeComparator(maxWidth, maxHeight, aspectRatio, maxPixels)); + + return sizes.get(0); + } + + /** + * Chose the best preview size based on the picture size. Try to find a size with the same + * aspect ratio and size as the picture if possible + */ + private Camera.Size chooseBestPreviewSize(final Camera.Size pictureSize) { + final List<Camera.Size> sizes = new ArrayList<Camera.Size>( + mCamera.getParameters().getSupportedPreviewSizes()); + final float aspectRatio = pictureSize.width / (float) pictureSize.height; + final int capturePixels = pictureSize.width * pictureSize.height; + + // Sort the sizes so the best size is first + Collections.sort(sizes, new SizeComparator(Integer.MAX_VALUE, Integer.MAX_VALUE, + aspectRatio, capturePixels)); + + return sizes.get(0); + } + + private class OrientationHandler extends OrientationEventListener { + OrientationHandler(final Context context) { + super(context); + } + + @Override + public void onOrientationChanged(final int orientation) { + updateCameraOrientation(); + } + } + + private static class SizeComparator implements Comparator<Camera.Size> { + private static final int PREFER_LEFT = -1; + private static final int PREFER_RIGHT = 1; + + // The max width/height for the preferred size. Integer.MAX_VALUE if no size limit + private final int mMaxWidth; + private final int mMaxHeight; + + // The desired aspect ratio + private final float mTargetAspectRatio; + + // The desired size (width x height) to try to match + private final int mTargetPixels; + + public SizeComparator(final int maxWidth, final int maxHeight, + final float targetAspectRatio, final int targetPixels) { + mMaxWidth = maxWidth; + mMaxHeight = maxHeight; + mTargetAspectRatio = targetAspectRatio; + mTargetPixels = targetPixels; + } + + /** + * Returns a negative value if left is a better choice than right, or a positive value if + * right is a better choice is better than left. 0 if they are equal + */ + @Override + public int compare(final Camera.Size left, final Camera.Size right) { + // If one size is less than the max size prefer it over the other + if ((left.width <= mMaxWidth && left.height <= mMaxHeight) != + (right.width <= mMaxWidth && right.height <= mMaxHeight)) { + return left.width <= mMaxWidth ? PREFER_LEFT : PREFER_RIGHT; + } + + // If one is closer to the target aspect ratio, prefer it. + final float leftAspectRatio = left.width / (float) left.height; + final float rightAspectRatio = right.width / (float) right.height; + final float leftAspectRatioDiff = Math.abs(leftAspectRatio - mTargetAspectRatio); + final float rightAspectRatioDiff = Math.abs(rightAspectRatio - mTargetAspectRatio); + if (leftAspectRatioDiff != rightAspectRatioDiff) { + return (leftAspectRatioDiff - rightAspectRatioDiff) < 0 ? + PREFER_LEFT : PREFER_RIGHT; + } + + // At this point they have the same aspect ratio diff and are either both bigger + // than the max size or both smaller than the max size, so prefer the one closest + // to target size + final int leftDiff = Math.abs((left.width * left.height) - mTargetPixels); + final int rightDiff = Math.abs((right.width * right.height) - mTargetPixels); + return leftDiff - rightDiff; + } + } + + @Override // From FocusOverlayManager.Listener + public void autoFocus() { + if (mCamera == null) { + return; + } + + try { + mCamera.autoFocus(new Camera.AutoFocusCallback() { + @Override + public void onAutoFocus(final boolean success, final Camera camera) { + mFocusOverlayManager.onAutoFocus(success, false /* shutterDown */); + } + }); + } catch (final RuntimeException e) { + LogUtil.e(TAG, "RuntimeException in CameraManager.autoFocus", e); + // If autofocus fails, the camera should have called the callback with success=false, + // but some throw an exception here + mFocusOverlayManager.onAutoFocus(false /*success*/, false /*shutterDown*/); + } + } + + @Override // From FocusOverlayManager.Listener + public void cancelAutoFocus() { + if (mCamera == null) { + return; + } + try { + mCamera.cancelAutoFocus(); + } catch (final RuntimeException e) { + // Ignore + LogUtil.e(TAG, "RuntimeException in CameraManager.cancelAutoFocus", e); + } + } + + @Override // From FocusOverlayManager.Listener + public boolean capture() { + return false; + } + + @Override // From FocusOverlayManager.Listener + public void setFocusParameters() { + if (mCamera == null) { + return; + } + try { + final Camera.Parameters parameters = mCamera.getParameters(); + parameters.setFocusMode(mFocusOverlayManager.getFocusMode()); + if (parameters.getMaxNumFocusAreas() > 0) { + // Don't set focus areas (even to null) if focus areas aren't supported, camera may + // crash + parameters.setFocusAreas(mFocusOverlayManager.getFocusAreas()); + } + parameters.setMeteringAreas(mFocusOverlayManager.getMeteringAreas()); + mCamera.setParameters(parameters); + } catch (final RuntimeException e) { + // This occurs when the device is out of space or when the camera is locked + LogUtil.e(TAG, "RuntimeException in CameraManager setFocusParameters"); + } + } + + private void logCameraSize(final String prefix, final Camera.Size size) { + // Log the camera size and aspect ratio for help when examining bug reports for camera + // failures + LogUtil.i(TAG, prefix + size.width + "x" + size.height + + " (" + (size.width / (float) size.height) + ")"); + } + + + private Integer mSavedOrientation = null; + + private void lockOrientation() { + // when we start recording, lock our orientation + final Activity a = UiUtils.getActivity(mCameraPreview.getContext()); + final WindowManager windowManager = + (WindowManager) a.getSystemService(Context.WINDOW_SERVICE); + final int rotation = windowManager.getDefaultDisplay().getRotation(); + + mSavedOrientation = a.getRequestedOrientation(); + switch (rotation) { + case Surface.ROTATION_0: + a.setRequestedOrientation( + ActivityInfo.SCREEN_ORIENTATION_PORTRAIT); + break; + case Surface.ROTATION_90: + a.setRequestedOrientation( + ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE); + break; + case Surface.ROTATION_180: + a.setRequestedOrientation( + ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT); + break; + case Surface.ROTATION_270: + a.setRequestedOrientation( + ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE); + break; + } + + } + + private void restoreRequestedOrientation() { + if (mSavedOrientation != null) { + final Activity a = UiUtils.getActivity(mCameraPreview.getContext()); + if (a != null) { + a.setRequestedOrientation(mSavedOrientation); + } + mSavedOrientation = null; + } + } + + static boolean hasCameraPermission() { + return OsUtil.hasPermission(Manifest.permission.CAMERA); + } +} diff --git a/src/com/android/messaging/ui/mediapicker/CameraMediaChooser.java b/src/com/android/messaging/ui/mediapicker/CameraMediaChooser.java new file mode 100644 index 0000000..2c7a7f2 --- /dev/null +++ b/src/com/android/messaging/ui/mediapicker/CameraMediaChooser.java @@ -0,0 +1,481 @@ +/* + * 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.ui.mediapicker; + +import android.Manifest; +import android.content.Context; +import android.content.pm.PackageManager; +import android.graphics.Rect; +import android.hardware.Camera; +import android.net.Uri; +import android.os.SystemClock; +import android.view.LayoutInflater; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewGroup; +import android.view.animation.AlphaAnimation; +import android.view.animation.Animation; +import android.view.animation.AnimationSet; +import android.widget.Chronometer; +import android.widget.ImageButton; + +import com.android.messaging.R; +import com.android.messaging.datamodel.data.MediaPickerMessagePartData; +import com.android.messaging.ui.mediapicker.CameraManager.MediaCallback; +import com.android.messaging.ui.mediapicker.camerafocus.RenderOverlay; +import com.android.messaging.util.Assert; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.OsUtil; +import com.android.messaging.util.UiUtils; + +/** + * Chooser which allows the user to take pictures or video without leaving the current app/activity + */ +class CameraMediaChooser extends MediaChooser implements + CameraManager.CameraManagerListener { + private CameraPreview.CameraPreviewHost mCameraPreviewHost; + private ImageButton mFullScreenButton; + private ImageButton mSwapCameraButton; + private ImageButton mSwapModeButton; + private ImageButton mCaptureButton; + private ImageButton mCancelVideoButton; + private Chronometer mVideoCounter; + private boolean mVideoCancelled; + private int mErrorToast; + private View mEnabledView; + private View mMissingPermissionView; + + CameraMediaChooser(final MediaPicker mediaPicker) { + super(mediaPicker); + } + + @Override + public int getSupportedMediaTypes() { + if (CameraManager.get().hasAnyCamera()) { + return MediaPicker.MEDIA_TYPE_IMAGE | MediaPicker.MEDIA_TYPE_VIDEO; + } else { + return MediaPicker.MEDIA_TYPE_NONE; + } + } + + @Override + public View destroyView() { + CameraManager.get().closeCamera(); + CameraManager.get().setListener(null); + CameraManager.get().setSubscriptionDataProvider(null); + return super.destroyView(); + } + + @Override + protected View createView(final ViewGroup container) { + CameraManager.get().setListener(this); + CameraManager.get().setSubscriptionDataProvider(this); + CameraManager.get().setVideoMode(false); + final LayoutInflater inflater = getLayoutInflater(); + final CameraMediaChooserView view = (CameraMediaChooserView) inflater.inflate( + R.layout.mediapicker_camera_chooser, + container /* root */, + false /* attachToRoot */); + mCameraPreviewHost = (CameraPreview.CameraPreviewHost) view.findViewById( + R.id.camera_preview); + mCameraPreviewHost.getView().setOnTouchListener(new View.OnTouchListener() { + @Override + public boolean onTouch(final View view, final MotionEvent motionEvent) { + if (CameraManager.get().isVideoMode()) { + // Prevent the swipe down in video mode because video is always captured in + // full screen + return true; + } + + return false; + } + }); + + final View shutterVisual = view.findViewById(R.id.camera_shutter_visual); + + mFullScreenButton = (ImageButton) view.findViewById(R.id.camera_fullScreen_button); + mFullScreenButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(final View view) { + mMediaPicker.setFullScreen(true); + } + }); + + mSwapCameraButton = (ImageButton) view.findViewById(R.id.camera_swapCamera_button); + mSwapCameraButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(final View view) { + CameraManager.get().swapCamera(); + } + }); + + mCaptureButton = (ImageButton) view.findViewById(R.id.camera_capture_button); + mCaptureButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(final View v) { + final float heightPercent = Math.min(mMediaPicker.getViewPager().getHeight() / + (float) mCameraPreviewHost.getView().getHeight(), 1); + + if (CameraManager.get().isRecording()) { + CameraManager.get().stopVideo(); + } else { + final CameraManager.MediaCallback callback = new CameraManager.MediaCallback() { + @Override + public void onMediaReady( + final Uri uriToVideo, final String contentType, + final int width, final int height) { + mVideoCounter.stop(); + if (mVideoCancelled || uriToVideo == null) { + mVideoCancelled = false; + } else { + final Rect startRect = new Rect(); + // It's possible to throw out the chooser while taking the + // picture/video. In that case, still use the attachment, just + // skip the startRect + if (mView != null) { + mView.getGlobalVisibleRect(startRect); + } + mMediaPicker.dispatchItemsSelected( + new MediaPickerMessagePartData(startRect, contentType, + uriToVideo, width, height), + true /* dismissMediaPicker */); + } + updateViewState(); + } + + @Override + public void onMediaFailed(final Exception exception) { + UiUtils.showToastAtBottom(R.string.camera_media_failure); + updateViewState(); + } + + @Override + public void onMediaInfo(final int what) { + if (what == MediaCallback.MEDIA_NO_DATA) { + UiUtils.showToastAtBottom(R.string.camera_media_failure); + } + updateViewState(); + } + }; + if (CameraManager.get().isVideoMode()) { + CameraManager.get().startVideo(callback); + mVideoCounter.setBase(SystemClock.elapsedRealtime()); + mVideoCounter.start(); + updateViewState(); + } else { + showShutterEffect(shutterVisual); + CameraManager.get().takePicture(heightPercent, callback); + updateViewState(); + } + } + } + }); + + mSwapModeButton = (ImageButton) view.findViewById(R.id.camera_swap_mode_button); + mSwapModeButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(final View view) { + final boolean isSwitchingToVideo = !CameraManager.get().isVideoMode(); + if (isSwitchingToVideo && !OsUtil.hasRecordAudioPermission()) { + requestRecordAudioPermission(); + } else { + onSwapMode(); + } + } + }); + + mCancelVideoButton = (ImageButton) view.findViewById(R.id.camera_cancel_button); + mCancelVideoButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(final View view) { + mVideoCancelled = true; + CameraManager.get().stopVideo(); + mMediaPicker.dismiss(true); + } + }); + + mVideoCounter = (Chronometer) view.findViewById(R.id.camera_video_counter); + + CameraManager.get().setRenderOverlay((RenderOverlay) view.findViewById(R.id.focus_visual)); + + mEnabledView = view.findViewById(R.id.mediapicker_enabled); + mMissingPermissionView = view.findViewById(R.id.missing_permission_view); + + // Must set mView before calling updateViewState because it operates on mView + mView = view; + updateViewState(); + updateForPermissionState(CameraManager.hasCameraPermission()); + return view; + } + + @Override + public int getIconResource() { + return R.drawable.ic_camera_light; + } + + @Override + public int getIconDescriptionResource() { + return R.string.mediapicker_cameraChooserDescription; + } + + /** + * Updates the view when entering or leaving full-screen camera mode + * @param fullScreen + */ + @Override + void onFullScreenChanged(final boolean fullScreen) { + super.onFullScreenChanged(fullScreen); + if (!fullScreen && CameraManager.get().isVideoMode()) { + CameraManager.get().setVideoMode(false); + } + updateViewState(); + } + + /** + * Initializes the control to a default state when it is opened / closed + * @param open True if the control is opened + */ + @Override + void onOpenedChanged(final boolean open) { + super.onOpenedChanged(open); + updateViewState(); + } + + @Override + protected void setSelected(final boolean selected) { + super.setSelected(selected); + if (selected) { + if (CameraManager.hasCameraPermission()) { + // If an error occurred before the chooser was selected, show it now + showErrorToastIfNeeded(); + } else { + requestCameraPermission(); + } + } + } + + private void requestCameraPermission() { + mMediaPicker.requestPermissions(new String[] { Manifest.permission.CAMERA }, + MediaPicker.CAMERA_PERMISSION_REQUEST_CODE); + } + + private void requestRecordAudioPermission() { + mMediaPicker.requestPermissions(new String[] { Manifest.permission.RECORD_AUDIO }, + MediaPicker.RECORD_AUDIO_PERMISSION_REQUEST_CODE); + } + + @Override + protected void onRequestPermissionsResult( + final int requestCode, final String permissions[], final int[] grantResults) { + if (requestCode == MediaPicker.CAMERA_PERMISSION_REQUEST_CODE) { + final boolean permissionGranted = grantResults[0] == PackageManager.PERMISSION_GRANTED; + updateForPermissionState(permissionGranted); + if (permissionGranted) { + mCameraPreviewHost.onCameraPermissionGranted(); + } + } else if (requestCode == MediaPicker.RECORD_AUDIO_PERMISSION_REQUEST_CODE) { + Assert.isFalse(CameraManager.get().isVideoMode()); + if (grantResults[0] == PackageManager.PERMISSION_GRANTED) { + // Switch to video mode + onSwapMode(); + } else { + // Stay in still-photo mode + } + } + } + + private void updateForPermissionState(final boolean granted) { + // onRequestPermissionsResult can sometimes get called before createView(). + if (mEnabledView == null) { + return; + } + + mEnabledView.setVisibility(granted ? View.VISIBLE : View.GONE); + mMissingPermissionView.setVisibility(granted ? View.GONE : View.VISIBLE); + } + + @Override + public boolean canSwipeDown() { + if (CameraManager.get().isVideoMode()) { + return true; + } + return super.canSwipeDown(); + } + + /** + * Handles an error from the camera manager by showing the appropriate error message to the user + * @param errorCode One of the CameraManager.ERROR_* constants + * @param e The exception which caused the error, if any + */ + @Override + public void onCameraError(final int errorCode, final Exception e) { + switch (errorCode) { + case CameraManager.ERROR_OPENING_CAMERA: + case CameraManager.ERROR_SHOWING_PREVIEW: + mErrorToast = R.string.camera_error_opening; + break; + case CameraManager.ERROR_INITIALIZING_VIDEO: + mErrorToast = R.string.camera_error_video_init_fail; + updateViewState(); + break; + case CameraManager.ERROR_STORAGE_FAILURE: + mErrorToast = R.string.camera_error_storage_fail; + updateViewState(); + break; + case CameraManager.ERROR_TAKING_PICTURE: + mErrorToast = R.string.camera_error_failure_taking_picture; + break; + default: + mErrorToast = R.string.camera_error_unknown; + LogUtil.w(LogUtil.BUGLE_TAG, "Unknown camera error:" + errorCode); + break; + } + showErrorToastIfNeeded(); + } + + private void showErrorToastIfNeeded() { + if (mErrorToast != 0 && mSelected) { + UiUtils.showToastAtBottom(mErrorToast); + mErrorToast = 0; + } + } + + @Override + public void onCameraChanged() { + updateViewState(); + } + + private void onSwapMode() { + CameraManager.get().setVideoMode(!CameraManager.get().isVideoMode()); + if (CameraManager.get().isVideoMode()) { + mMediaPicker.setFullScreen(true); + + // For now we start recording immediately + mCaptureButton.performClick(); + } + updateViewState(); + } + + private void showShutterEffect(final View shutterVisual) { + final float maxAlpha = getContext().getResources().getFraction( + R.fraction.camera_shutter_max_alpha, 1 /* base */, 1 /* pBase */); + + // Divide by 2 so each half of the animation adds up to the full duration + final int animationDuration = getContext().getResources().getInteger( + R.integer.camera_shutter_duration) / 2; + + final AnimationSet animation = new AnimationSet(false /* shareInterpolator */); + final Animation alphaInAnimation = new AlphaAnimation(0.0f, maxAlpha); + alphaInAnimation.setDuration(animationDuration); + animation.addAnimation(alphaInAnimation); + + final Animation alphaOutAnimation = new AlphaAnimation(maxAlpha, 0.0f); + alphaOutAnimation.setStartOffset(animationDuration); + alphaOutAnimation.setDuration(animationDuration); + animation.addAnimation(alphaOutAnimation); + + animation.setAnimationListener(new Animation.AnimationListener() { + @Override + public void onAnimationStart(final Animation animation) { + shutterVisual.setVisibility(View.VISIBLE); + } + + @Override + public void onAnimationEnd(final Animation animation) { + shutterVisual.setVisibility(View.GONE); + } + + @Override + public void onAnimationRepeat(final Animation animation) { + } + }); + shutterVisual.startAnimation(animation); + } + + /** Updates the state of the buttons and overlays based on the current state of the view */ + private void updateViewState() { + if (mView == null) { + return; + } + + final Context context = getContext(); + if (context == null) { + // Context is null if the fragment was already removed from the activity + return; + } + final boolean fullScreen = mMediaPicker.isFullScreen(); + final boolean videoMode = CameraManager.get().isVideoMode(); + final boolean isRecording = CameraManager.get().isRecording(); + final boolean isCameraAvailable = isCameraAvailable(); + final Camera.CameraInfo cameraInfo = CameraManager.get().getCameraInfo(); + final boolean frontCamera = cameraInfo != null && cameraInfo.facing == + Camera.CameraInfo.CAMERA_FACING_FRONT; + + mView.setSystemUiVisibility( + fullScreen ? View.SYSTEM_UI_FLAG_LOW_PROFILE : + View.SYSTEM_UI_FLAG_VISIBLE); + + mFullScreenButton.setVisibility(!fullScreen ? View.VISIBLE : View.GONE); + mFullScreenButton.setEnabled(isCameraAvailable); + mSwapCameraButton.setVisibility( + fullScreen && !isRecording && CameraManager.get().hasFrontAndBackCamera() ? + View.VISIBLE : View.GONE); + mSwapCameraButton.setImageResource(frontCamera ? + R.drawable.ic_camera_front_light : + R.drawable.ic_camera_rear_light); + mSwapCameraButton.setEnabled(isCameraAvailable); + + mCancelVideoButton.setVisibility(isRecording ? View.VISIBLE : View.GONE); + mVideoCounter.setVisibility(isRecording ? View.VISIBLE : View.GONE); + + mSwapModeButton.setImageResource(videoMode ? + R.drawable.ic_mp_camera_small_light : + R.drawable.ic_mp_video_small_light); + mSwapModeButton.setContentDescription(context.getString(videoMode ? + R.string.camera_switch_to_still_mode : R.string.camera_switch_to_video_mode)); + mSwapModeButton.setVisibility(isRecording ? View.GONE : View.VISIBLE); + mSwapModeButton.setEnabled(isCameraAvailable); + + if (isRecording) { + mCaptureButton.setImageResource(R.drawable.ic_mp_capture_stop_large_light); + mCaptureButton.setContentDescription(context.getString( + R.string.camera_stop_recording)); + } else if (videoMode) { + mCaptureButton.setImageResource(R.drawable.ic_mp_video_large_light); + mCaptureButton.setContentDescription(context.getString( + R.string.camera_start_recording)); + } else { + mCaptureButton.setImageResource(R.drawable.ic_checkmark_large_light); + mCaptureButton.setContentDescription(context.getString( + R.string.camera_take_picture)); + } + mCaptureButton.setEnabled(isCameraAvailable); + } + + @Override + int getActionBarTitleResId() { + return 0; + } + + /** + * Returns if the camera is currently ready camera is loaded and not taking a picture. + * otherwise we should avoid taking another picture, swapping camera or recording video. + */ + private boolean isCameraAvailable() { + return CameraManager.get().isCameraAvailable(); + } +} diff --git a/src/com/android/messaging/ui/mediapicker/CameraMediaChooserView.java b/src/com/android/messaging/ui/mediapicker/CameraMediaChooserView.java new file mode 100644 index 0000000..64c07b2 --- /dev/null +++ b/src/com/android/messaging/ui/mediapicker/CameraMediaChooserView.java @@ -0,0 +1,102 @@ +/* + * 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.ui.mediapicker; + +import android.content.Context; +import android.graphics.Canvas; +import android.hardware.Camera; +import android.os.Bundle; +import android.os.Parcelable; +import android.util.AttributeSet; +import android.view.ViewGroup; +import android.widget.FrameLayout; +import com.android.messaging.R; +import com.android.messaging.ui.PersistentInstanceState; +import com.android.messaging.util.ThreadUtil; + +public class CameraMediaChooserView extends FrameLayout implements PersistentInstanceState { + private static final String KEY_CAMERA_INDEX = "camera_index"; + + // True if we have at least queued an update to the view tree to support software rendering + // fallback + private boolean mIsSoftwareFallbackActive; + + public CameraMediaChooserView(final Context context, final AttributeSet attrs) { + super(context, attrs); + } + + @Override + protected Parcelable onSaveInstanceState() { + final Bundle bundle = new Bundle(); + bundle.putInt(KEY_CAMERA_INDEX, CameraManager.get().getCameraIndex()); + return bundle; + } + + @Override + protected void onRestoreInstanceState(final Parcelable state) { + if (!(state instanceof Bundle)) { + return; + } + + final Bundle bundle = (Bundle) state; + CameraManager.get().selectCameraByIndex(bundle.getInt(KEY_CAMERA_INDEX)); + } + + @Override + public Parcelable saveState() { + return onSaveInstanceState(); + } + + @Override + public void restoreState(final Parcelable restoredState) { + onRestoreInstanceState(restoredState); + } + + @Override + public void resetState() { + CameraManager.get().selectCamera(Camera.CameraInfo.CAMERA_FACING_BACK); + } + + @Override + protected void onDraw(final Canvas canvas) { + super.onDraw(canvas); + // If the canvas isn't hardware accelerated, we have to replace the HardwareCameraPreview + // with a SoftwareCameraPreview which supports software rendering + if (!canvas.isHardwareAccelerated() && !mIsSoftwareFallbackActive) { + mIsSoftwareFallbackActive = true; + // Post modifying the tree since we can't modify the view tree during a draw pass + ThreadUtil.getMainThreadHandler().post(new Runnable() { + @Override + public void run() { + final HardwareCameraPreview cameraPreview = + (HardwareCameraPreview) findViewById(R.id.camera_preview); + if (cameraPreview == null) { + return; + } + final ViewGroup parent = ((ViewGroup) cameraPreview.getParent()); + final int index = parent.indexOfChild(cameraPreview); + final SoftwareCameraPreview softwareCameraPreview = + new SoftwareCameraPreview(getContext()); + // Be sure to remove the hardware view before adding the software view to + // prevent having 2 camera previews active at the same time + parent.removeView(cameraPreview); + parent.addView(softwareCameraPreview, index); + } + }); + } + } +} diff --git a/src/com/android/messaging/ui/mediapicker/CameraPreview.java b/src/com/android/messaging/ui/mediapicker/CameraPreview.java new file mode 100644 index 0000000..ecac978 --- /dev/null +++ b/src/com/android/messaging/ui/mediapicker/CameraPreview.java @@ -0,0 +1,152 @@ +/* + * 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.ui.mediapicker; + +import android.content.Context; +import android.content.res.Configuration; +import android.hardware.Camera; +import android.view.View; +import android.view.View.MeasureSpec; +import com.android.messaging.util.Assert; + +import java.io.IOException; + +/** + * Contains shared code for SoftwareCameraPreview and HardwareCameraPreview, cannot use inheritance + * because those classes must inherit from separate Views, so those classes delegate calls to this + * helper class. Specifics for each implementation are in CameraPreviewHost + */ +public class CameraPreview { + public interface CameraPreviewHost { + View getView(); + boolean isValid(); + void startPreview(final Camera camera) throws IOException; + void onCameraPermissionGranted(); + + } + + private int mCameraWidth = -1; + private int mCameraHeight = -1; + + private final CameraPreviewHost mHost; + + public CameraPreview(final CameraPreviewHost host) { + Assert.notNull(host); + Assert.notNull(host.getView()); + mHost = host; + } + + public void setSize(final Camera.Size size, final int orientation) { + switch (orientation) { + case 0: + case 180: + mCameraWidth = size.width; + mCameraHeight = size.height; + break; + case 90: + case 270: + default: + mCameraWidth = size.height; + mCameraHeight = size.width; + } + mHost.getView().requestLayout(); + } + + public int getWidthMeasureSpec(final int widthMeasureSpec, final int heightMeasureSpec) { + if (mCameraHeight >= 0) { + final int width = View.MeasureSpec.getSize(widthMeasureSpec); + return MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY); + } else { + return widthMeasureSpec; + } + } + + public int getHeightMeasureSpec(final int widthMeasureSpec, final int heightMeasureSpec) { + if (mCameraHeight >= 0) { + final int orientation = getContext().getResources().getConfiguration().orientation; + final int width = View.MeasureSpec.getSize(widthMeasureSpec); + final float aspectRatio = (float) mCameraWidth / (float) mCameraHeight; + int height; + if (orientation == Configuration.ORIENTATION_LANDSCAPE) { + height = (int) (width * aspectRatio); + } else { + height = (int) (width / aspectRatio); + } + return View.MeasureSpec.makeMeasureSpec(height, View.MeasureSpec.EXACTLY); + } else { + return heightMeasureSpec; + } + } + + public void onVisibilityChanged(final int visibility) { + if (CameraManager.hasCameraPermission()) { + if (visibility == View.VISIBLE) { + CameraManager.get().openCamera(); + } else { + CameraManager.get().closeCamera(); + } + } + } + + public Context getContext() { + return mHost.getView().getContext(); + } + + public void setOnTouchListener(final View.OnTouchListener listener) { + mHost.getView().setOnTouchListener(listener); + } + + public int getHeight() { + return mHost.getView().getHeight(); + } + + public void onAttachedToWindow() { + if (CameraManager.hasCameraPermission()) { + CameraManager.get().openCamera(); + } + } + + public void onDetachedFromWindow() { + CameraManager.get().closeCamera(); + } + + public void onRestoreInstanceState() { + if (CameraManager.hasCameraPermission()) { + CameraManager.get().openCamera(); + } + } + + public void onCameraPermissionGranted() { + CameraManager.get().openCamera(); + } + + /** + * @return True if the view is valid and prepared for the camera to start showing the preview + */ + public boolean isValid() { + return mHost.isValid(); + } + + /** + * Starts the camera preview on the current surface. Abstracts out the differences in API + * from the CameraManager + * @throws IOException Which is caught by the CameraManager to display an error + */ + public void startPreview(final Camera camera) throws IOException { + mHost.startPreview(camera); + } +} diff --git a/src/com/android/messaging/ui/mediapicker/DocumentImagePicker.java b/src/com/android/messaging/ui/mediapicker/DocumentImagePicker.java new file mode 100644 index 0000000..2c36752 --- /dev/null +++ b/src/com/android/messaging/ui/mediapicker/DocumentImagePicker.java @@ -0,0 +1,128 @@ +/* + * 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.ui.mediapicker; + +import android.app.Activity; +import android.app.Fragment; +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; + +import com.android.messaging.Factory; +import com.android.messaging.datamodel.data.PendingAttachmentData; +import com.android.messaging.ui.UIIntents; +import com.android.messaging.util.ImageUtils; +import com.android.messaging.util.SafeAsyncTask; + +/** + * Wraps around the functionalities to allow the user to pick images from the document + * picker. Instances of this class must be tied to a Fragment which is able to delegate activity + * result callbacks. + */ +public class DocumentImagePicker { + + /** + * An interface for a listener that listens for when a document has been picked. + */ + public interface SelectionListener { + /** + * Called when an document is selected from picker. At this point, the file hasn't been + * actually loaded and staged in the temp directory, so we are passing in a pending + * MessagePartData, which the consumer should use to display a placeholder image. + * @param pendingItem a temporary attachment data for showing the placeholder state. + */ + void onDocumentSelected(PendingAttachmentData pendingItem); + } + + // The owning fragment. + private final Fragment mFragment; + + // The listener on the picker events. + private final SelectionListener mListener; + + private static final String EXTRA_PHOTO_URL = "photo_url"; + + /** + * Creates a new instance of DocumentImagePicker. + * @param activity The activity that owns the picker, or the activity that hosts the owning + * fragment. + */ + public DocumentImagePicker(final Fragment fragment, + final SelectionListener listener) { + mFragment = fragment; + mListener = listener; + } + + /** + * Intent out to open an image/video from document picker. + */ + public void launchPicker() { + UIIntents.get().launchDocumentImagePicker(mFragment); + } + + /** + * Must be called from the fragment/activity's onActivityResult(). + */ + public void onActivityResult(final int requestCode, final int resultCode, final Intent data) { + if (requestCode == UIIntents.REQUEST_PICK_IMAGE_FROM_DOCUMENT_PICKER && + resultCode == Activity.RESULT_OK) { + // Sometimes called after media item has been picked from the document picker. + String url = data.getStringExtra(EXTRA_PHOTO_URL); + if (url == null) { + // we're using the builtin photo picker which supplies the return + // url as it's "data" + url = data.getDataString(); + if (url == null) { + final Bundle extras = data.getExtras(); + if (extras != null) { + final Uri uri = (Uri) extras.getParcelable(Intent.EXTRA_STREAM); + if (uri != null) { + url = uri.toString(); + } + } + } + } + + // Guard against null uri cases for when the activity returns a null/invalid intent. + if (url != null) { + final Uri uri = Uri.parse(url); + prepareDocumentForAttachment(uri); + } + } + } + + private void prepareDocumentForAttachment(final Uri documentUri) { + // Notify our listener with a PendingAttachmentData containing the metadata. + // Asynchronously get the content type for the picked image since + // ImageUtils.getContentType() potentially involves I/O and can be expensive. + new SafeAsyncTask<Void, Void, String>() { + @Override + protected String doInBackgroundTimed(final Void... params) { + return ImageUtils.getContentType( + Factory.get().getApplicationContext().getContentResolver(), documentUri); + } + + @Override + protected void onPostExecute(final String contentType) { + // Ask the listener to create a temporary placeholder item to show the progress. + final PendingAttachmentData pendingItem = + PendingAttachmentData.createPendingAttachmentData(contentType, + documentUri); + mListener.onDocumentSelected(pendingItem); + } + }.executeOnThreadPool(); + } +} diff --git a/src/com/android/messaging/ui/mediapicker/GalleryGridAdapter.java b/src/com/android/messaging/ui/mediapicker/GalleryGridAdapter.java new file mode 100644 index 0000000..fda3b19 --- /dev/null +++ b/src/com/android/messaging/ui/mediapicker/GalleryGridAdapter.java @@ -0,0 +1,62 @@ +/* + * 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.ui.mediapicker; + +import android.content.Context; +import android.database.Cursor; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.CursorAdapter; + +import com.android.messaging.R; +import com.android.messaging.ui.mediapicker.GalleryGridItemView.HostInterface; +import com.android.messaging.util.Assert; + +/** + * Bridges between the image cursor loaded by GalleryBoundCursorLoader and the GalleryGridView. + */ +public class GalleryGridAdapter extends CursorAdapter { + private GalleryGridItemView.HostInterface mGgivHostInterface; + + public GalleryGridAdapter(final Context context, final Cursor cursor) { + super(context, cursor, 0); + } + + public void setHostInterface(final HostInterface ggivHostInterface) { + mGgivHostInterface = ggivHostInterface; + } + + /** + * {@inheritDoc} + */ + @Override + public void bindView(final View view, final Context context, final Cursor cursor) { + Assert.isTrue(view instanceof GalleryGridItemView); + final GalleryGridItemView galleryImageView = (GalleryGridItemView) view; + galleryImageView.bind(cursor, mGgivHostInterface); + } + + /** + * {@inheritDoc} + */ + @Override + public View newView(final Context context, final Cursor cursor, final ViewGroup parent) { + final LayoutInflater layoutInflater = LayoutInflater.from(context); + return layoutInflater.inflate(R.layout.gallery_grid_item_view, parent, false); + } +} diff --git a/src/com/android/messaging/ui/mediapicker/GalleryGridItemView.java b/src/com/android/messaging/ui/mediapicker/GalleryGridItemView.java new file mode 100644 index 0000000..3d71fe6 --- /dev/null +++ b/src/com/android/messaging/ui/mediapicker/GalleryGridItemView.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.ui.mediapicker; + +import android.content.Context; +import android.database.Cursor; +import android.graphics.Rect; +import android.util.AttributeSet; +import android.view.MotionEvent; +import android.view.TouchDelegate; +import android.view.View; +import android.widget.CheckBox; +import android.widget.FrameLayout; +import android.widget.ImageView.ScaleType; + +import com.android.messaging.R; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.data.GalleryGridItemData; +import com.android.messaging.ui.AsyncImageView; +import com.android.messaging.ui.ConversationDrawables; +import com.google.common.annotations.VisibleForTesting; + +import java.util.concurrent.TimeUnit; + +/** + * Shows an item in the gallery picker grid view. Hosts an FileImageView with a checkbox. + */ +public class GalleryGridItemView extends FrameLayout { + /** + * Implemented by the owner of this GalleryGridItemView instance to communicate on media + * picking and selection events. + */ + public interface HostInterface { + void onItemClicked(View view, GalleryGridItemData data, boolean longClick); + boolean isItemSelected(GalleryGridItemData data); + boolean isMultiSelectEnabled(); + } + + @VisibleForTesting + GalleryGridItemData mData; + private AsyncImageView mImageView; + private CheckBox mCheckBox; + private HostInterface mHostInterface; + private final OnClickListener mOnClickListener = new OnClickListener() { + @Override + public void onClick(final View v) { + mHostInterface.onItemClicked(GalleryGridItemView.this, mData, false /*longClick*/); + } + }; + + public GalleryGridItemView(final Context context, final AttributeSet attrs) { + super(context, attrs); + mData = DataModel.get().createGalleryGridItemData(); + } + + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + mImageView = (AsyncImageView) findViewById(R.id.image); + mCheckBox = (CheckBox) findViewById(R.id.checkbox); + mCheckBox.setOnClickListener(mOnClickListener); + setOnClickListener(mOnClickListener); + final OnLongClickListener longClickListener = new OnLongClickListener() { + @Override + public boolean onLongClick(final View v) { + mHostInterface.onItemClicked(v, mData, true /* longClick */); + return true; + } + }; + setOnLongClickListener(longClickListener); + mCheckBox.setOnLongClickListener(longClickListener); + addOnLayoutChangeListener(new OnLayoutChangeListener() { + @Override + public void onLayoutChange(View v, int left, int top, int right, int bottom, + int oldLeft, int oldTop, int oldRight, int oldBottom) { + // Enlarge the clickable region for the checkbox to fill the entire view. + final Rect region = new Rect(0, 0, getWidth(), getHeight()); + setTouchDelegate(new TouchDelegate(region, mCheckBox) { + @Override + public boolean onTouchEvent(MotionEvent event) { + switch (event.getAction()) { + case MotionEvent.ACTION_DOWN: + setPressed(true); + break; + case MotionEvent.ACTION_UP: + case MotionEvent.ACTION_CANCEL: + setPressed(false); + break; + } + return super.onTouchEvent(event); + } + }); + } + }); + } + + @Override + protected void onMeasure(final int widthMeasureSpec, final int heightMeasureSpec) { + // The grid view auto-fit the columns, so we want to let the height match the width + // to make the image square. + super.onMeasure(widthMeasureSpec, widthMeasureSpec); + } + + public void bind(final Cursor cursor, final HostInterface hostInterface) { + final int desiredSize = getResources() + .getDimensionPixelSize(R.dimen.gallery_image_cell_size); + mData.bind(cursor, desiredSize, desiredSize); + mHostInterface = hostInterface; + updateViewState(); + } + + private void updateViewState() { + updateImageView(); + if (mHostInterface.isMultiSelectEnabled() && !mData.isDocumentPickerItem()) { + mCheckBox.setVisibility(VISIBLE); + mCheckBox.setClickable(true); + mCheckBox.setChecked(mHostInterface.isItemSelected(mData)); + } else { + mCheckBox.setVisibility(GONE); + mCheckBox.setClickable(false); + } + } + + private void updateImageView() { + if (mData.isDocumentPickerItem()) { + mImageView.setScaleType(ScaleType.CENTER); + setBackgroundColor(ConversationDrawables.get().getConversationThemeColor()); + mImageView.setImageResourceId(null); + mImageView.setImageResource(R.drawable.ic_photo_library_light); + mImageView.setContentDescription(getResources().getString( + R.string.pick_image_from_document_library_content_description)); + } else { + mImageView.setScaleType(ScaleType.CENTER_CROP); + setBackgroundColor(getResources().getColor(R.color.gallery_image_default_background)); + mImageView.setImageResourceId(mData.getImageRequestDescriptor()); + final long dateSeconds = mData.getDateSeconds(); + final boolean isValidDate = (dateSeconds > 0); + final int templateId = isValidDate ? + R.string.mediapicker_gallery_image_item_description : + R.string.mediapicker_gallery_image_item_description_no_date; + String contentDescription = String.format(getResources().getString(templateId), + dateSeconds * TimeUnit.SECONDS.toMillis(1)); + mImageView.setContentDescription(contentDescription); + } + } +} diff --git a/src/com/android/messaging/ui/mediapicker/GalleryGridView.java b/src/com/android/messaging/ui/mediapicker/GalleryGridView.java new file mode 100644 index 0000000..a5a7dad --- /dev/null +++ b/src/com/android/messaging/ui/mediapicker/GalleryGridView.java @@ -0,0 +1,315 @@ +/* + * 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.ui.mediapicker; + +import android.content.Context; +import android.graphics.Rect; +import android.net.Uri; +import android.os.Parcel; +import android.os.Parcelable; +import android.support.v4.util.ArrayMap; +import android.util.AttributeSet; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; + +import com.android.messaging.R; +import com.android.messaging.datamodel.binding.BindingBase; +import com.android.messaging.datamodel.binding.ImmutableBindingRef; +import com.android.messaging.datamodel.data.DraftMessageData; +import com.android.messaging.datamodel.data.GalleryGridItemData; +import com.android.messaging.datamodel.data.MessagePartData; +import com.android.messaging.datamodel.data.DraftMessageData.DraftMessageDataListener; +import com.android.messaging.ui.PersistentInstanceState; +import com.android.messaging.util.Assert; +import com.android.messaging.util.ContentType; +import com.android.messaging.util.LogUtil; + +import java.util.Iterator; +import java.util.Map; + +/** + * Shows a list of galley images from external storage in a GridView with multi-select + * capabilities, and with the option to intent out to a standalone image picker. + */ +public class GalleryGridView extends MediaPickerGridView implements + GalleryGridItemView.HostInterface, + PersistentInstanceState, + DraftMessageDataListener { + /** + * Implemented by the owner of this GalleryGridView instance to communicate on image + * picking and multi-image selection events. + */ + public interface GalleryGridViewListener { + void onDocumentPickerItemClicked(); + void onItemSelected(MessagePartData item); + void onItemUnselected(MessagePartData item); + void onConfirmSelection(); + void onUpdate(); + } + + private GalleryGridViewListener mListener; + + // TODO: Consider putting this into the data model object if we add more states. + private final ArrayMap<Uri, MessagePartData> mSelectedImages; + private boolean mIsMultiSelectMode = false; + private ImmutableBindingRef<DraftMessageData> mDraftMessageDataModel; + + public GalleryGridView(final Context context, final AttributeSet attrs) { + super(context, attrs); + mSelectedImages = new ArrayMap<Uri, MessagePartData>(); + } + + public void setHostInterface(final GalleryGridViewListener hostInterface) { + mListener = hostInterface; + } + + public void setDraftMessageDataModel(final BindingBase<DraftMessageData> dataModel) { + mDraftMessageDataModel = BindingBase.createBindingReference(dataModel); + mDraftMessageDataModel.getData().addListener(this); + } + + @Override + public void onItemClicked(final View view, final GalleryGridItemData data, + final boolean longClick) { + if (data.isDocumentPickerItem()) { + mListener.onDocumentPickerItemClicked(); + } else if (ContentType.isMediaType(data.getContentType())) { + if (longClick) { + // Turn on multi-select mode when an item is long-pressed. + setMultiSelectEnabled(true); + } + + final Rect startRect = new Rect(); + view.getGlobalVisibleRect(startRect); + if (isMultiSelectEnabled()) { + toggleItemSelection(startRect, data); + } else { + mListener.onItemSelected(data.constructMessagePartData(startRect)); + } + } else { + LogUtil.w(LogUtil.BUGLE_TAG, + "Selected item has invalid contentType " + data.getContentType()); + } + } + + @Override + public boolean isItemSelected(final GalleryGridItemData data) { + return mSelectedImages.containsKey(data.getImageUri()); + } + + int getSelectionCount() { + return mSelectedImages.size(); + } + + @Override + public boolean isMultiSelectEnabled() { + return mIsMultiSelectMode; + } + + private void toggleItemSelection(final Rect startRect, final GalleryGridItemData data) { + Assert.isTrue(isMultiSelectEnabled()); + if (isItemSelected(data)) { + final MessagePartData item = mSelectedImages.remove(data.getImageUri()); + mListener.onItemUnselected(item); + if (mSelectedImages.size() == 0) { + // No image is selected any more, turn off multi-select mode. + setMultiSelectEnabled(false); + } + } else { + final MessagePartData item = data.constructMessagePartData(startRect); + mSelectedImages.put(data.getImageUri(), item); + mListener.onItemSelected(item); + } + invalidateViews(); + } + + private void toggleMultiSelect() { + mIsMultiSelectMode = !mIsMultiSelectMode; + invalidateViews(); + } + + private void setMultiSelectEnabled(final boolean enabled) { + if (mIsMultiSelectMode != enabled) { + toggleMultiSelect(); + } + } + + private boolean canToggleMultiSelect() { + // We allow the user to toggle multi-select mode only when nothing has selected. If + // something has been selected, we show a confirm button instead. + return mSelectedImages.size() == 0; + } + + public void onCreateOptionsMenu(final MenuInflater inflater, final Menu menu) { + inflater.inflate(R.menu.gallery_picker_menu, menu); + final MenuItem toggleMultiSelect = menu.findItem(R.id.action_multiselect); + final MenuItem confirmMultiSelect = menu.findItem(R.id.action_confirm_multiselect); + final boolean canToggleMultiSelect = canToggleMultiSelect(); + toggleMultiSelect.setVisible(canToggleMultiSelect); + confirmMultiSelect.setVisible(!canToggleMultiSelect); + } + + public boolean onOptionsItemSelected(final MenuItem item) { + switch (item.getItemId()) { + case R.id.action_multiselect: + Assert.isTrue(canToggleMultiSelect()); + toggleMultiSelect(); + return true; + + case R.id.action_confirm_multiselect: + Assert.isTrue(!canToggleMultiSelect()); + mListener.onConfirmSelection(); + return true; + } + return false; + } + + + @Override + public void onDraftChanged(final DraftMessageData data, final int changeFlags) { + mDraftMessageDataModel.ensureBound(data); + // Whenever attachment changed, refresh selection state to remove those that are not + // selected. + if ((changeFlags & DraftMessageData.ATTACHMENTS_CHANGED) == + DraftMessageData.ATTACHMENTS_CHANGED) { + refreshImageSelectionStateOnAttachmentChange(); + } + } + + @Override + public void onDraftAttachmentLimitReached(final DraftMessageData data) { + mDraftMessageDataModel.ensureBound(data); + // Whenever draft attachment limit is reach, refresh selection state to remove those + // not actually added to draft. + refreshImageSelectionStateOnAttachmentChange(); + } + + @Override + public void onDraftAttachmentLoadFailed() { + // Nothing to do since the failed attachment gets removed automatically. + } + + private void refreshImageSelectionStateOnAttachmentChange() { + boolean changed = false; + final Iterator<Map.Entry<Uri, MessagePartData>> iterator = + mSelectedImages.entrySet().iterator(); + while (iterator.hasNext()) { + Map.Entry<Uri, MessagePartData> entry = iterator.next(); + if (!mDraftMessageDataModel.getData().containsAttachment(entry.getKey())) { + iterator.remove(); + changed = true; + } + } + + if (changed) { + mListener.onUpdate(); + invalidateViews(); + } + } + + @Override // PersistentInstanceState + public Parcelable saveState() { + return onSaveInstanceState(); + } + + @Override // PersistentInstanceState + public void restoreState(final Parcelable restoredState) { + onRestoreInstanceState(restoredState); + invalidateViews(); + } + + @Override + public Parcelable onSaveInstanceState() { + final Parcelable superState = super.onSaveInstanceState(); + final SavedState savedState = new SavedState(superState); + savedState.isMultiSelectMode = mIsMultiSelectMode; + savedState.selectedImages = mSelectedImages.values() + .toArray(new MessagePartData[mSelectedImages.size()]); + return savedState; + } + + @Override + public void onRestoreInstanceState(final Parcelable state) { + if (!(state instanceof SavedState)) { + super.onRestoreInstanceState(state); + return; + } + + final SavedState savedState = (SavedState) state; + super.onRestoreInstanceState(savedState.getSuperState()); + mIsMultiSelectMode = savedState.isMultiSelectMode; + mSelectedImages.clear(); + for (int i = 0; i < savedState.selectedImages.length; i++) { + final MessagePartData selectedImage = savedState.selectedImages[i]; + mSelectedImages.put(selectedImage.getContentUri(), selectedImage); + } + } + + @Override // PersistentInstanceState + public void resetState() { + mSelectedImages.clear(); + mIsMultiSelectMode = false; + invalidateViews(); + } + + public static class SavedState extends BaseSavedState { + boolean isMultiSelectMode; + MessagePartData[] selectedImages; + + SavedState(final Parcelable superState) { + super(superState); + } + + private SavedState(final Parcel in) { + super(in); + isMultiSelectMode = in.readInt() == 1 ? true : false; + + // Read parts + final int partCount = in.readInt(); + selectedImages = new MessagePartData[partCount]; + for (int i = 0; i < partCount; i++) { + selectedImages[i] = ((MessagePartData) in.readParcelable( + MessagePartData.class.getClassLoader())); + } + } + + @Override + public void writeToParcel(final Parcel out, final int flags) { + super.writeToParcel(out, flags); + out.writeInt(isMultiSelectMode ? 1 : 0); + + // Write parts + out.writeInt(selectedImages.length); + for (final MessagePartData image : selectedImages) { + out.writeParcelable(image, flags); + } + } + + public static final Parcelable.Creator<SavedState> CREATOR = + new Parcelable.Creator<SavedState>() { + @Override + public SavedState createFromParcel(final Parcel in) { + return new SavedState(in); + } + @Override + public SavedState[] newArray(final int size) { + return new SavedState[size]; + } + }; + } +} diff --git a/src/com/android/messaging/ui/mediapicker/GalleryMediaChooser.java b/src/com/android/messaging/ui/mediapicker/GalleryMediaChooser.java new file mode 100644 index 0000000..9422386 --- /dev/null +++ b/src/com/android/messaging/ui/mediapicker/GalleryMediaChooser.java @@ -0,0 +1,230 @@ +/* + * 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.ui.mediapicker; + +import android.Manifest; +import android.content.pm.PackageManager; +import android.database.Cursor; +import android.database.MatrixCursor; +import android.database.MergeCursor; +import android.support.v7.app.ActionBar; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; + +import com.android.messaging.Factory; +import com.android.messaging.R; +import com.android.messaging.datamodel.data.GalleryGridItemData; +import com.android.messaging.datamodel.data.MediaPickerData; +import com.android.messaging.datamodel.data.MessagePartData; +import com.android.messaging.datamodel.data.MediaPickerData.MediaPickerDataListener; +import com.android.messaging.util.Assert; +import com.android.messaging.util.OsUtil; + +/** + * Chooser which allows the user to select one or more existing images or videos + */ +class GalleryMediaChooser extends MediaChooser implements + GalleryGridView.GalleryGridViewListener, MediaPickerDataListener { + private final GalleryGridAdapter mAdapter; + private GalleryGridView mGalleryGridView; + private View mMissingPermissionView; + + GalleryMediaChooser(final MediaPicker mediaPicker) { + super(mediaPicker); + mAdapter = new GalleryGridAdapter(Factory.get().getApplicationContext(), null); + } + + @Override + public int getSupportedMediaTypes() { + return MediaPicker.MEDIA_TYPE_IMAGE | MediaPicker.MEDIA_TYPE_VIDEO; + } + + @Override + public View destroyView() { + mGalleryGridView.setAdapter(null); + mAdapter.setHostInterface(null); + // The loader is started only if startMediaPickerDataLoader() is called + if (OsUtil.hasStoragePermission()) { + mBindingRef.getData().destroyLoader(MediaPickerData.GALLERY_IMAGE_LOADER); + } + return super.destroyView(); + } + + @Override + public int getIconResource() { + return R.drawable.ic_image_light; + } + + @Override + public int getIconDescriptionResource() { + return R.string.mediapicker_galleryChooserDescription; + } + + @Override + public boolean canSwipeDown() { + return mGalleryGridView.canSwipeDown(); + } + + @Override + public void onItemSelected(final MessagePartData item) { + mMediaPicker.dispatchItemsSelected(item, !mGalleryGridView.isMultiSelectEnabled()); + } + + @Override + public void onItemUnselected(final MessagePartData item) { + mMediaPicker.dispatchItemUnselected(item); + } + + @Override + public void onConfirmSelection() { + // The user may only confirm if multiselect is enabled. + Assert.isTrue(mGalleryGridView.isMultiSelectEnabled()); + mMediaPicker.dispatchConfirmItemSelection(); + } + + @Override + public void onUpdate() { + mMediaPicker.invalidateOptionsMenu(); + } + + @Override + public void onCreateOptionsMenu(final MenuInflater inflater, final Menu menu) { + if (mView != null) { + mGalleryGridView.onCreateOptionsMenu(inflater, menu); + } + } + + @Override + public boolean onOptionsItemSelected(final MenuItem item) { + return (mView != null) ? mGalleryGridView.onOptionsItemSelected(item) : false; + } + + @Override + protected View createView(final ViewGroup container) { + final LayoutInflater inflater = getLayoutInflater(); + final View view = inflater.inflate( + R.layout.mediapicker_image_chooser, + container /* root */, + false /* attachToRoot */); + + mGalleryGridView = (GalleryGridView) view.findViewById(R.id.gallery_grid_view); + mAdapter.setHostInterface(mGalleryGridView); + mGalleryGridView.setAdapter(mAdapter); + mGalleryGridView.setHostInterface(this); + mGalleryGridView.setDraftMessageDataModel(mMediaPicker.getDraftMessageDataModel()); + if (OsUtil.hasStoragePermission()) { + startMediaPickerDataLoader(); + } + + mMissingPermissionView = view.findViewById(R.id.missing_permission_view); + updateForPermissionState(OsUtil.hasStoragePermission()); + return view; + } + + @Override + int getActionBarTitleResId() { + return R.string.mediapicker_gallery_title; + } + + @Override + public void onDocumentPickerItemClicked() { + mMediaPicker.launchDocumentPicker(); + } + + @Override + void updateActionBar(final ActionBar actionBar) { + super.updateActionBar(actionBar); + if (mGalleryGridView == null) { + return; + } + final int selectionCount = mGalleryGridView.getSelectionCount(); + if (selectionCount > 0 && mGalleryGridView.isMultiSelectEnabled()) { + actionBar.setTitle(getContext().getResources().getString( + R.string.mediapicker_gallery_title_selection, + selectionCount)); + } + } + + @Override + public void onMediaPickerDataUpdated(final MediaPickerData mediaPickerData, final Object data, + final int loaderId) { + mBindingRef.ensureBound(mediaPickerData); + Assert.equals(MediaPickerData.GALLERY_IMAGE_LOADER, loaderId); + Cursor rawCursor = null; + if (data instanceof Cursor) { + rawCursor = (Cursor) data; + } + // Before delivering the cursor, wrap around the local gallery cursor + // with an extra item for document picker integration in the front. + final MatrixCursor specialItemsCursor = + new MatrixCursor(GalleryGridItemData.SPECIAL_ITEM_COLUMNS); + specialItemsCursor.addRow(new Object[] { GalleryGridItemData.ID_DOCUMENT_PICKER_ITEM }); + final MergeCursor cursor = + new MergeCursor(new Cursor[] { specialItemsCursor, rawCursor }); + mAdapter.swapCursor(cursor); + } + + @Override + public void onResume() { + if (OsUtil.hasStoragePermission()) { + // Work around a bug in MediaStore where cursors querying the Files provider don't get + // updated for changes to Images.Media or Video.Media. + startMediaPickerDataLoader(); + } + } + + @Override + protected void setSelected(final boolean selected) { + super.setSelected(selected); + if (selected && !OsUtil.hasStoragePermission()) { + mMediaPicker.requestPermissions( + new String[] { Manifest.permission.READ_EXTERNAL_STORAGE }, + MediaPicker.GALLERY_PERMISSION_REQUEST_CODE); + } + } + + private void startMediaPickerDataLoader() { + mBindingRef.getData().startLoader(MediaPickerData.GALLERY_IMAGE_LOADER, mBindingRef, null, + this); + } + + @Override + protected void onRequestPermissionsResult( + final int requestCode, final String permissions[], final int[] grantResults) { + if (requestCode == MediaPicker.GALLERY_PERMISSION_REQUEST_CODE) { + final boolean permissionGranted = grantResults[0] == PackageManager.PERMISSION_GRANTED; + if (permissionGranted) { + startMediaPickerDataLoader(); + } + updateForPermissionState(permissionGranted); + } + } + + private void updateForPermissionState(final boolean granted) { + // onRequestPermissionsResult can sometimes get called before createView(). + if (mGalleryGridView == null) { + return; + } + + mGalleryGridView.setVisibility(granted ? View.VISIBLE : View.GONE); + mMissingPermissionView.setVisibility(granted ? View.GONE : View.VISIBLE); + } +} diff --git a/src/com/android/messaging/ui/mediapicker/HardwareCameraPreview.java b/src/com/android/messaging/ui/mediapicker/HardwareCameraPreview.java new file mode 100644 index 0000000..45d9579 --- /dev/null +++ b/src/com/android/messaging/ui/mediapicker/HardwareCameraPreview.java @@ -0,0 +1,118 @@ +/* + * 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.ui.mediapicker; + +import android.content.Context; +import android.graphics.SurfaceTexture; +import android.hardware.Camera; +import android.os.Parcelable; +import android.util.AttributeSet; +import android.view.TextureView; +import android.view.View; + +import java.io.IOException; + +/** + * A hardware accelerated preview texture for the camera. This is the preferred CameraPreview + * because it animates smoother. When hardware acceleration isn't available, SoftwareCameraPreview + * is used. + * + * There is a significant amount of duplication between HardwareCameraPreview and + * SoftwareCameraPreview which we can't easily share due to a lack of multiple inheritance, The + * implementations of the shared methods are delegated to CameraPreview + */ +public class HardwareCameraPreview extends TextureView implements CameraPreview.CameraPreviewHost { + private CameraPreview mPreview; + + public HardwareCameraPreview(final Context context, final AttributeSet attrs) { + super(context, attrs); + mPreview = new CameraPreview(this); + setSurfaceTextureListener(new SurfaceTextureListener() { + @Override + public void onSurfaceTextureAvailable(final SurfaceTexture surfaceTexture, final int i, final int i2) { + CameraManager.get().setSurface(mPreview); + } + + @Override + public void onSurfaceTextureSizeChanged(final SurfaceTexture surfaceTexture, final int i, final int i2) { + CameraManager.get().setSurface(mPreview); + } + + @Override + public boolean onSurfaceTextureDestroyed(final SurfaceTexture surfaceTexture) { + CameraManager.get().setSurface(null); + return true; + } + + @Override + public void onSurfaceTextureUpdated(final SurfaceTexture surfaceTexture) { + CameraManager.get().setSurface(mPreview); + } + }); + } + + @Override + protected void onVisibilityChanged(final View changedView, final int visibility) { + super.onVisibilityChanged(changedView, visibility); + mPreview.onVisibilityChanged(visibility); + } + + @Override + protected void onDetachedFromWindow() { + super.onDetachedFromWindow(); + mPreview.onDetachedFromWindow(); + } + + @Override + protected void onAttachedToWindow() { + super.onAttachedToWindow(); + mPreview.onAttachedToWindow(); + } + + @Override + protected void onRestoreInstanceState(final Parcelable state) { + super.onRestoreInstanceState(state); + mPreview.onRestoreInstanceState(); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + widthMeasureSpec = mPreview.getWidthMeasureSpec(widthMeasureSpec, heightMeasureSpec); + heightMeasureSpec = mPreview.getHeightMeasureSpec(widthMeasureSpec, heightMeasureSpec); + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + } + + @Override + public View getView() { + return this; + } + + @Override + public boolean isValid() { + return getSurfaceTexture() != null; + } + + @Override + public void startPreview(final Camera camera) throws IOException { + camera.setPreviewTexture(getSurfaceTexture()); + } + + @Override + public void onCameraPermissionGranted() { + mPreview.onCameraPermissionGranted(); + } +} diff --git a/src/com/android/messaging/ui/mediapicker/ImagePersistTask.java b/src/com/android/messaging/ui/mediapicker/ImagePersistTask.java new file mode 100644 index 0000000..637eb84 --- /dev/null +++ b/src/com/android/messaging/ui/mediapicker/ImagePersistTask.java @@ -0,0 +1,172 @@ +/* + * 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.ui.mediapicker; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Canvas; +import android.graphics.Matrix; +import android.net.Uri; + +import com.android.messaging.datamodel.MediaScratchFileProvider; +import com.android.messaging.util.Assert; +import com.android.messaging.util.ContentType; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.SafeAsyncTask; +import com.android.messaging.util.exif.ExifInterface; +import com.android.messaging.util.exif.ExifTag; + +import java.io.IOException; +import java.io.OutputStream; + +public class ImagePersistTask extends SafeAsyncTask<Void, Void, Void> { + private static final String JPEG_EXTENSION = "jpg"; + private static final String TAG = LogUtil.BUGLE_TAG; + + private int mWidth; + private int mHeight; + private final float mHeightPercent; + private final byte[] mBytes; + private final Context mContext; + private final CameraManager.MediaCallback mCallback; + private Uri mOutputUri; + private Exception mException; + + public ImagePersistTask( + final int width, + final int height, + final float heightPercent, + final byte[] bytes, + final Context context, + final CameraManager.MediaCallback callback) { + Assert.isTrue(heightPercent >= 0 && heightPercent <= 1); + Assert.notNull(bytes); + Assert.notNull(context); + Assert.notNull(callback); + mWidth = width; + mHeight = height; + mHeightPercent = heightPercent; + mBytes = bytes; + mContext = context; + mCallback = callback; + // TODO: We probably want to store directly in MMS storage to prevent this + // intermediate step + mOutputUri = MediaScratchFileProvider.buildMediaScratchSpaceUri(JPEG_EXTENSION); + } + + @Override + protected Void doInBackgroundTimed(final Void... params) { + OutputStream outputStream = null; + Bitmap bitmap = null; + Bitmap clippedBitmap = null; + try { + outputStream = + mContext.getContentResolver().openOutputStream(mOutputUri); + if (mHeightPercent != 1.0f) { + int orientation = android.media.ExifInterface.ORIENTATION_UNDEFINED; + final ExifInterface exifInterface = new ExifInterface(); + try { + exifInterface.readExif(mBytes); + final Integer orientationValue = + exifInterface.getTagIntValue(ExifInterface.TAG_ORIENTATION); + if (orientationValue != null) { + orientation = orientationValue.intValue(); + } + // The thumbnail is of the full image, but we're cropping it, so just clear + // the thumbnail + exifInterface.setCompressedThumbnail((byte[]) null); + } catch (IOException e) { + // Couldn't get exif tags, not the end of the world + } + bitmap = BitmapFactory.decodeByteArray(mBytes, 0, mBytes.length); + final int clippedWidth; + final int clippedHeight; + if (ExifInterface.getOrientationParams(orientation).invertDimensions) { + Assert.equals(mWidth, bitmap.getHeight()); + Assert.equals(mHeight, bitmap.getWidth()); + clippedWidth = (int) (mHeight * mHeightPercent); + clippedHeight = mWidth; + } else { + Assert.equals(mWidth, bitmap.getWidth()); + Assert.equals(mHeight, bitmap.getHeight()); + clippedWidth = mWidth; + clippedHeight = (int) (mHeight * mHeightPercent); + } + final int offsetTop = (bitmap.getHeight() - clippedHeight) / 2; + final int offsetLeft = (bitmap.getWidth() - clippedWidth) / 2; + mWidth = clippedWidth; + mHeight = clippedHeight; + clippedBitmap = Bitmap.createBitmap(clippedWidth, clippedHeight, + Bitmap.Config.ARGB_8888); + clippedBitmap.setDensity(bitmap.getDensity()); + final Canvas clippedBitmapCanvas = new Canvas(clippedBitmap); + final Matrix matrix = new Matrix(); + matrix.postTranslate(-offsetLeft, -offsetTop); + clippedBitmapCanvas.drawBitmap(bitmap, matrix, null /* paint */); + clippedBitmapCanvas.save(); + // EXIF data can take a big chunk of the file size and is often cleared by the + // carrier, only store orientation since that's critical + ExifTag orientationTag = exifInterface.getTag(ExifInterface.TAG_ORIENTATION); + exifInterface.clearExif(); + exifInterface.setTag(orientationTag); + exifInterface.writeExif(clippedBitmap, outputStream); + } else { + outputStream.write(mBytes); + } + } catch (final IOException e) { + mOutputUri = null; + mException = e; + LogUtil.e(TAG, "Unable to persist image to temp storage " + e); + } finally { + if (bitmap != null) { + bitmap.recycle(); + } + + if (clippedBitmap != null) { + clippedBitmap.recycle(); + } + + if (outputStream != null) { + try { + outputStream.flush(); + } catch (final IOException e) { + mOutputUri = null; + mException = e; + LogUtil.e(TAG, "error trying to flush and close the outputStream" + e); + } finally { + try { + outputStream.close(); + } catch (final IOException e) { + // Do nothing. + } + } + } + } + return null; + } + + @Override + protected void onPostExecute(final Void aVoid) { + if (mOutputUri != null) { + mCallback.onMediaReady(mOutputUri, ContentType.IMAGE_JPEG, mWidth, mHeight); + } else { + Assert.notNull(mException); + mCallback.onMediaFailed(mException); + } + } +} diff --git a/src/com/android/messaging/ui/mediapicker/LevelTrackingMediaRecorder.java b/src/com/android/messaging/ui/mediapicker/LevelTrackingMediaRecorder.java new file mode 100644 index 0000000..06730a3 --- /dev/null +++ b/src/com/android/messaging/ui/mediapicker/LevelTrackingMediaRecorder.java @@ -0,0 +1,223 @@ +/* + * 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.ui.mediapicker; + +import android.media.MediaRecorder; +import android.net.Uri; +import android.os.ParcelFileDescriptor; + +import com.android.messaging.Factory; +import com.android.messaging.R; +import com.android.messaging.datamodel.MediaScratchFileProvider; +import com.android.messaging.util.Assert; +import com.android.messaging.util.ContentType; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.SafeAsyncTask; +import com.android.messaging.util.UiUtils; + +import java.io.IOException; + +/** + * Wraps around the functionalities of MediaRecorder, performs routine setup for audio recording + * and updates the audio level to be displayed in UI. + * + * During the start and end of a recording session, we kick off a thread that polls for audio + * levels, and updates the thread-safe AudioLevelSource instance. Consumers may bind to the + * sound level by either polling from the level source, or register for a level change callback + * on the level source object. In Bugle, the UI element (SoundLevels) polls for the sound level + * on the UI thread by using animation ticks and invalidating itself. + * + * Aside from tracking sound levels, this also encapsulates the functionality to save the file + * to the scratch space. The saved file is returned by calling stopRecording(). + */ +public class LevelTrackingMediaRecorder { + // We refresh sound level every 100ms during a recording session. + private static final int REFRESH_INTERVAL_MILLIS = 100; + + // The native amplitude returned from MediaRecorder ranges from 0~32768 (unfortunately, this + // is not a constant that's defined anywhere, but the framework's Recorder app is using the + // same hard-coded number). Therefore, a constant is needed in order to make it 0~100. + private static final int MAX_AMPLITUDE_FACTOR = 32768 / 100; + + // We want to limit the max audio file size by the max message size allowed by MmsConfig, + // plus multiplied by this fudge ratio to guarantee that we don't go over limit. + private static final float MAX_SIZE_RATIO = 0.8f; + + // Default recorder settings for Bugle. + // TODO: Do we want these to be tweakable? + private static final int MEDIA_RECORDER_AUDIO_SOURCE = MediaRecorder.AudioSource.MIC; + private static final int MEDIA_RECORDER_OUTPUT_FORMAT = MediaRecorder.OutputFormat.THREE_GPP; + private static final int MEDIA_RECORDER_AUDIO_ENCODER = MediaRecorder.AudioEncoder.AMR_NB; + + private final AudioLevelSource mLevelSource; + private Thread mRefreshLevelThread; + private MediaRecorder mRecorder; + private Uri mOutputUri; + private ParcelFileDescriptor mOutputFD; + + public LevelTrackingMediaRecorder() { + mLevelSource = new AudioLevelSource(); + } + + public AudioLevelSource getLevelSource() { + return mLevelSource; + } + + /** + * @return if we are currently in a recording session. + */ + public boolean isRecording() { + return mRecorder != null; + } + + /** + * Start a new recording session. + * @return true if a session is successfully started; false if something went wrong or if + * we are already recording. + */ + public boolean startRecording(final MediaRecorder.OnErrorListener errorListener, + final MediaRecorder.OnInfoListener infoListener, int maxSize) { + synchronized (LevelTrackingMediaRecorder.class) { + if (mRecorder == null) { + mOutputUri = MediaScratchFileProvider.buildMediaScratchSpaceUri( + ContentType.THREE_GPP_EXTENSION); + mRecorder = new MediaRecorder(); + try { + // The scratch space file is a Uri, however MediaRecorder + // API only accepts absolute FD's. Therefore, get the + // FileDescriptor from the content resolver to ensure the + // directory is created and get the file path to output the + // audio to. + maxSize *= MAX_SIZE_RATIO; + mOutputFD = Factory.get().getApplicationContext() + .getContentResolver().openFileDescriptor(mOutputUri, "w"); + mRecorder.setAudioSource(MEDIA_RECORDER_AUDIO_SOURCE); + mRecorder.setOutputFormat(MEDIA_RECORDER_OUTPUT_FORMAT); + mRecorder.setAudioEncoder(MEDIA_RECORDER_AUDIO_ENCODER); + mRecorder.setOutputFile(mOutputFD.getFileDescriptor()); + mRecorder.setMaxFileSize(maxSize); + mRecorder.setOnErrorListener(errorListener); + mRecorder.setOnInfoListener(infoListener); + mRecorder.prepare(); + mRecorder.start(); + startTrackingSoundLevel(); + return true; + } catch (final Exception e) { + // There may be a device failure or I/O failure, record the error but + // don't fail. + LogUtil.e(LogUtil.BUGLE_TAG, "Something went wrong when starting " + + "media recorder. " + e); + UiUtils.showToastAtBottom(R.string.audio_recording_start_failed); + stopRecording(); + } + } else { + Assert.fail("Trying to start a new recording session while already recording!"); + } + return false; + } + } + + /** + * Stop the current recording session. + * @return the Uri of the output file, or null if not currently recording. + */ + public Uri stopRecording() { + synchronized (LevelTrackingMediaRecorder.class) { + if (mRecorder != null) { + try { + mRecorder.stop(); + } catch (final RuntimeException ex) { + // This may happen when the recording is too short, so just drop the recording + // in this case. + LogUtil.w(LogUtil.BUGLE_TAG, "Something went wrong when stopping " + + "media recorder. " + ex); + if (mOutputUri != null) { + final Uri outputUri = mOutputUri; + SafeAsyncTask.executeOnThreadPool(new Runnable() { + @Override + public void run() { + Factory.get().getApplicationContext().getContentResolver().delete( + outputUri, null, null); + } + }); + mOutputUri = null; + } + } finally { + mRecorder.release(); + mRecorder = null; + } + } else { + Assert.fail("Not currently recording!"); + return null; + } + } + + if (mOutputFD != null) { + try { + mOutputFD.close(); + } catch (final IOException e) { + // Nothing to do + } + mOutputFD = null; + } + + stopTrackingSoundLevel(); + return mOutputUri; + } + + private int getAmplitude() { + synchronized (LevelTrackingMediaRecorder.class) { + if (mRecorder != null) { + final int maxAmplitude = mRecorder.getMaxAmplitude() / MAX_AMPLITUDE_FACTOR; + return Math.min(maxAmplitude, 100); + } else { + return 0; + } + } + } + + private void startTrackingSoundLevel() { + stopTrackingSoundLevel(); + mRefreshLevelThread = new Thread() { + @Override + public void run() { + try { + while (true) { + synchronized (LevelTrackingMediaRecorder.class) { + if (mRecorder != null) { + mLevelSource.setSpeechLevel(getAmplitude()); + } else { + // The recording session is over, finish the thread. + return; + } + } + Thread.sleep(REFRESH_INTERVAL_MILLIS); + } + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + }; + mRefreshLevelThread.start(); + } + + private void stopTrackingSoundLevel() { + if (mRefreshLevelThread != null && mRefreshLevelThread.isAlive()) { + mRefreshLevelThread.interrupt(); + mRefreshLevelThread = null; + } + } +} diff --git a/src/com/android/messaging/ui/mediapicker/MediaChooser.java b/src/com/android/messaging/ui/mediapicker/MediaChooser.java new file mode 100644 index 0000000..9ac0d1b --- /dev/null +++ b/src/com/android/messaging/ui/mediapicker/MediaChooser.java @@ -0,0 +1,216 @@ +/* + * 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.ui.mediapicker; + +import android.app.FragmentManager; +import android.content.Context; +import android.support.v7.app.ActionBar; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageButton; + +import com.android.messaging.R; +import com.android.messaging.datamodel.binding.ImmutableBindingRef; +import com.android.messaging.datamodel.data.MediaPickerData; +import com.android.messaging.datamodel.data.DraftMessageData.DraftMessageSubscriptionDataProvider; +import com.android.messaging.ui.BasePagerViewHolder; +import com.android.messaging.util.Assert; +import com.android.messaging.util.OsUtil; + +abstract class MediaChooser extends BasePagerViewHolder + implements DraftMessageSubscriptionDataProvider { + /** The media picker that the chooser is hosted in */ + protected final MediaPicker mMediaPicker; + + /** Referencing the main media picker binding to perform data loading */ + protected final ImmutableBindingRef<MediaPickerData> mBindingRef; + + /** True if this is the selected chooser */ + protected boolean mSelected; + + /** True if this chooser is open */ + protected boolean mOpen; + + /** The button to show in the tab strip */ + private ImageButton mTabButton; + + /** Used by subclasses to indicate that no loader is required from the data model in order for + * this chooser to function. + */ + public static final int NO_LOADER_REQUIRED = -1; + + /** + * Initializes a new instance of the Chooser class + * @param mediaPicker The media picker that the chooser is hosted in + */ + MediaChooser(final MediaPicker mediaPicker) { + Assert.notNull(mediaPicker); + mMediaPicker = mediaPicker; + mBindingRef = mediaPicker.getMediaPickerDataBinding(); + mSelected = false; + } + + protected void setSelected(final boolean selected) { + mSelected = selected; + if (selected) { + // If we're selected, it must be open + mOpen = true; + } + if (mTabButton != null) { + mTabButton.setSelected(selected); + mTabButton.setAlpha(selected ? 1 : 0.5f); + } + } + + ImageButton getTabButton() { + return mTabButton; + } + + void onCreateTabButton(final LayoutInflater inflater, final ViewGroup parent) { + mTabButton = (ImageButton) inflater.inflate( + R.layout.mediapicker_tab_button, + parent, + false /* addToParent */); + mTabButton.setImageResource(getIconResource()); + mTabButton.setContentDescription( + inflater.getContext().getResources().getString(getIconDescriptionResource())); + setSelected(mSelected); + mTabButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(final View view) { + mMediaPicker.selectChooser(MediaChooser.this); + } + }); + } + + protected Context getContext() { + return mMediaPicker.getActivity(); + } + + protected FragmentManager getFragmentManager() { + return OsUtil.isAtLeastJB_MR1() ? mMediaPicker.getChildFragmentManager() : + mMediaPicker.getFragmentManager(); + } + protected LayoutInflater getLayoutInflater() { + return LayoutInflater.from(getContext()); + } + + /** Allows the chooser to handle full screen change */ + void onFullScreenChanged(final boolean fullScreen) {} + + /** Allows the chooser to handle the chooser being opened or closed */ + void onOpenedChanged(final boolean open) { + mOpen = open; + } + + /** @return The bit field of media types that this chooser can pick */ + public abstract int getSupportedMediaTypes(); + + /** @return The resource id of the icon for the chooser */ + abstract int getIconResource(); + + /** @return The resource id of the string to use for the accessibility text of the icon */ + abstract int getIconDescriptionResource(); + + /** + * Sets up the action bar to show the current state of the full-screen chooser + * @param actionBar The action bar to populate + */ + void updateActionBar(final ActionBar actionBar) { + final int actionBarTitleResId = getActionBarTitleResId(); + if (actionBarTitleResId == 0) { + actionBar.hide(); + } else { + actionBar.setCustomView(null); + actionBar.setDisplayOptions(ActionBar.DISPLAY_SHOW_TITLE); + actionBar.setDisplayHomeAsUpEnabled(true); + actionBar.show(); + // Use X instead of <- in the action bar + actionBar.setHomeAsUpIndicator(R.drawable.ic_remove_small_light); + actionBar.setTitle(actionBarTitleResId); + } + } + + /** + * Returns the resource Id used for the action bar title. + */ + abstract int getActionBarTitleResId(); + + /** + * Throws an exception if the media chooser object doesn't require data support. + */ + public void onDataUpdated(final Object data, final int loaderId) { + throw new IllegalStateException(); + } + + /** + * Called by the MediaPicker to determine whether this panel can be swiped down further. If + * not, then a swipe down gestured will be captured by the MediaPickerPanel to shrink the + * entire panel. + */ + public boolean canSwipeDown() { + return false; + } + + /** + * Typically the media picker is closed when the IME is opened, but this allows the chooser to + * specify that showing the IME is okay while the chooser is up + */ + public boolean canShowIme() { + return false; + } + + public boolean onBackPressed() { + return false; + } + + public void onCreateOptionsMenu(final MenuInflater inflater, final Menu menu) { + } + + public boolean onOptionsItemSelected(final MenuItem item) { + return false; + } + + public void setThemeColor(final int color) { + } + + /** + * Returns true if the chooser is owning any incoming touch events, so that the media picker + * panel won't process it and slide the panel. + */ + public boolean isHandlingTouch() { + return false; + } + + public void stopTouchHandling() { + } + + @Override + public int getConversationSelfSubId() { + return mMediaPicker.getConversationSelfSubId(); + } + + /** Optional activity life-cycle methods to be overridden by subclasses */ + public void onPause() { } + public void onResume() { } + protected void onRequestPermissionsResult( + final int requestCode, final String permissions[], final int[] grantResults) { } +} diff --git a/src/com/android/messaging/ui/mediapicker/MediaPicker.java b/src/com/android/messaging/ui/mediapicker/MediaPicker.java new file mode 100644 index 0000000..f441d09 --- /dev/null +++ b/src/com/android/messaging/ui/mediapicker/MediaPicker.java @@ -0,0 +1,736 @@ +/* + * 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.ui.mediapicker; + + +import android.app.Activity; +import android.app.Fragment; +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import android.os.Handler; +import android.support.v4.view.PagerAdapter; +import android.support.v4.view.ViewPager; +import android.support.v7.app.ActionBar; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageButton; +import android.widget.LinearLayout; + +import com.android.messaging.Factory; +import com.android.messaging.R; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.binding.Binding; +import com.android.messaging.datamodel.binding.BindingBase; +import com.android.messaging.datamodel.binding.ImmutableBindingRef; +import com.android.messaging.datamodel.data.DraftMessageData; +import com.android.messaging.datamodel.data.MediaPickerData; +import com.android.messaging.datamodel.data.MessagePartData; +import com.android.messaging.datamodel.data.PendingAttachmentData; +import com.android.messaging.datamodel.data.DraftMessageData.DraftMessageSubscriptionDataProvider; +import com.android.messaging.ui.BugleActionBarActivity; +import com.android.messaging.ui.FixedViewPagerAdapter; +import com.android.messaging.ui.mediapicker.DocumentImagePicker.SelectionListener; +import com.android.messaging.util.AccessibilityUtil; +import com.android.messaging.util.Assert; +import com.android.messaging.util.UiUtils; +import com.google.common.annotations.VisibleForTesting; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +/** + * Fragment used to select or capture media to be added to the message + */ +public class MediaPicker extends Fragment implements DraftMessageSubscriptionDataProvider { + /** The listener interface for events from the media picker */ + public interface MediaPickerListener { + /** Called when the media picker is opened so the host can accommodate the UI */ + void onOpened(); + + /** + * Called when the media picker goes into or leaves full screen mode so the host can + * accommodate the fullscreen UI + */ + void onFullScreenChanged(boolean fullScreen); + + /** + * Called when the user selects one or more items + * @param items The list of items which were selected + */ + void onItemsSelected(Collection<MessagePartData> items, boolean dismissMediaPicker); + + /** + * Called when the user unselects one item. + */ + void onItemUnselected(MessagePartData item); + + /** + * Called when the media picker is closed. Always called immediately after onItemsSelected + */ + void onDismissed(); + + /** + * Called when media item selection is confirmed in a multi-select action. + */ + void onConfirmItemSelection(); + + /** + * Called when a pending attachment is added. + * @param pendingItem the pending attachment data being loaded. + */ + void onPendingItemAdded(PendingAttachmentData pendingItem); + + /** + * Called when a new media chooser is selected. + */ + void onChooserSelected(final int chooserIndex); + } + + /** The tag used when registering and finding this fragment */ + public static final String FRAGMENT_TAG = "mediapicker"; + + // Media type constants that the media picker supports + public static final int MEDIA_TYPE_DEFAULT = 0x0000; + public static final int MEDIA_TYPE_NONE = 0x0000; + public static final int MEDIA_TYPE_IMAGE = 0x0001; + public static final int MEDIA_TYPE_VIDEO = 0x0002; + public static final int MEDIA_TYPE_AUDIO = 0x0004; + public static final int MEDIA_TYPE_VCARD = 0x0008; + public static final int MEDIA_TYPE_LOCATION = 0x0010; + private static final int MEDA_TYPE_INVALID = 0x0020; + public static final int MEDIA_TYPE_ALL = 0xFFFF; + + /** The listener to call when events occur */ + private MediaPickerListener mListener; + + /** The handler used to dispatch calls to the listener */ + private Handler mListenerHandler; + + /** The bit flags of media types supported */ + private int mSupportedMediaTypes; + + /** The list of choosers which could be within the media picker */ + private final MediaChooser[] mChoosers; + + /** The list of currently enabled choosers */ + private final ArrayList<MediaChooser> mEnabledChoosers; + + /** The currently selected chooser */ + private MediaChooser mSelectedChooser; + + /** The main panel that controls the custom layout */ + private MediaPickerPanel mMediaPickerPanel; + + /** The linear layout that holds the icons to select individual chooser tabs */ + private LinearLayout mTabStrip; + + /** The view pager to swap between choosers */ + private ViewPager mViewPager; + + /** The current pager adapter for the view pager */ + private FixedViewPagerAdapter<MediaChooser> mPagerAdapter; + + /** True if the media picker is visible */ + private boolean mOpen; + + /** The theme color to use to make the media picker match the rest of the UI */ + private int mThemeColor; + + @VisibleForTesting + final Binding<MediaPickerData> mBinding = BindingBase.createBinding(this); + + /** Handles picking image from the document picker */ + private DocumentImagePicker mDocumentImagePicker; + + /** Provides subscription-related data to access per-subscription configurations. */ + private DraftMessageSubscriptionDataProvider mSubscriptionDataProvider; + + /** Provides access to DraftMessageData associated with the current conversation */ + private ImmutableBindingRef<DraftMessageData> mDraftMessageDataModel; + + public MediaPicker() { + this(Factory.get().getApplicationContext()); + } + + public MediaPicker(final Context context) { + mBinding.bind(DataModel.get().createMediaPickerData(context)); + mEnabledChoosers = new ArrayList<MediaChooser>(); + mChoosers = new MediaChooser[] { + new CameraMediaChooser(this), + new GalleryMediaChooser(this), + new AudioMediaChooser(this), + }; + + mOpen = false; + setSupportedMediaTypes(MEDIA_TYPE_ALL); + } + + private boolean mIsAttached; + private int mStartingMediaTypeOnAttach = MEDA_TYPE_INVALID; + private boolean mAnimateOnAttach; + + @Override + public void onAttach (final Activity activity) { + super.onAttach(activity); + mIsAttached = true; + if (mStartingMediaTypeOnAttach != MEDA_TYPE_INVALID) { + // open() was previously called. Do the pending open now. + doOpen(mStartingMediaTypeOnAttach, mAnimateOnAttach); + } + } + + @Override + public void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + mBinding.getData().init(getLoaderManager()); + mDocumentImagePicker = new DocumentImagePicker(this, + new SelectionListener() { + @Override + public void onDocumentSelected(final PendingAttachmentData data) { + if (mBinding.isBound()) { + dispatchPendingItemAdded(data); + } + } + }); + } + + @Override + public View onCreateView( + final LayoutInflater inflater, + final ViewGroup container, + final Bundle savedInstanceState) { + mMediaPickerPanel = (MediaPickerPanel) inflater.inflate( + R.layout.mediapicker_fragment, + container, + false); + mMediaPickerPanel.setMediaPicker(this); + mTabStrip = (LinearLayout) mMediaPickerPanel.findViewById(R.id.mediapicker_tabstrip); + mTabStrip.setBackgroundColor(mThemeColor); + for (final MediaChooser chooser : mChoosers) { + chooser.onCreateTabButton(inflater, mTabStrip); + final boolean enabled = (chooser.getSupportedMediaTypes() & mSupportedMediaTypes) != + MEDIA_TYPE_NONE; + final ImageButton tabButton = chooser.getTabButton(); + if (tabButton != null) { + tabButton.setVisibility(enabled ? View.VISIBLE : View.GONE); + mTabStrip.addView(tabButton); + } + } + + mViewPager = (ViewPager) mMediaPickerPanel.findViewById(R.id.mediapicker_view_pager); + mViewPager.setOnPageChangeListener(new ViewPager.OnPageChangeListener() { + @Override + public void onPageScrolled( + final int position, + final float positionOffset, + final int positionOffsetPixels) { + } + + @Override + public void onPageSelected(int position) { + // The position returned is relative to if we are in RtL mode. This class never + // switches the indices of the elements if we are in RtL mode so we need to + // translate the index back. For example, if the user clicked the item most to the + // right in RtL mode we would want the index to appear as 0 here, however the + // position returned would the last possible index. + if (UiUtils.isRtlMode()) { + position = mEnabledChoosers.size() - 1 - position; + } + selectChooser(mEnabledChoosers.get(position)); + } + + @Override + public void onPageScrollStateChanged(final int state) { + } + }); + // Camera initialization is expensive, so don't realize offscreen pages if not needed. + mViewPager.setOffscreenPageLimit(0); + mViewPager.setAdapter(mPagerAdapter); + final boolean isTouchExplorationEnabled = AccessibilityUtil.isTouchExplorationEnabled( + getActivity()); + mMediaPickerPanel.setFullScreenOnly(isTouchExplorationEnabled); + mMediaPickerPanel.setExpanded(mOpen, true, mEnabledChoosers.indexOf(mSelectedChooser)); + return mMediaPickerPanel; + } + + @Override + public void onPause() { + super.onPause(); + CameraManager.get().onPause(); + for (final MediaChooser chooser : mEnabledChoosers) { + chooser.onPause(); + } + } + + @Override + public void onResume() { + super.onResume(); + CameraManager.get().onResume(); + + for (final MediaChooser chooser : mEnabledChoosers) { + chooser.onResume(); + } + } + + @Override + public void onActivityResult(final int requestCode, final int resultCode, final Intent data) { + super.onActivityResult(requestCode, resultCode, data); + mDocumentImagePicker.onActivityResult(requestCode, resultCode, data); + } + + @Override + public void onDestroy() { + super.onDestroy(); + mBinding.unbind(); + } + + /** + * Sets the theme color to make the media picker match the surrounding UI + * @param themeColor The new theme color + */ + public void setConversationThemeColor(final int themeColor) { + mThemeColor = themeColor; + if (mTabStrip != null) { + mTabStrip.setBackgroundColor(mThemeColor); + } + + for (final MediaChooser chooser : mEnabledChoosers) { + chooser.setThemeColor(mThemeColor); + } + } + + /** + * Gets the current conversation theme color. + */ + public int getConversationThemeColor() { + return mThemeColor; + } + + public void setDraftMessageDataModel(final BindingBase<DraftMessageData> draftBinding) { + mDraftMessageDataModel = Binding.createBindingReference(draftBinding); + } + + public ImmutableBindingRef<DraftMessageData> getDraftMessageDataModel() { + return mDraftMessageDataModel; + } + + public void setSubscriptionDataProvider(final DraftMessageSubscriptionDataProvider provider) { + mSubscriptionDataProvider = provider; + } + + @Override + public int getConversationSelfSubId() { + return mSubscriptionDataProvider.getConversationSelfSubId(); + } + + /** + * Opens the media picker and optionally shows the chooser for the supplied media type + * @param startingMediaType The media type of the chooser to open if {@link #MEDIA_TYPE_DEFAULT} + * is used, then the default chooser from saved shared prefs is opened + */ + public void open(final int startingMediaType, final boolean animate) { + mOpen = true; + if (mIsAttached) { + doOpen(startingMediaType, animate); + } else { + // open() can get called immediately after the MediaPicker is created. In that case, + // we defer doing work as it may require an attached fragment (eg. calling + // Fragment#requestPermission) + mStartingMediaTypeOnAttach = startingMediaType; + mAnimateOnAttach = animate; + } + } + + private void doOpen(int startingMediaType, final boolean animate) { + final boolean isTouchExplorationEnabled = AccessibilityUtil.isTouchExplorationEnabled( + // getActivity() will be null at this point + Factory.get().getApplicationContext()); + + // If no specific starting type is specified (i.e. MEDIA_TYPE_DEFAULT), try to get the + // last opened chooser index from shared prefs. + if (startingMediaType == MEDIA_TYPE_DEFAULT) { + final int selectedChooserIndex = mBinding.getData().getSelectedChooserIndex(); + if (selectedChooserIndex >= 0 && selectedChooserIndex < mEnabledChoosers.size()) { + selectChooser(mEnabledChoosers.get(selectedChooserIndex)); + } else { + // This is the first time the picker is being used + if (isTouchExplorationEnabled) { + // Accessibility defaults to audio attachment mode. + startingMediaType = MEDIA_TYPE_AUDIO; + } + } + } + + if (mSelectedChooser == null) { + for (final MediaChooser chooser : mEnabledChoosers) { + if (startingMediaType == MEDIA_TYPE_DEFAULT || + (startingMediaType & chooser.getSupportedMediaTypes()) != MEDIA_TYPE_NONE) { + selectChooser(chooser); + break; + } + } + } + + if (mSelectedChooser == null) { + // Fall back to the first chooser. + selectChooser(mEnabledChoosers.get(0)); + } + + if (mMediaPickerPanel != null) { + mMediaPickerPanel.setFullScreenOnly(isTouchExplorationEnabled); + mMediaPickerPanel.setExpanded(true, animate, + mEnabledChoosers.indexOf(mSelectedChooser)); + } + } + + /** @return True if the media picker is open */ + public boolean isOpen() { + return mOpen; + } + + /** + * Sets the list of media types to allow the user to select + * @param mediaTypes The bit flags of media types to allow. Can be any combination of the + * MEDIA_TYPE_* values + */ + void setSupportedMediaTypes(final int mediaTypes) { + mSupportedMediaTypes = mediaTypes; + mEnabledChoosers.clear(); + boolean selectNextChooser = false; + for (final MediaChooser chooser : mChoosers) { + final boolean enabled = (chooser.getSupportedMediaTypes() & mSupportedMediaTypes) != + MEDIA_TYPE_NONE; + if (enabled) { + // TODO Add a way to inform the chooser which media types are supported + mEnabledChoosers.add(chooser); + if (selectNextChooser) { + selectChooser(chooser); + selectNextChooser = false; + } + } else if (mSelectedChooser == chooser) { + selectNextChooser = true; + } + final ImageButton tabButton = chooser.getTabButton(); + if (tabButton != null) { + tabButton.setVisibility(enabled ? View.VISIBLE : View.GONE); + } + } + + if (selectNextChooser && mEnabledChoosers.size() > 0) { + selectChooser(mEnabledChoosers.get(0)); + } + final MediaChooser[] enabledChoosers = new MediaChooser[mEnabledChoosers.size()]; + mEnabledChoosers.toArray(enabledChoosers); + mPagerAdapter = new FixedViewPagerAdapter<MediaChooser>(enabledChoosers); + if (mViewPager != null) { + mViewPager.setAdapter(mPagerAdapter); + } + + // Only rebind data if we are currently bound. Otherwise, we must have not + // bound to any data yet and should wait until onCreate() to bind data. + if (mBinding.isBound() && getActivity() != null) { + mBinding.unbind(); + mBinding.bind(DataModel.get().createMediaPickerData(getActivity())); + mBinding.getData().init(getLoaderManager()); + } + } + + ViewPager getViewPager() { + return mViewPager; + } + + /** Hides the media picker, and frees up any resources it’s using */ + public void dismiss(final boolean animate) { + mOpen = false; + if (mMediaPickerPanel != null) { + mMediaPickerPanel.setExpanded(false, animate, MediaPickerPanel.PAGE_NOT_SET); + } + mSelectedChooser = null; + } + + /** + * Sets the listener for the media picker events + * @param listener The listener which will receive events + */ + public void setListener(final MediaPickerListener listener) { + Assert.isMainThread(); + mListener = listener; + mListenerHandler = listener != null ? new Handler() : null; + } + + /** @return True if the media picker is in full-screen mode */ + public boolean isFullScreen() { + return mMediaPickerPanel != null && mMediaPickerPanel.isFullScreen(); + } + + public void setFullScreen(final boolean fullScreen) { + mMediaPickerPanel.setFullScreenView(fullScreen, true); + } + + public void updateActionBar(final ActionBar actionBar) { + if (getActivity() == null) { + return; + } + if (isFullScreen() && mSelectedChooser != null) { + mSelectedChooser.updateActionBar(actionBar); + } else { + actionBar.hide(); + } + } + + /** + * Selects a new chooser + * @param newSelectedChooser The newly selected chooser + */ + void selectChooser(final MediaChooser newSelectedChooser) { + if (mSelectedChooser == newSelectedChooser) { + return; + } + + if (mSelectedChooser != null) { + mSelectedChooser.setSelected(false); + } + mSelectedChooser = newSelectedChooser; + if (mSelectedChooser != null) { + mSelectedChooser.setSelected(true); + } + + final int chooserIndex = mEnabledChoosers.indexOf(mSelectedChooser); + if (mViewPager != null) { + mViewPager.setCurrentItem(chooserIndex, true /* smoothScroll */); + } + + if (isFullScreen()) { + invalidateOptionsMenu(); + } + + // Save the newly selected chooser's index so we may directly switch to it the + // next time user opens the media picker. + mBinding.getData().saveSelectedChooserIndex(chooserIndex); + if (mMediaPickerPanel != null) { + mMediaPickerPanel.onChooserChanged(); + } + dispatchChooserSelected(chooserIndex); + } + + public boolean canShowIme() { + if (mSelectedChooser != null) { + return mSelectedChooser.canShowIme(); + } + return false; + } + + public boolean onBackPressed() { + return mSelectedChooser != null && mSelectedChooser.onBackPressed(); + } + + void invalidateOptionsMenu() { + ((BugleActionBarActivity) getActivity()).supportInvalidateOptionsMenu(); + } + + void dispatchOpened() { + setHasOptionsMenu(false); + mOpen = true; + mPagerAdapter.notifyDataSetChanged(); + if (mListener != null) { + mListenerHandler.post(new Runnable() { + @Override + public void run() { + mListener.onOpened(); + } + }); + } + if (mSelectedChooser != null) { + mSelectedChooser.onFullScreenChanged(false); + mSelectedChooser.onOpenedChanged(true); + } + } + + void dispatchDismissed() { + setHasOptionsMenu(false); + mOpen = false; + if (mListener != null) { + mListenerHandler.post(new Runnable() { + @Override + public void run() { + mListener.onDismissed(); + } + }); + } + if (mSelectedChooser != null) { + mSelectedChooser.onOpenedChanged(false); + } + } + + void dispatchFullScreen(final boolean fullScreen) { + setHasOptionsMenu(fullScreen); + if (mListener != null) { + mListenerHandler.post(new Runnable() { + @Override + public void run() { + mListener.onFullScreenChanged(fullScreen); + } + }); + } + if (mSelectedChooser != null) { + mSelectedChooser.onFullScreenChanged(fullScreen); + } + } + + void dispatchItemsSelected(final MessagePartData item, final boolean dismissMediaPicker) { + final List<MessagePartData> items = new ArrayList<MessagePartData>(1); + items.add(item); + dispatchItemsSelected(items, dismissMediaPicker); + } + + void dispatchItemsSelected(final Collection<MessagePartData> items, + final boolean dismissMediaPicker) { + if (mListener != null) { + mListenerHandler.post(new Runnable() { + @Override + public void run() { + mListener.onItemsSelected(items, dismissMediaPicker); + } + }); + } + + if (isFullScreen() && !dismissMediaPicker) { + invalidateOptionsMenu(); + } + } + + void dispatchItemUnselected(final MessagePartData item) { + if (mListener != null) { + mListenerHandler.post(new Runnable() { + @Override + public void run() { + mListener.onItemUnselected(item); + } + }); + } + + if (isFullScreen()) { + invalidateOptionsMenu(); + } + } + + void dispatchConfirmItemSelection() { + if (mListener != null) { + mListenerHandler.post(new Runnable() { + @Override + public void run() { + mListener.onConfirmItemSelection(); + } + }); + } + } + + void dispatchPendingItemAdded(final PendingAttachmentData pendingItem) { + if (mListener != null) { + mListenerHandler.post(new Runnable() { + @Override + public void run() { + mListener.onPendingItemAdded(pendingItem); + } + }); + } + + if (isFullScreen()) { + invalidateOptionsMenu(); + } + } + + void dispatchChooserSelected(final int chooserIndex) { + if (mListener != null) { + mListenerHandler.post(new Runnable() { + @Override + public void run() { + mListener.onChooserSelected(chooserIndex); + } + }); + } + } + + public boolean canSwipeDownChooser() { + return mSelectedChooser == null ? false : mSelectedChooser.canSwipeDown(); + } + + public boolean isChooserHandlingTouch() { + return mSelectedChooser == null ? false : mSelectedChooser.isHandlingTouch(); + } + + public void stopChooserTouchHandling() { + if (mSelectedChooser != null) { + mSelectedChooser.stopTouchHandling(); + } + } + + boolean getChooserShowsActionBarInFullScreen() { + return mSelectedChooser == null ? false : mSelectedChooser.getActionBarTitleResId() != 0; + } + + @Override + public void onCreateOptionsMenu(final Menu menu, final MenuInflater inflater) { + if (mSelectedChooser != null) { + mSelectedChooser.onCreateOptionsMenu(inflater, menu); + } + } + + @Override + public boolean onOptionsItemSelected(final MenuItem item) { + return (mSelectedChooser != null && mSelectedChooser.onOptionsItemSelected(item)) || + super.onOptionsItemSelected(item); + } + + PagerAdapter getPagerAdapter() { + return mPagerAdapter; + } + + public void resetViewHolderState() { + mPagerAdapter.resetState(); + } + + /** + * Launch an external picker to pick item from document picker as attachment. + */ + public void launchDocumentPicker() { + mDocumentImagePicker.launchPicker(); + } + + public ImmutableBindingRef<MediaPickerData> getMediaPickerDataBinding() { + return BindingBase.createBindingReference(mBinding); + } + + protected static final int CAMERA_PERMISSION_REQUEST_CODE = 1; + protected static final int LOCATION_PERMISSION_REQUEST_CODE = 2; + protected static final int RECORD_AUDIO_PERMISSION_REQUEST_CODE = 3; + protected static final int GALLERY_PERMISSION_REQUEST_CODE = 4; + + @Override + public void onRequestPermissionsResult( + final int requestCode, final String permissions[], final int[] grantResults) { + if (mSelectedChooser != null) { + mSelectedChooser.onRequestPermissionsResult(requestCode, permissions, grantResults); + } + } +} diff --git a/src/com/android/messaging/ui/mediapicker/MediaPickerGridView.java b/src/com/android/messaging/ui/mediapicker/MediaPickerGridView.java new file mode 100644 index 0000000..cc3a4a1 --- /dev/null +++ b/src/com/android/messaging/ui/mediapicker/MediaPickerGridView.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.ui.mediapicker; + +import android.content.Context; +import android.util.AttributeSet; +import android.widget.GridView; + +public class MediaPickerGridView extends GridView { + + public MediaPickerGridView(final Context context, final AttributeSet attrs) { + super(context, attrs); + } + + /** + * Returns if the grid view can be swiped down further. It cannot be swiped down + * if there's no item or if we are already at the top. + */ + public boolean canSwipeDown() { + if (getAdapter() == null || getAdapter().getCount() == 0 || getChildCount() == 0) { + return false; + } + + final int firstVisiblePosition = getFirstVisiblePosition(); + if (firstVisiblePosition == 0 && getChildAt(0).getTop() >= 0) { + return false; + } + return true; + } +} diff --git a/src/com/android/messaging/ui/mediapicker/MediaPickerPanel.java b/src/com/android/messaging/ui/mediapicker/MediaPickerPanel.java new file mode 100644 index 0000000..56b0a03 --- /dev/null +++ b/src/com/android/messaging/ui/mediapicker/MediaPickerPanel.java @@ -0,0 +1,563 @@ +/* + * 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.ui.mediapicker; + +import android.content.Context; +import android.content.res.Resources; +import android.os.Handler; +import android.util.AttributeSet; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewConfiguration; +import android.view.ViewGroup; +import android.view.animation.Animation; +import android.view.animation.Transformation; +import android.widget.LinearLayout; + +import com.android.messaging.R; +import com.android.messaging.ui.PagingAwareViewPager; +import com.android.messaging.util.Assert; +import com.android.messaging.util.OsUtil; +import com.android.messaging.util.UiUtils; + +/** + * Custom layout panel which makes the MediaPicker animations seamless and synchronized + * Designed to be very specific to the MediaPicker's usage + */ +public class MediaPickerPanel extends ViewGroup { + /** + * The window of time in which we might to decide to reinterpret the intent of a gesture. + */ + private static final long TOUCH_RECAPTURE_WINDOW_MS = 500L; + + // The two view components to layout + private LinearLayout mTabStrip; + private boolean mFullScreenOnly; + private PagingAwareViewPager mViewPager; + + /** + * True if the MediaPicker is full screen or animating into it + */ + private boolean mFullScreen; + + /** + * True if the MediaPicker is open at all + */ + private boolean mExpanded; + + /** + * The current desired height of the MediaPicker. This property may be animated and the + * measure pass uses it to determine what size the components are. + */ + private int mCurrentDesiredHeight; + + private final Handler mHandler = new Handler(); + + /** + * The media picker for dispatching events to the MediaPicker's listener + */ + private MediaPicker mMediaPicker; + + /** + * The computed default "half-screen" height of the view pager in px + */ + private final int mDefaultViewPagerHeight; + + /** + * The action bar height used to compute the padding on the view pager when it's full screen. + */ + private final int mActionBarHeight; + + private TouchHandler mTouchHandler; + + static final int PAGE_NOT_SET = -1; + + public MediaPickerPanel(final Context context, final AttributeSet attrs) { + super(context, attrs); + // Cache the computed dimension + mDefaultViewPagerHeight = getResources().getDimensionPixelSize( + R.dimen.mediapicker_default_chooser_height); + mActionBarHeight = getResources().getDimensionPixelSize(R.dimen.action_bar_height); + } + + @Override + protected void onFinishInflate() { + super.onFinishInflate(); + mTabStrip = (LinearLayout) findViewById(R.id.mediapicker_tabstrip); + mViewPager = (PagingAwareViewPager) findViewById(R.id.mediapicker_view_pager); + mTouchHandler = new TouchHandler(); + setOnTouchListener(mTouchHandler); + mViewPager.setOnTouchListener(mTouchHandler); + + // Make sure full screen mode is updated in landscape mode change when the panel is open. + addOnLayoutChangeListener(new OnLayoutChangeListener() { + private boolean mLandscapeMode = UiUtils.isLandscapeMode(); + + @Override + public void onLayoutChange(View v, int left, int top, int right, int bottom, + int oldLeft, int oldTop, int oldRight, int oldBottom) { + final boolean newLandscapeMode = UiUtils.isLandscapeMode(); + if (mLandscapeMode != newLandscapeMode) { + mLandscapeMode = newLandscapeMode; + if (mExpanded) { + setExpanded(mExpanded, false /* animate */, mViewPager.getCurrentItem(), + true /* force */); + } + } + } + }); + } + + @Override + protected void onMeasure(final int widthMeasureSpec, final int heightMeasureSpec) { + int requestedHeight = MeasureSpec.getSize(heightMeasureSpec); + if (mMediaPicker.getChooserShowsActionBarInFullScreen()) { + requestedHeight -= mActionBarHeight; + } + int desiredHeight = Math.min(mCurrentDesiredHeight, requestedHeight); + if (mExpanded && desiredHeight == 0) { + // If we want to be shown, we have to have a non-0 height. Returning a height of 0 will + // cause the framework to abort the animation from 0, so we must always have some + // height once we start expanding + desiredHeight = 1; + } else if (!mExpanded && desiredHeight == 0) { + mViewPager.setVisibility(View.GONE); + mViewPager.setAdapter(null); + } + + measureChild(mTabStrip, widthMeasureSpec, heightMeasureSpec); + + int tabStripHeight; + if (requiresFullScreen()) { + // Ensure that the tab strip is always visible, even in full screen. + tabStripHeight = mTabStrip.getMeasuredHeight(); + } else { + // Slide out the tab strip at the end of the animation to full screen. + tabStripHeight = Math.min(mTabStrip.getMeasuredHeight(), + requestedHeight - desiredHeight); + } + + // If we are animating and have an interim desired height, use the default height. We can't + // take the max here as on some devices the mDefaultViewPagerHeight may be too big in + // landscape mode after animation. + final int tabAdjustedDesiredHeight = desiredHeight - tabStripHeight; + final int viewPagerHeight = + tabAdjustedDesiredHeight <= 1 ? mDefaultViewPagerHeight : tabAdjustedDesiredHeight; + + int viewPagerHeightMeasureSpec = MeasureSpec.makeMeasureSpec( + viewPagerHeight, MeasureSpec.EXACTLY); + measureChild(mViewPager, widthMeasureSpec, viewPagerHeightMeasureSpec); + setMeasuredDimension(mViewPager.getMeasuredWidth(), desiredHeight); + } + + @Override + protected void onLayout(final boolean changed, final int left, final int top, final int right, + final int bottom) { + int y = top; + final int width = right - left; + + final int viewPagerHeight = mViewPager.getMeasuredHeight(); + mViewPager.layout(0, y, width, y + viewPagerHeight); + y += viewPagerHeight; + + mTabStrip.layout(0, y, width, y + mTabStrip.getMeasuredHeight()); + } + + void onChooserChanged() { + if (mFullScreen) { + setDesiredHeight(getDesiredHeight(), true); + } + } + + void setFullScreenOnly(boolean fullScreenOnly) { + mFullScreenOnly = fullScreenOnly; + } + + boolean isFullScreen() { + return mFullScreen; + } + + void setMediaPicker(final MediaPicker mediaPicker) { + mMediaPicker = mediaPicker; + } + + /** + * Get the desired height of the media picker panel for when the panel is not in motion (i.e. + * not being dragged by the user). + */ + private int getDesiredHeight() { + if (mFullScreen) { + int fullHeight = getContext().getResources().getDisplayMetrics().heightPixels; + if (OsUtil.isAtLeastKLP() && isAttachedToWindow()) { + // When we're attached to the window, we can get an accurate height, not necessary + // on older API level devices because they don't include the action bar height + View composeContainer = + getRootView().findViewById(R.id.conversation_and_compose_container); + if (composeContainer != null) { + // protect against composeContainer having been unloaded already + fullHeight -= UiUtils.getMeasuredBoundsOnScreen(composeContainer).top; + } + } + if (mMediaPicker.getChooserShowsActionBarInFullScreen()) { + return fullHeight - mActionBarHeight; + } else { + return fullHeight; + } + } else if (mExpanded) { + return LayoutParams.WRAP_CONTENT; + } else { + return 0; + } + } + + private void setupViewPager(final int startingPage) { + mViewPager.setVisibility(View.VISIBLE); + if (startingPage >= 0 && startingPage < mMediaPicker.getPagerAdapter().getCount()) { + mViewPager.setAdapter(mMediaPicker.getPagerAdapter()); + mViewPager.setCurrentItem(startingPage); + } + updateViewPager(); + } + + /** + * Expand the media picker panel. Since we always set the pager adapter to null when the panel + * is collapsed, we need to restore the adapter and the starting page. + * @param expanded expanded or collapsed + * @param animate need animation + * @param startingPage the desired selected page to start + */ + void setExpanded(final boolean expanded, final boolean animate, final int startingPage) { + setExpanded(expanded, animate, startingPage, false /* force */); + } + + private void setExpanded(final boolean expanded, final boolean animate, final int startingPage, + final boolean force) { + if (expanded == mExpanded && !force) { + return; + } + mFullScreen = false; + mExpanded = expanded; + mHandler.post(new Runnable() { + @Override + public void run() { + setDesiredHeight(getDesiredHeight(), animate); + } + }); + if (expanded) { + setupViewPager(startingPage); + mMediaPicker.dispatchOpened(); + } else { + mMediaPicker.dispatchDismissed(); + } + + // Call setFullScreenView() when we are in landscape mode so it can go full screen as + // soon as it is expanded. + if (expanded && requiresFullScreen()) { + setFullScreenView(true, animate); + } + } + + private boolean requiresFullScreen() { + return mFullScreenOnly || UiUtils.isLandscapeMode(); + } + + private void setDesiredHeight(int height, final boolean animate) { + final int startHeight = mCurrentDesiredHeight; + if (height == LayoutParams.WRAP_CONTENT) { + height = measureHeight(); + } + clearAnimation(); + if (animate) { + final int deltaHeight = height - startHeight; + final Animation animation = new Animation() { + @Override + protected void applyTransformation(final float interpolatedTime, + final Transformation t) { + mCurrentDesiredHeight = (int) (startHeight + deltaHeight * interpolatedTime); + requestLayout(); + } + + @Override + public boolean willChangeBounds() { + return true; + } + }; + animation.setDuration(UiUtils.MEDIAPICKER_TRANSITION_DURATION); + animation.setInterpolator(UiUtils.EASE_OUT_INTERPOLATOR); + startAnimation(animation); + } else { + mCurrentDesiredHeight = height; + } + requestLayout(); + } + + /** + * @return The minimum total height of the view + */ + private int measureHeight() { + final int measureSpec = MeasureSpec.makeMeasureSpec(Integer.MAX_VALUE, MeasureSpec.AT_MOST); + measureChild(mTabStrip, measureSpec, measureSpec); + return mDefaultViewPagerHeight + mTabStrip.getMeasuredHeight(); + } + + /** + * Enters or leaves full screen view + * + * @param fullScreen True to enter full screen view, false to leave + * @param animate True to animate the transition + */ + void setFullScreenView(final boolean fullScreen, final boolean animate) { + if (fullScreen == mFullScreen) { + return; + } + + if (requiresFullScreen() && !fullScreen) { + setExpanded(false /* expanded */, true /* animate */, PAGE_NOT_SET); + return; + } + mFullScreen = fullScreen; + setDesiredHeight(getDesiredHeight(), animate); + mMediaPicker.dispatchFullScreen(mFullScreen); + updateViewPager(); + } + + /** + * ViewPager should have its paging disabled when in full screen mode. + */ + private void updateViewPager() { + mViewPager.setPagingEnabled(!mFullScreen); + } + + @Override + public boolean onInterceptTouchEvent(final MotionEvent ev) { + return mTouchHandler.onInterceptTouchEvent(ev) || super.onInterceptTouchEvent(ev); + } + + /** + * Helper class to handle touch events and swipe gestures + */ + private class TouchHandler implements OnTouchListener { + /** + * The height of the view when the touch press started + */ + private int mDownHeight = -1; + + /** + * True if the panel moved at all (changed height) during the drag + */ + private boolean mMoved = false; + + // The threshold constants converted from DP to px + private final float mFlingThresholdPx; + private final float mBigFlingThresholdPx; + + // The system defined pixel size to determine when a movement is considered a drag. + private final int mTouchSlop; + + /** + * A copy of the MotionEvent that started the drag/swipe gesture + */ + private MotionEvent mDownEvent; + + /** + * Whether we are currently moving down. We may not be able to move down in full screen + * mode when the child view can swipe down (such as a list view). + */ + private boolean mMovedDown = false; + + /** + * Indicates whether the child view contained in the panel can swipe down at the beginning + * of the drag event (i.e. the initial down). The MediaPanel can contain + * scrollable children such as a list view / grid view. If the child view can swipe down, + * We want to let the child view handle the scroll first instead of handling it ourselves. + */ + private boolean mCanChildViewSwipeDown = false; + + /** + * Necessary direction ratio for a fling to be considered in one direction this prevents + * horizontal swipes with small vertical components from triggering vertical swipe actions + */ + private static final float DIRECTION_RATIO = 1.1f; + + TouchHandler() { + final Resources resources = getContext().getResources(); + final ViewConfiguration configuration = ViewConfiguration.get(getContext()); + mFlingThresholdPx = resources.getDimensionPixelSize( + R.dimen.mediapicker_fling_threshold); + mBigFlingThresholdPx = resources.getDimensionPixelSize( + R.dimen.mediapicker_big_fling_threshold); + mTouchSlop = configuration.getScaledTouchSlop(); + } + + /** + * The media picker panel may contain scrollable children such as a GridView, which eats + * all touch events before we get to it. Therefore, we'd like to intercept these events + * before the children to determine if we should handle swiping down in full screen mode. + * In non-full screen mode, we should handle all vertical scrolling events and leave + * horizontal scrolling to the view pager. + */ + public boolean onInterceptTouchEvent(final MotionEvent ev) { + switch (ev.getActionMasked()) { + case MotionEvent.ACTION_DOWN: + // Never capture the initial down, so that the children may handle it + // as well. Let the touch handler know about the down event as well. + mTouchHandler.onTouch(MediaPickerPanel.this, ev); + + // Ask the MediaPicker whether the contained view can be swiped down. + // We record the value at the start of the drag to decide the swiping mode + // for the entire motion. + mCanChildViewSwipeDown = mMediaPicker.canSwipeDownChooser(); + return false; + + case MotionEvent.ACTION_MOVE: { + if (mMediaPicker.isChooserHandlingTouch()) { + if (shouldAllowRecaptureTouch(ev)) { + mMediaPicker.stopChooserTouchHandling(); + mViewPager.setPagingEnabled(true); + return false; + } + // If the chooser is claiming ownership on all touch events, then we + // shouldn't try to handle them (neither should the view pager). + mViewPager.setPagingEnabled(false); + return false; + } else if (mCanChildViewSwipeDown) { + // Never capture event if the child view can swipe down. + return false; + } else if (!mFullScreen && mMoved) { + // When we are not fullscreen, we own any vertical drag motion. + return true; + } else if (mMovedDown) { + // We are currently handling the down swipe ourselves, so always + // capture this event. + return true; + } else { + // The current interaction mode is undetermined, so always let the + // touch handler know about this event. However, don't capture this + // event so the child may handle it as well. + mTouchHandler.onTouch(MediaPickerPanel.this, ev); + + // Capture the touch event from now on if we are handling the drag. + return mFullScreen ? mMovedDown : mMoved; + } + } + } + return false; + } + + /** + * Determine whether we think the user is actually trying to expand or slide despite the + * fact that they touched first on a chooser that captured the input. + */ + private boolean shouldAllowRecaptureTouch(MotionEvent ev) { + final long elapsedMs = ev.getEventTime() - ev.getDownTime(); + if (mDownEvent == null || elapsedMs == 0 || elapsedMs > TOUCH_RECAPTURE_WINDOW_MS) { + // Either we don't have info to decide or it's been long enough that we no longer + // want to reinterpret user intent. + return false; + } + final float dx = ev.getRawX() - mDownEvent.getRawX(); + final float dy = ev.getRawY() - mDownEvent.getRawY(); + final float dt = elapsedMs / 1000.0f; + final float maxAbsDelta = Math.max(Math.abs(dx), Math.abs(dy)); + final float velocity = maxAbsDelta / dt; + return velocity > mFlingThresholdPx; + } + + @Override + public boolean onTouch(final View view, final MotionEvent motionEvent) { + switch (motionEvent.getAction()) { + case MotionEvent.ACTION_UP: { + if (!mMoved || mDownEvent == null) { + return false; + } + final float dx = motionEvent.getRawX() - mDownEvent.getRawX(); + final float dy = motionEvent.getRawY() - mDownEvent.getRawY(); + + final float dt = + (motionEvent.getEventTime() - mDownEvent.getEventTime()) / 1000.0f; + final float yVelocity = dy / dt; + + boolean handled = false; + + // Vertical swipe occurred if the direction is as least mostly in the y + // component and has the required velocity (px/sec) + if ((dx == 0 || (Math.abs(dy) / Math.abs(dx)) > DIRECTION_RATIO) && + Math.abs(yVelocity) > mFlingThresholdPx) { + if (yVelocity < 0 && mExpanded) { + setFullScreenView(true, true); + handled = true; + } else if (yVelocity > 0) { + if (mFullScreen && yVelocity < mBigFlingThresholdPx) { + setFullScreenView(false, true); + } else { + setExpanded(false, true, PAGE_NOT_SET); + } + handled = true; + } + } + + if (!handled) { + // If they didn't swipe enough, animate back to resting state + setDesiredHeight(getDesiredHeight(), true); + } + resetState(); + break; + } + case MotionEvent.ACTION_DOWN: { + mDownHeight = getHeight(); + mDownEvent = MotionEvent.obtain(motionEvent); + // If we are here and care about the return value (i.e. this is not called + // from onInterceptTouchEvent), then presumably no children view in the panel + // handles the down event. We'd like to handle future ACTION_MOVE events, so + // always claim ownership on this event so it doesn't fall through and gets + // cancelled by the framework. + return true; + } + case MotionEvent.ACTION_MOVE: { + if (mDownEvent == null) { + return mMoved; + } + + final float dx = mDownEvent.getRawX() - motionEvent.getRawX(); + final float dy = mDownEvent.getRawY() - motionEvent.getRawY(); + // Don't act if the move is mostly horizontal + if (Math.abs(dy) > mTouchSlop && + (Math.abs(dy) / Math.abs(dx)) > DIRECTION_RATIO) { + setDesiredHeight((int) (mDownHeight + dy), false); + mMoved = true; + if (dy < -mTouchSlop) { + mMovedDown = true; + } + } + return mMoved; + } + + } + return mMoved; + } + + private void resetState() { + mDownEvent = null; + mDownHeight = -1; + mMoved = false; + mMovedDown = false; + mCanChildViewSwipeDown = false; + updateViewPager(); + } + } +} + diff --git a/src/com/android/messaging/ui/mediapicker/MmsVideoRecorder.java b/src/com/android/messaging/ui/mediapicker/MmsVideoRecorder.java new file mode 100644 index 0000000..7ac7871 --- /dev/null +++ b/src/com/android/messaging/ui/mediapicker/MmsVideoRecorder.java @@ -0,0 +1,127 @@ +/* + * 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.ui.mediapicker; + +import android.hardware.Camera; +import android.media.CamcorderProfile; +import android.media.MediaRecorder; +import android.net.Uri; + +import com.android.messaging.Factory; +import com.android.messaging.datamodel.MediaScratchFileProvider; +import com.android.messaging.util.ContentType; +import com.android.messaging.util.SafeAsyncTask; + +import java.io.FileNotFoundException; + +class MmsVideoRecorder extends MediaRecorder { + private static final float VIDEO_OVERSHOOT_SLOP = .85F; + + private static final int BITS_PER_BYTE = 8; + + // We think user will expect to be able to record videos at least this long + private static final long MIN_DURATION_LIMIT_SECONDS = 25; + + /** The uri where video is being recorded to */ + private Uri mTempVideoUri; + + /** The settings used for video recording */ + private final CamcorderProfile mCamcorderProfile; + + public MmsVideoRecorder(final Camera camera, final int cameraIndex, final int orientation, + final int maxMessageSize) + throws FileNotFoundException { + mCamcorderProfile = + CamcorderProfile.get(cameraIndex, CamcorderProfile.QUALITY_LOW); + mTempVideoUri = MediaScratchFileProvider.buildMediaScratchSpaceUri( + ContentType.getExtension(getContentType())); + + // The video recorder can sometimes return a file that's larger than the max we + // say we can handle. Try to handle that overshoot by specifying an 85% limit. + final long sizeLimit = (long) (maxMessageSize * VIDEO_OVERSHOOT_SLOP); + + // The QUALITY_LOW profile might not be low enough to allow for video of a reasonable + // minimum duration. Adjust a/v bitrates to allow at least MIN_DURATION_LIMIT video + // to be recorded. + int audioBitRate = mCamcorderProfile.audioBitRate; + int videoBitRate = mCamcorderProfile.videoBitRate; + final double initialDurationLimit = sizeLimit * BITS_PER_BYTE + / (double) (audioBitRate + videoBitRate); + if (initialDurationLimit < MIN_DURATION_LIMIT_SECONDS) { + // Reduce the suggested bitrates. These bitrates are only requests, if implementation + // can't actually hit these goals it will still record video at higher rate and stop when + // it hits the size limit. + final double bitRateAdjustmentFactor = initialDurationLimit / MIN_DURATION_LIMIT_SECONDS; + audioBitRate *= bitRateAdjustmentFactor; + videoBitRate *= bitRateAdjustmentFactor; + } + + setCamera(camera); + setOrientationHint(orientation); + setAudioSource(MediaRecorder.AudioSource.CAMCORDER); + setVideoSource(MediaRecorder.VideoSource.CAMERA); + setOutputFormat(mCamcorderProfile.fileFormat); + setOutputFile( + Factory.get().getApplicationContext().getContentResolver().openFileDescriptor( + mTempVideoUri, "w").getFileDescriptor()); + + // Copy settings from CamcorderProfile to MediaRecorder + setAudioEncodingBitRate(audioBitRate); + setAudioChannels(mCamcorderProfile.audioChannels); + setAudioEncoder(mCamcorderProfile.audioCodec); + setAudioSamplingRate(mCamcorderProfile.audioSampleRate); + setVideoEncodingBitRate(videoBitRate); + setVideoEncoder(mCamcorderProfile.videoCodec); + setVideoFrameRate(mCamcorderProfile.videoFrameRate); + setVideoSize( + mCamcorderProfile.videoFrameWidth, mCamcorderProfile.videoFrameHeight); + setMaxFileSize(sizeLimit); + } + + Uri getVideoUri() { + return mTempVideoUri; + } + + int getVideoWidth() { + return mCamcorderProfile.videoFrameWidth; + } + + int getVideoHeight() { + return mCamcorderProfile.videoFrameHeight; + } + + void cleanupTempFile() { + final Uri tempUri = mTempVideoUri; + SafeAsyncTask.executeOnThreadPool(new Runnable() { + @Override + public void run() { + Factory.get().getApplicationContext().getContentResolver().delete( + tempUri, null, null); + } + }); + mTempVideoUri = null; + } + + String getContentType() { + if (mCamcorderProfile.fileFormat == OutputFormat.MPEG_4) { + return ContentType.VIDEO_MP4; + } else { + // 3GPP is the only other video format with a constant in OutputFormat + return ContentType.VIDEO_3GPP; + } + } +} diff --git a/src/com/android/messaging/ui/mediapicker/PausableChronometer.java b/src/com/android/messaging/ui/mediapicker/PausableChronometer.java new file mode 100644 index 0000000..dc8f90b --- /dev/null +++ b/src/com/android/messaging/ui/mediapicker/PausableChronometer.java @@ -0,0 +1,75 @@ +/* + * 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.ui.mediapicker; + +import android.content.Context; +import android.os.SystemClock; +import android.util.AttributeSet; +import android.widget.Chronometer; + +import com.android.messaging.ui.PlaybackStateView; + +/** + * A pausable Chronometer implementation. The default Chronometer in Android only stops the UI + * from updating when you call stop(), but doesn't actually pause it. This implementation adds an + * additional timestamp that tracks the timespan for the pause and compensate for that. + */ +public class PausableChronometer extends Chronometer implements PlaybackStateView { + // Keeps track of how far long the Chronometer has been tracking when it's paused. We'd like + // to start from this time the next time it's resumed. + private long mTimeWhenPaused = 0; + + public PausableChronometer(final Context context, final AttributeSet attrs) { + super(context, attrs); + } + + /** + * Reset the timer and start counting from zero. + */ + @Override + public void restart() { + reset(); + start(); + } + + /** + * Reset the timer to zero, but don't start it. + */ + @Override + public void reset() { + stop(); + setBase(SystemClock.elapsedRealtime()); + mTimeWhenPaused = 0; + } + + /** + * Resume the timer after a previous pause. + */ + @Override + public void resume() { + setBase(SystemClock.elapsedRealtime() - mTimeWhenPaused); + start(); + } + + /** + * Pause the timer. + */ + @Override + public void pause() { + stop(); + mTimeWhenPaused = SystemClock.elapsedRealtime() - getBase(); + } +} diff --git a/src/com/android/messaging/ui/mediapicker/SoftwareCameraPreview.java b/src/com/android/messaging/ui/mediapicker/SoftwareCameraPreview.java new file mode 100644 index 0000000..5dc3185 --- /dev/null +++ b/src/com/android/messaging/ui/mediapicker/SoftwareCameraPreview.java @@ -0,0 +1,114 @@ +/* + * 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.ui.mediapicker; + +import android.content.Context; +import android.hardware.Camera; +import android.os.Parcelable; +import android.view.SurfaceHolder; +import android.view.SurfaceView; +import android.view.View; + +import java.io.IOException; + +/** + * A software rendered preview surface for the camera. This renders slower and causes more jank, so + * HardwareCameraPreview is preferred if possible. + * + * There is a significant amount of duplication between HardwareCameraPreview and + * SoftwareCameraPreview which we can't easily share due to a lack of multiple inheritance, The + * implementations of the shared methods are delegated to CameraPreview + */ +public class SoftwareCameraPreview extends SurfaceView implements CameraPreview.CameraPreviewHost { + private final CameraPreview mPreview; + + public SoftwareCameraPreview(final Context context) { + super(context); + mPreview = new CameraPreview(this); + getHolder().addCallback(new SurfaceHolder.Callback() { + @Override + public void surfaceCreated(final SurfaceHolder surfaceHolder) { + CameraManager.get().setSurface(mPreview); + } + + @Override + public void surfaceChanged(final SurfaceHolder surfaceHolder, final int format, final int width, + final int height) { + CameraManager.get().setSurface(mPreview); + } + + @Override + public void surfaceDestroyed(final SurfaceHolder surfaceHolder) { + CameraManager.get().setSurface(null); + } + }); + } + + + @Override + protected void onVisibilityChanged(final View changedView, final int visibility) { + super.onVisibilityChanged(changedView, visibility); + mPreview.onVisibilityChanged(visibility); + } + + @Override + protected void onDetachedFromWindow() { + super.onDetachedFromWindow(); + mPreview.onDetachedFromWindow(); + } + + @Override + protected void onAttachedToWindow() { + super.onAttachedToWindow(); + mPreview.onAttachedToWindow(); + } + + @Override + protected void onRestoreInstanceState(final Parcelable state) { + super.onRestoreInstanceState(state); + mPreview.onRestoreInstanceState(); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + widthMeasureSpec = mPreview.getWidthMeasureSpec(widthMeasureSpec, heightMeasureSpec); + heightMeasureSpec = mPreview.getHeightMeasureSpec(widthMeasureSpec, heightMeasureSpec); + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + } + + @Override + public View getView() { + return this; + } + + @Override + public boolean isValid() { + return getHolder() != null; + } + + @Override + public void startPreview(final Camera camera) throws IOException { + camera.setPreviewDisplay(getHolder()); + } + + @Override + public void onCameraPermissionGranted() { + mPreview.onCameraPermissionGranted(); + } +} + + diff --git a/src/com/android/messaging/ui/mediapicker/SoundLevels.java b/src/com/android/messaging/ui/mediapicker/SoundLevels.java new file mode 100644 index 0000000..6f4dca6 --- /dev/null +++ b/src/com/android/messaging/ui/mediapicker/SoundLevels.java @@ -0,0 +1,212 @@ +/* + * 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.ui.mediapicker; + +import android.animation.ObjectAnimator; +import android.animation.TimeAnimator; +import android.animation.TimeAnimator.TimeListener; +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.Paint.Style; +import android.util.AttributeSet; +import android.util.Log; +import android.view.View; +import android.view.accessibility.AccessibilityNodeInfo; + +import com.android.messaging.R; +import com.android.messaging.util.LogUtil; + +/** + * This view draws circular sound levels. By default the sound levels are black, unless + * otherwise defined via {@link #mPrimaryLevelPaint}. + */ +public class SoundLevels extends View { + private static final String TAG = LogUtil.BUGLE_TAG; + private static final boolean DEBUG = false; + + private boolean mCenterDefined; + private int mCenterX; + private int mCenterY; + + // Paint for the main level meter, most closely follows the mic. + private final Paint mPrimaryLevelPaint; + + // The minimum size of the levels as a percentage of the max, that is the size when volume is 0. + private final float mMinimumLevel; + + // The minimum size of the levels, that is the size when volume is 0. + private final float mMinimumLevelSize; + + // The maximum size of the levels, that is the size when volume is 100. + private final float mMaximumLevelSize; + + // Generates clock ticks for the animation using the global animation loop. + private final TimeAnimator mSpeechLevelsAnimator; + + private float mCurrentVolume; + + // Indicates whether we should be animating the sound level. + private boolean mIsEnabled; + + // Input level is pulled from here. + private AudioLevelSource mLevelSource; + + public SoundLevels(final Context context) { + this(context, null); + } + + public SoundLevels(final Context context, final AttributeSet attrs) { + this(context, attrs, 0); + } + + public SoundLevels(final Context context, final AttributeSet attrs, final int defStyle) { + super(context, attrs, defStyle); + + // Safe source, replaced with system one when attached. + mLevelSource = new AudioLevelSource(); + mLevelSource.setSpeechLevel(0); + + final TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.SoundLevels, + defStyle, 0); + + mMaximumLevelSize = a.getDimensionPixelOffset( + R.styleable.SoundLevels_maxLevelRadius, 0); + mMinimumLevelSize = a.getDimensionPixelOffset( + R.styleable.SoundLevels_minLevelRadius, 0); + mMinimumLevel = mMinimumLevelSize / mMaximumLevelSize; + + mPrimaryLevelPaint = new Paint(); + mPrimaryLevelPaint.setColor( + a.getColor(R.styleable.SoundLevels_primaryColor, Color.BLACK)); + mPrimaryLevelPaint.setFlags(Paint.ANTI_ALIAS_FLAG); + + a.recycle(); + + // This animator generates ticks that invalidate the + // view so that the animation is synced with the global animation loop. + // TODO: We could probably remove this in favor of using postInvalidateOnAnimation + // which might improve things further. + mSpeechLevelsAnimator = new TimeAnimator(); + mSpeechLevelsAnimator.setRepeatCount(ObjectAnimator.INFINITE); + mSpeechLevelsAnimator.setTimeListener(new TimeListener() { + @Override + public void onTimeUpdate(final TimeAnimator animation, final long totalTime, + final long deltaTime) { + invalidate(); + } + }); + } + + @Override + protected void onDraw(final Canvas canvas) { + if (!mIsEnabled) { + return; + } + + if (!mCenterDefined) { + // One time computation here, because we can't rely on getWidth() to be computed at + // constructor time or in onFinishInflate :(. + mCenterX = getWidth() / 2; + mCenterY = getWidth() / 2; + mCenterDefined = true; + } + + final int level = mLevelSource.getSpeechLevel(); + // Either ease towards the target level, or decay away from it depending on whether + // its higher or lower than the current. + if (level > mCurrentVolume) { + mCurrentVolume = mCurrentVolume + ((level - mCurrentVolume) / 4); + } else { + mCurrentVolume = mCurrentVolume * 0.95f; + } + + final float radius = mMinimumLevel + (1f - mMinimumLevel) * mCurrentVolume / 100; + mPrimaryLevelPaint.setStyle(Style.FILL); + canvas.drawCircle(mCenterX, mCenterY, radius * mMaximumLevelSize, mPrimaryLevelPaint); + } + + public void setLevelSource(final AudioLevelSource source) { + if (DEBUG) { + Log.d(TAG, "Speech source set."); + } + mLevelSource = source; + } + + private void startSpeechLevelsAnimator() { + if (DEBUG) { + Log.d(TAG, "startAnimator()"); + } + if (!mSpeechLevelsAnimator.isStarted()) { + mSpeechLevelsAnimator.start(); + } + } + + private void stopSpeechLevelsAnimator() { + if (DEBUG) { + Log.d(TAG, "stopAnimator()"); + } + if (mSpeechLevelsAnimator.isStarted()) { + mSpeechLevelsAnimator.end(); + } + } + + @Override + protected void onDetachedFromWindow() { + super.onDetachedFromWindow(); + stopSpeechLevelsAnimator(); + } + + @Override + public void setEnabled(final boolean enabled) { + if (enabled == mIsEnabled) { + return; + } + if (DEBUG) { + Log.d("TAG", "setEnabled: " + enabled); + } + super.setEnabled(enabled); + mIsEnabled = enabled; + setKeepScreenOn(enabled); + updateSpeechLevelsAnimatorState(); + } + + private void updateSpeechLevelsAnimatorState() { + if (mIsEnabled) { + startSpeechLevelsAnimator(); + } else { + stopSpeechLevelsAnimator(); + } + } + + /** + * This is required to make the View findable by uiautomator + */ + @Override + public void onInitializeAccessibilityNodeInfo(final AccessibilityNodeInfo info) { + super.onInitializeAccessibilityNodeInfo(info); + info.setClassName(SoundLevels.class.getCanonicalName()); + } + + /** + * Set the alpha level of the sound circles. + */ + public void setPrimaryColorAlpha(final int alpha) { + mPrimaryLevelPaint.setAlpha(alpha); + } +} diff --git a/src/com/android/messaging/ui/mediapicker/camerafocus/FocusIndicator.java b/src/com/android/messaging/ui/mediapicker/camerafocus/FocusIndicator.java new file mode 100644 index 0000000..92ed3c1 --- /dev/null +++ b/src/com/android/messaging/ui/mediapicker/camerafocus/FocusIndicator.java @@ -0,0 +1,24 @@ +/* + * 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.ui.mediapicker.camerafocus; + +public interface FocusIndicator { + public void showStart(); + public void showSuccess(boolean timeout); + public void showFail(boolean timeout); + public void clear(); +}
\ No newline at end of file diff --git a/src/com/android/messaging/ui/mediapicker/camerafocus/FocusOverlayManager.java b/src/com/android/messaging/ui/mediapicker/camerafocus/FocusOverlayManager.java new file mode 100644 index 0000000..e620fc2 --- /dev/null +++ b/src/com/android/messaging/ui/mediapicker/camerafocus/FocusOverlayManager.java @@ -0,0 +1,589 @@ +/* + * 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.ui.mediapicker.camerafocus; + +import android.graphics.Matrix; +import android.graphics.Rect; +import android.graphics.RectF; +import android.hardware.Camera.Area; +import android.hardware.Camera.Parameters; +import android.os.Handler; +import android.os.Looper; +import android.os.Message; + +import com.android.messaging.util.Assert; +import com.android.messaging.util.LogUtil; + +import java.util.ArrayList; +import java.util.List; + +/* A class that handles everything about focus in still picture mode. + * This also handles the metering area because it is the same as focus area. + * + * The test cases: + * (1) The camera has continuous autofocus. Move the camera. Take a picture when + * CAF is not in progress. + * (2) The camera has continuous autofocus. Move the camera. Take a picture when + * CAF is in progress. + * (3) The camera has face detection. Point the camera at some faces. Hold the + * shutter. Release to take a picture. + * (4) The camera has face detection. Point the camera at some faces. Single tap + * the shutter to take a picture. + * (5) The camera has autofocus. Single tap the shutter to take a picture. + * (6) The camera has autofocus. Hold the shutter. Release to take a picture. + * (7) The camera has no autofocus. Single tap the shutter and take a picture. + * (8) The camera has autofocus and supports focus area. Touch the screen to + * trigger autofocus. Take a picture. + * (9) The camera has autofocus and supports focus area. Touch the screen to + * trigger autofocus. Wait until it times out. + * (10) The camera has no autofocus and supports metering area. Touch the screen + * to change metering area. + */ +public class FocusOverlayManager { + private static final String TAG = LogUtil.BUGLE_TAG; + private static final String TRUE = "true"; + private static final String AUTO_EXPOSURE_LOCK_SUPPORTED = "auto-exposure-lock-supported"; + private static final String AUTO_WHITE_BALANCE_LOCK_SUPPORTED = + "auto-whitebalance-lock-supported"; + + private static final int RESET_TOUCH_FOCUS = 0; + private static final int RESET_TOUCH_FOCUS_DELAY = 3000; + + private int mState = STATE_IDLE; + private static final int STATE_IDLE = 0; // Focus is not active. + private static final int STATE_FOCUSING = 1; // Focus is in progress. + // Focus is in progress and the camera should take a picture after focus finishes. + private static final int STATE_FOCUSING_SNAP_ON_FINISH = 2; + private static final int STATE_SUCCESS = 3; // Focus finishes and succeeds. + private static final int STATE_FAIL = 4; // Focus finishes and fails. + + private boolean mInitialized; + private boolean mFocusAreaSupported; + private boolean mMeteringAreaSupported; + private boolean mLockAeAwbNeeded; + private boolean mAeAwbLock; + private Matrix mMatrix; + + private PieRenderer mPieRenderer; + + private int mPreviewWidth; // The width of the preview frame layout. + private int mPreviewHeight; // The height of the preview frame layout. + private boolean mMirror; // true if the camera is front-facing. + private int mDisplayOrientation; + private List<Object> mFocusArea; // focus area in driver format + private List<Object> mMeteringArea; // metering area in driver format + private String mFocusMode; + private String mOverrideFocusMode; + private Parameters mParameters; + private Handler mHandler; + Listener mListener; + + public interface Listener { + public void autoFocus(); + public void cancelAutoFocus(); + public boolean capture(); + public void setFocusParameters(); + } + + private class MainHandler extends Handler { + public MainHandler(Looper looper) { + super(looper); + } + + @Override + public void handleMessage(Message msg) { + switch (msg.what) { + case RESET_TOUCH_FOCUS: { + cancelAutoFocus(); + break; + } + } + } + } + + public FocusOverlayManager(Listener listener, Looper looper) { + mHandler = new MainHandler(looper); + mMatrix = new Matrix(); + mListener = listener; + } + + public void setFocusRenderer(PieRenderer renderer) { + mPieRenderer = renderer; + mInitialized = (mMatrix != null); + } + + public void setParameters(Parameters parameters) { + // parameters can only be null when onConfigurationChanged is called + // before camera is open. We will just return in this case, because + // parameters will be set again later with the right parameters after + // camera is open. + if (parameters == null) { + return; + } + mParameters = parameters; + mFocusAreaSupported = isFocusAreaSupported(parameters); + mMeteringAreaSupported = isMeteringAreaSupported(parameters); + mLockAeAwbNeeded = (isAutoExposureLockSupported(mParameters) || + isAutoWhiteBalanceLockSupported(mParameters)); + } + + public void setPreviewSize(int previewWidth, int previewHeight) { + if (mPreviewWidth != previewWidth || mPreviewHeight != previewHeight) { + mPreviewWidth = previewWidth; + mPreviewHeight = previewHeight; + setMatrix(); + } + } + + public void setMirror(boolean mirror) { + mMirror = mirror; + setMatrix(); + } + + public void setDisplayOrientation(int displayOrientation) { + mDisplayOrientation = displayOrientation; + setMatrix(); + } + + private void setMatrix() { + if (mPreviewWidth != 0 && mPreviewHeight != 0) { + Matrix matrix = new Matrix(); + prepareMatrix(matrix, mMirror, mDisplayOrientation, + mPreviewWidth, mPreviewHeight); + // In face detection, the matrix converts the driver coordinates to UI + // coordinates. In tap focus, the inverted matrix converts the UI + // coordinates to driver coordinates. + matrix.invert(mMatrix); + mInitialized = (mPieRenderer != null); + } + } + + private void lockAeAwbIfNeeded() { + if (mLockAeAwbNeeded && !mAeAwbLock) { + mAeAwbLock = true; + mListener.setFocusParameters(); + } + } + + private void unlockAeAwbIfNeeded() { + if (mLockAeAwbNeeded && mAeAwbLock && (mState != STATE_FOCUSING_SNAP_ON_FINISH)) { + mAeAwbLock = false; + mListener.setFocusParameters(); + } + } + + public void onShutterDown() { + if (!mInitialized) { + return; + } + + boolean autoFocusCalled = false; + if (needAutoFocusCall()) { + // Do not focus if touch focus has been triggered. + if (mState != STATE_SUCCESS && mState != STATE_FAIL) { + autoFocus(); + autoFocusCalled = true; + } + } + + if (!autoFocusCalled) { + lockAeAwbIfNeeded(); + } + } + + public void onShutterUp() { + if (!mInitialized) { + return; + } + + if (needAutoFocusCall()) { + // User releases half-pressed focus key. + if (mState == STATE_FOCUSING || mState == STATE_SUCCESS + || mState == STATE_FAIL) { + cancelAutoFocus(); + } + } + + // Unlock AE and AWB after cancelAutoFocus. Camera API does not + // guarantee setParameters can be called during autofocus. + unlockAeAwbIfNeeded(); + } + + public void doSnap() { + if (!mInitialized) { + return; + } + + // If the user has half-pressed the shutter and focus is completed, we + // can take the photo right away. If the focus mode is infinity, we can + // also take the photo. + if (!needAutoFocusCall() || (mState == STATE_SUCCESS || mState == STATE_FAIL)) { + capture(); + } else if (mState == STATE_FOCUSING) { + // Half pressing the shutter (i.e. the focus button event) will + // already have requested AF for us, so just request capture on + // focus here. + mState = STATE_FOCUSING_SNAP_ON_FINISH; + } else if (mState == STATE_IDLE) { + // We didn't do focus. This can happen if the user press focus key + // while the snapshot is still in progress. The user probably wants + // the next snapshot as soon as possible, so we just do a snapshot + // without focusing again. + capture(); + } + } + + public void onAutoFocus(boolean focused, boolean shutterButtonPressed) { + if (mState == STATE_FOCUSING_SNAP_ON_FINISH) { + // Take the picture no matter focus succeeds or fails. No need + // to play the AF sound if we're about to play the shutter + // sound. + if (focused) { + mState = STATE_SUCCESS; + } else { + mState = STATE_FAIL; + } + updateFocusUI(); + capture(); + } else if (mState == STATE_FOCUSING) { + // This happens when (1) user is half-pressing the focus key or + // (2) touch focus is triggered. Play the focus tone. Do not + // take the picture now. + if (focused) { + mState = STATE_SUCCESS; + } else { + mState = STATE_FAIL; + } + updateFocusUI(); + // If this is triggered by touch focus, cancel focus after a + // while. + if (mFocusArea != null) { + mHandler.sendEmptyMessageDelayed(RESET_TOUCH_FOCUS, RESET_TOUCH_FOCUS_DELAY); + } + if (shutterButtonPressed) { + // Lock AE & AWB so users can half-press shutter and recompose. + lockAeAwbIfNeeded(); + } + } else if (mState == STATE_IDLE) { + // User has released the focus key before focus completes. + // Do nothing. + } + } + + public void onAutoFocusMoving(boolean moving) { + if (!mInitialized) { + return; + } + + // Ignore if we have requested autofocus. This method only handles + // continuous autofocus. + if (mState != STATE_IDLE) { + return; + } + + if (moving) { + mPieRenderer.showStart(); + } else { + mPieRenderer.showSuccess(true); + } + } + + private void initializeFocusAreas(int focusWidth, int focusHeight, + int x, int y, int previewWidth, int previewHeight) { + if (mFocusArea == null) { + mFocusArea = new ArrayList<Object>(); + mFocusArea.add(new Area(new Rect(), 1)); + } + + // Convert the coordinates to driver format. + calculateTapArea(focusWidth, focusHeight, 1f, x, y, previewWidth, previewHeight, + ((Area) mFocusArea.get(0)).rect); + } + + private void initializeMeteringAreas(int focusWidth, int focusHeight, + int x, int y, int previewWidth, int previewHeight) { + if (mMeteringArea == null) { + mMeteringArea = new ArrayList<Object>(); + mMeteringArea.add(new Area(new Rect(), 1)); + } + + // Convert the coordinates to driver format. + // AE area is bigger because exposure is sensitive and + // easy to over- or underexposure if area is too small. + calculateTapArea(focusWidth, focusHeight, 1.5f, x, y, previewWidth, previewHeight, + ((Area) mMeteringArea.get(0)).rect); + } + + public void onSingleTapUp(int x, int y) { + if (!mInitialized || mState == STATE_FOCUSING_SNAP_ON_FINISH) { + return; + } + + // Let users be able to cancel previous touch focus. + if ((mFocusArea != null) && (mState == STATE_FOCUSING || + mState == STATE_SUCCESS || mState == STATE_FAIL)) { + cancelAutoFocus(); + } + // Initialize variables. + int focusWidth = mPieRenderer.getSize(); + int focusHeight = mPieRenderer.getSize(); + if (focusWidth == 0 || mPieRenderer.getWidth() == 0 || mPieRenderer.getHeight() == 0) { + return; + } + int previewWidth = mPreviewWidth; + int previewHeight = mPreviewHeight; + // Initialize mFocusArea. + if (mFocusAreaSupported) { + initializeFocusAreas(focusWidth, focusHeight, x, y, previewWidth, previewHeight); + } + // Initialize mMeteringArea. + if (mMeteringAreaSupported) { + initializeMeteringAreas(focusWidth, focusHeight, x, y, previewWidth, previewHeight); + } + + // Use margin to set the focus indicator to the touched area. + mPieRenderer.setFocus(x, y); + + // Set the focus area and metering area. + mListener.setFocusParameters(); + if (mFocusAreaSupported) { + autoFocus(); + } else { // Just show the indicator in all other cases. + updateFocusUI(); + // Reset the metering area in 3 seconds. + mHandler.removeMessages(RESET_TOUCH_FOCUS); + mHandler.sendEmptyMessageDelayed(RESET_TOUCH_FOCUS, RESET_TOUCH_FOCUS_DELAY); + } + } + + public void onPreviewStarted() { + mState = STATE_IDLE; + } + + public void onPreviewStopped() { + // If auto focus was in progress, it would have been stopped. + mState = STATE_IDLE; + resetTouchFocus(); + updateFocusUI(); + } + + public void onCameraReleased() { + onPreviewStopped(); + } + + private void autoFocus() { + LogUtil.v(TAG, "Start autofocus."); + mListener.autoFocus(); + mState = STATE_FOCUSING; + updateFocusUI(); + mHandler.removeMessages(RESET_TOUCH_FOCUS); + } + + private void cancelAutoFocus() { + LogUtil.v(TAG, "Cancel autofocus."); + + // Reset the tap area before calling mListener.cancelAutofocus. + // Otherwise, focus mode stays at auto and the tap area passed to the + // driver is not reset. + resetTouchFocus(); + mListener.cancelAutoFocus(); + mState = STATE_IDLE; + updateFocusUI(); + mHandler.removeMessages(RESET_TOUCH_FOCUS); + } + + private void capture() { + if (mListener.capture()) { + mState = STATE_IDLE; + mHandler.removeMessages(RESET_TOUCH_FOCUS); + } + } + + public String getFocusMode() { + if (mOverrideFocusMode != null) { + return mOverrideFocusMode; + } + List<String> supportedFocusModes = mParameters.getSupportedFocusModes(); + + if (mFocusAreaSupported && mFocusArea != null) { + // Always use autofocus in tap-to-focus. + mFocusMode = Parameters.FOCUS_MODE_AUTO; + } else { + mFocusMode = Parameters.FOCUS_MODE_CONTINUOUS_PICTURE; + } + + if (!isSupported(mFocusMode, supportedFocusModes)) { + // For some reasons, the driver does not support the current + // focus mode. Fall back to auto. + if (isSupported(Parameters.FOCUS_MODE_AUTO, + mParameters.getSupportedFocusModes())) { + mFocusMode = Parameters.FOCUS_MODE_AUTO; + } else { + mFocusMode = mParameters.getFocusMode(); + } + } + return mFocusMode; + } + + public List getFocusAreas() { + return mFocusArea; + } + + public List getMeteringAreas() { + return mMeteringArea; + } + + public void updateFocusUI() { + if (!mInitialized) { + return; + } + FocusIndicator focusIndicator = mPieRenderer; + + if (mState == STATE_IDLE) { + if (mFocusArea == null) { + focusIndicator.clear(); + } else { + // Users touch on the preview and the indicator represents the + // metering area. Either focus area is not supported or + // autoFocus call is not required. + focusIndicator.showStart(); + } + } else if (mState == STATE_FOCUSING || mState == STATE_FOCUSING_SNAP_ON_FINISH) { + focusIndicator.showStart(); + } else { + if (Parameters.FOCUS_MODE_CONTINUOUS_PICTURE.equals(mFocusMode)) { + // TODO: check HAL behavior and decide if this can be removed. + focusIndicator.showSuccess(false); + } else if (mState == STATE_SUCCESS) { + focusIndicator.showSuccess(false); + } else if (mState == STATE_FAIL) { + focusIndicator.showFail(false); + } + } + } + + public void resetTouchFocus() { + if (!mInitialized) { + return; + } + + // Put focus indicator to the center. clear reset position + mPieRenderer.clear(); + + mFocusArea = null; + mMeteringArea = null; + } + + private void calculateTapArea(int focusWidth, int focusHeight, float areaMultiple, + int x, int y, int previewWidth, int previewHeight, Rect rect) { + int areaWidth = (int) (focusWidth * areaMultiple); + int areaHeight = (int) (focusHeight * areaMultiple); + int left = clamp(x - areaWidth / 2, 0, previewWidth - areaWidth); + int top = clamp(y - areaHeight / 2, 0, previewHeight - areaHeight); + + RectF rectF = new RectF(left, top, left + areaWidth, top + areaHeight); + mMatrix.mapRect(rectF); + rectFToRect(rectF, rect); + } + + /* package */ int getFocusState() { + return mState; + } + + public boolean isFocusCompleted() { + return mState == STATE_SUCCESS || mState == STATE_FAIL; + } + + public boolean isFocusingSnapOnFinish() { + return mState == STATE_FOCUSING_SNAP_ON_FINISH; + } + + public void removeMessages() { + mHandler.removeMessages(RESET_TOUCH_FOCUS); + } + + public void overrideFocusMode(String focusMode) { + mOverrideFocusMode = focusMode; + } + + public void setAeAwbLock(boolean lock) { + mAeAwbLock = lock; + } + + public boolean getAeAwbLock() { + return mAeAwbLock; + } + + private boolean needAutoFocusCall() { + String focusMode = getFocusMode(); + return !(focusMode.equals(Parameters.FOCUS_MODE_INFINITY) + || focusMode.equals(Parameters.FOCUS_MODE_FIXED) + || focusMode.equals(Parameters.FOCUS_MODE_EDOF)); + } + + public static boolean isAutoExposureLockSupported(Parameters params) { + return TRUE.equals(params.get(AUTO_EXPOSURE_LOCK_SUPPORTED)); + } + + public static boolean isAutoWhiteBalanceLockSupported(Parameters params) { + return TRUE.equals(params.get(AUTO_WHITE_BALANCE_LOCK_SUPPORTED)); + } + + public static boolean isSupported(String value, List<String> supported) { + return supported != null && supported.indexOf(value) >= 0; + } + + public static boolean isMeteringAreaSupported(Parameters params) { + return params.getMaxNumMeteringAreas() > 0; + } + + public static boolean isFocusAreaSupported(Parameters params) { + return (params.getMaxNumFocusAreas() > 0 + && isSupported(Parameters.FOCUS_MODE_AUTO, + params.getSupportedFocusModes())); + } + + public static void prepareMatrix(Matrix matrix, boolean mirror, int displayOrientation, + int viewWidth, int viewHeight) { + // Need mirror for front camera. + matrix.setScale(mirror ? -1 : 1, 1); + // This is the value for android.hardware.Camera.setDisplayOrientation. + matrix.postRotate(displayOrientation); + // Camera driver coordinates range from (-1000, -1000) to (1000, 1000). + // UI coordinates range from (0, 0) to (width, height). + matrix.postScale(viewWidth / 2000f, viewHeight / 2000f); + matrix.postTranslate(viewWidth / 2f, viewHeight / 2f); + } + + public static int clamp(int x, int min, int max) { + Assert.isTrue(max >= min); + if (x > max) { + return max; + } + if (x < min) { + return min; + } + return x; + } + + public static void rectFToRect(RectF rectF, Rect rect) { + rect.left = Math.round(rectF.left); + rect.top = Math.round(rectF.top); + rect.right = Math.round(rectF.right); + rect.bottom = Math.round(rectF.bottom); + } +} diff --git a/src/com/android/messaging/ui/mediapicker/camerafocus/OverlayRenderer.java b/src/com/android/messaging/ui/mediapicker/camerafocus/OverlayRenderer.java new file mode 100644 index 0000000..df6734f --- /dev/null +++ b/src/com/android/messaging/ui/mediapicker/camerafocus/OverlayRenderer.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.ui.mediapicker.camerafocus; + +import android.content.Context; +import android.graphics.Canvas; +import android.view.MotionEvent; + +public abstract class OverlayRenderer implements RenderOverlay.Renderer { + + private static final String TAG = "CAM OverlayRenderer"; + protected RenderOverlay mOverlay; + + protected int mLeft, mTop, mRight, mBottom; + + protected boolean mVisible; + + public void setVisible(boolean vis) { + mVisible = vis; + update(); + } + + public boolean isVisible() { + return mVisible; + } + + // default does not handle touch + @Override + public boolean handlesTouch() { + return false; + } + + @Override + public boolean onTouchEvent(MotionEvent evt) { + return false; + } + + public abstract void onDraw(Canvas canvas); + + public void draw(Canvas canvas) { + if (mVisible) { + onDraw(canvas); + } + } + + @Override + public void setOverlay(RenderOverlay overlay) { + mOverlay = overlay; + } + + @Override + public void layout(int left, int top, int right, int bottom) { + mLeft = left; + mRight = right; + mTop = top; + mBottom = bottom; + } + + protected Context getContext() { + if (mOverlay != null) { + return mOverlay.getContext(); + } else { + return null; + } + } + + public int getWidth() { + return mRight - mLeft; + } + + public int getHeight() { + return mBottom - mTop; + } + + protected void update() { + if (mOverlay != null) { + mOverlay.update(); + } + } + +}
\ No newline at end of file diff --git a/src/com/android/messaging/ui/mediapicker/camerafocus/PieItem.java b/src/com/android/messaging/ui/mediapicker/camerafocus/PieItem.java new file mode 100644 index 0000000..c602852 --- /dev/null +++ b/src/com/android/messaging/ui/mediapicker/camerafocus/PieItem.java @@ -0,0 +1,202 @@ +/* + * 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.ui.mediapicker.camerafocus; + +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Path; +import android.graphics.drawable.Drawable; + +import java.util.ArrayList; +import java.util.List; + +/** + * Pie menu item + */ +public class PieItem { + + public static interface OnClickListener { + void onClick(PieItem item); + } + + private Drawable mDrawable; + private int level; + private float mCenter; + private float start; + private float sweep; + private float animate; + private int inner; + private int outer; + private boolean mSelected; + private boolean mEnabled; + private List<PieItem> mItems; + private Path mPath; + private OnClickListener mOnClickListener; + private float mAlpha; + + // Gray out the view when disabled + private static final float ENABLED_ALPHA = 1; + private static final float DISABLED_ALPHA = (float) 0.3; + private boolean mChangeAlphaWhenDisabled = true; + + public PieItem(Drawable drawable, int level) { + mDrawable = drawable; + this.level = level; + setAlpha(1f); + mEnabled = true; + setAnimationAngle(getAnimationAngle()); + start = -1; + mCenter = -1; + } + + public boolean hasItems() { + return mItems != null; + } + + public List<PieItem> getItems() { + return mItems; + } + + public void addItem(PieItem item) { + if (mItems == null) { + mItems = new ArrayList<PieItem>(); + } + mItems.add(item); + } + + public void setPath(Path p) { + mPath = p; + } + + public Path getPath() { + return mPath; + } + + public void setChangeAlphaWhenDisabled (boolean enable) { + mChangeAlphaWhenDisabled = enable; + } + + public void setAlpha(float alpha) { + mAlpha = alpha; + mDrawable.setAlpha((int) (255 * alpha)); + } + + public void setAnimationAngle(float a) { + animate = a; + } + + public float getAnimationAngle() { + return animate; + } + + public void setEnabled(boolean enabled) { + mEnabled = enabled; + if (mChangeAlphaWhenDisabled) { + if (mEnabled) { + setAlpha(ENABLED_ALPHA); + } else { + setAlpha(DISABLED_ALPHA); + } + } + } + + public boolean isEnabled() { + return mEnabled; + } + + public void setSelected(boolean s) { + mSelected = s; + } + + public boolean isSelected() { + return mSelected; + } + + public int getLevel() { + return level; + } + + public void setGeometry(float st, float sw, int inside, int outside) { + start = st; + sweep = sw; + inner = inside; + outer = outside; + } + + public void setFixedSlice(float center, float sweep) { + mCenter = center; + this.sweep = sweep; + } + + public float getCenter() { + return mCenter; + } + + public float getStart() { + return start; + } + + public float getStartAngle() { + return start + animate; + } + + public float getSweep() { + return sweep; + } + + public int getInnerRadius() { + return inner; + } + + public int getOuterRadius() { + return outer; + } + + public void setOnClickListener(OnClickListener listener) { + mOnClickListener = listener; + } + + public void performClick() { + if (mOnClickListener != null) { + mOnClickListener.onClick(this); + } + } + + public int getIntrinsicWidth() { + return mDrawable.getIntrinsicWidth(); + } + + public int getIntrinsicHeight() { + return mDrawable.getIntrinsicHeight(); + } + + public void setBounds(int left, int top, int right, int bottom) { + mDrawable.setBounds(left, top, right, bottom); + } + + public void draw(Canvas canvas) { + mDrawable.draw(canvas); + } + + public void setImageResource(Context context, int resId) { + Drawable d = context.getResources().getDrawable(resId).mutate(); + d.setBounds(mDrawable.getBounds()); + mDrawable = d; + setAlpha(mAlpha); + } + +}
\ No newline at end of file diff --git a/src/com/android/messaging/ui/mediapicker/camerafocus/PieRenderer.java b/src/com/android/messaging/ui/mediapicker/camerafocus/PieRenderer.java new file mode 100644 index 0000000..ce8ca00 --- /dev/null +++ b/src/com/android/messaging/ui/mediapicker/camerafocus/PieRenderer.java @@ -0,0 +1,825 @@ +/* + * 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.ui.mediapicker.camerafocus; + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.Path; +import android.graphics.Point; +import android.graphics.PointF; +import android.graphics.RectF; +import android.os.Handler; +import android.os.Message; +import android.view.MotionEvent; +import android.view.ViewConfiguration; +import android.view.animation.Animation; +import android.view.animation.Animation.AnimationListener; +import android.view.animation.LinearInterpolator; +import android.view.animation.Transformation; +import com.android.messaging.R; + +import java.util.ArrayList; +import java.util.List; + +public class PieRenderer extends OverlayRenderer + implements FocusIndicator { + // Sometimes continuous autofocus starts and stops several times quickly. + // These states are used to make sure the animation is run for at least some + // time. + private volatile int mState; + private ScaleAnimation mAnimation = new ScaleAnimation(); + private static final int STATE_IDLE = 0; + private static final int STATE_FOCUSING = 1; + private static final int STATE_FINISHING = 2; + private static final int STATE_PIE = 8; + + private Runnable mDisappear = new Disappear(); + private Animation.AnimationListener mEndAction = new EndAction(); + private static final int SCALING_UP_TIME = 600; + private static final int SCALING_DOWN_TIME = 100; + private static final int DISAPPEAR_TIMEOUT = 200; + private static final int DIAL_HORIZONTAL = 157; + + private static final long PIE_FADE_IN_DURATION = 200; + private static final long PIE_XFADE_DURATION = 200; + private static final long PIE_SELECT_FADE_DURATION = 300; + + private static final int MSG_OPEN = 0; + private static final int MSG_CLOSE = 1; + private static final float PIE_SWEEP = (float) (Math.PI * 2 / 3); + // geometry + private Point mCenter; + private int mRadius; + private int mRadiusInc; + + // the detection if touch is inside a slice is offset + // inbounds by this amount to allow the selection to show before the + // finger covers it + private int mTouchOffset; + + private List<PieItem> mItems; + + private PieItem mOpenItem; + + private Paint mSelectedPaint; + private Paint mSubPaint; + + // touch handling + private PieItem mCurrentItem; + + private Paint mFocusPaint; + private int mSuccessColor; + private int mFailColor; + private int mCircleSize; + private int mFocusX; + private int mFocusY; + private int mCenterX; + private int mCenterY; + + private int mDialAngle; + private RectF mCircle; + private RectF mDial; + private Point mPoint1; + private Point mPoint2; + private int mStartAnimationAngle; + private boolean mFocused; + private int mInnerOffset; + private int mOuterStroke; + private int mInnerStroke; + private boolean mTapMode; + private boolean mBlockFocus; + private int mTouchSlopSquared; + private Point mDown; + private boolean mOpening; + private LinearAnimation mXFade; + private LinearAnimation mFadeIn; + private volatile boolean mFocusCancelled; + + private Handler mHandler = new Handler() { + public void handleMessage(Message msg) { + switch(msg.what) { + case MSG_OPEN: + if (mListener != null) { + mListener.onPieOpened(mCenter.x, mCenter.y); + } + break; + case MSG_CLOSE: + if (mListener != null) { + mListener.onPieClosed(); + } + break; + } + } + }; + + private PieListener mListener; + + public static interface PieListener { + public void onPieOpened(int centerX, int centerY); + public void onPieClosed(); + } + + public void setPieListener(PieListener pl) { + mListener = pl; + } + + public PieRenderer(Context context) { + init(context); + } + + private void init(Context ctx) { + setVisible(false); + mItems = new ArrayList<PieItem>(); + Resources res = ctx.getResources(); + mRadius = (int) res.getDimensionPixelSize(R.dimen.pie_radius_start); + mCircleSize = mRadius - res.getDimensionPixelSize(R.dimen.focus_radius_offset); + mRadiusInc = (int) res.getDimensionPixelSize(R.dimen.pie_radius_increment); + mTouchOffset = (int) res.getDimensionPixelSize(R.dimen.pie_touch_offset); + mCenter = new Point(0, 0); + mSelectedPaint = new Paint(); + mSelectedPaint.setColor(Color.argb(255, 51, 181, 229)); + mSelectedPaint.setAntiAlias(true); + mSubPaint = new Paint(); + mSubPaint.setAntiAlias(true); + mSubPaint.setColor(Color.argb(200, 250, 230, 128)); + mFocusPaint = new Paint(); + mFocusPaint.setAntiAlias(true); + mFocusPaint.setColor(Color.WHITE); + mFocusPaint.setStyle(Paint.Style.STROKE); + mSuccessColor = Color.GREEN; + mFailColor = Color.RED; + mCircle = new RectF(); + mDial = new RectF(); + mPoint1 = new Point(); + mPoint2 = new Point(); + mInnerOffset = res.getDimensionPixelSize(R.dimen.focus_inner_offset); + mOuterStroke = res.getDimensionPixelSize(R.dimen.focus_outer_stroke); + mInnerStroke = res.getDimensionPixelSize(R.dimen.focus_inner_stroke); + mState = STATE_IDLE; + mBlockFocus = false; + mTouchSlopSquared = ViewConfiguration.get(ctx).getScaledTouchSlop(); + mTouchSlopSquared = mTouchSlopSquared * mTouchSlopSquared; + mDown = new Point(); + } + + public boolean showsItems() { + return mTapMode; + } + + public void addItem(PieItem item) { + // add the item to the pie itself + mItems.add(item); + } + + public void removeItem(PieItem item) { + mItems.remove(item); + } + + public void clearItems() { + mItems.clear(); + } + + public void showInCenter() { + if ((mState == STATE_PIE) && isVisible()) { + mTapMode = false; + show(false); + } else { + if (mState != STATE_IDLE) { + cancelFocus(); + } + mState = STATE_PIE; + setCenter(mCenterX, mCenterY); + mTapMode = true; + show(true); + } + } + + public void hide() { + show(false); + } + + /** + * guaranteed has center set + * @param show + */ + private void show(boolean show) { + if (show) { + mState = STATE_PIE; + // ensure clean state + mCurrentItem = null; + mOpenItem = null; + for (PieItem item : mItems) { + item.setSelected(false); + } + layoutPie(); + fadeIn(); + } else { + mState = STATE_IDLE; + mTapMode = false; + if (mXFade != null) { + mXFade.cancel(); + } + } + setVisible(show); + mHandler.sendEmptyMessage(show ? MSG_OPEN : MSG_CLOSE); + } + + private void fadeIn() { + mFadeIn = new LinearAnimation(0, 1); + mFadeIn.setDuration(PIE_FADE_IN_DURATION); + mFadeIn.setAnimationListener(new AnimationListener() { + @Override + public void onAnimationStart(Animation animation) { + } + + @Override + public void onAnimationEnd(Animation animation) { + mFadeIn = null; + } + + @Override + public void onAnimationRepeat(Animation animation) { + } + }); + mFadeIn.startNow(); + mOverlay.startAnimation(mFadeIn); + } + + public void setCenter(int x, int y) { + mCenter.x = x; + mCenter.y = y; + // when using the pie menu, align the focus ring + alignFocus(x, y); + } + + private void layoutPie() { + int rgap = 2; + int inner = mRadius + rgap; + int outer = mRadius + mRadiusInc - rgap; + int gap = 1; + layoutItems(mItems, (float) (Math.PI / 2), inner, outer, gap); + } + + private void layoutItems(List<PieItem> items, float centerAngle, int inner, + int outer, int gap) { + float emptyangle = PIE_SWEEP / 16; + float sweep = (float) (PIE_SWEEP - 2 * emptyangle) / items.size(); + float angle = centerAngle - PIE_SWEEP / 2 + emptyangle + sweep / 2; + // check if we have custom geometry + // first item we find triggers custom sweep for all + // this allows us to re-use the path + for (PieItem item : items) { + if (item.getCenter() >= 0) { + sweep = item.getSweep(); + break; + } + } + Path path = makeSlice(getDegrees(0) - gap, getDegrees(sweep) + gap, + outer, inner, mCenter); + for (PieItem item : items) { + // shared between items + item.setPath(path); + if (item.getCenter() >= 0) { + angle = item.getCenter(); + } + int w = item.getIntrinsicWidth(); + int h = item.getIntrinsicHeight(); + // move views to outer border + int r = inner + (outer - inner) * 2 / 3; + int x = (int) (r * Math.cos(angle)); + int y = mCenter.y - (int) (r * Math.sin(angle)) - h / 2; + x = mCenter.x + x - w / 2; + item.setBounds(x, y, x + w, y + h); + float itemstart = angle - sweep / 2; + item.setGeometry(itemstart, sweep, inner, outer); + if (item.hasItems()) { + layoutItems(item.getItems(), angle, inner, + outer + mRadiusInc / 2, gap); + } + angle += sweep; + } + } + + private Path makeSlice(float start, float end, int outer, int inner, Point center) { + RectF bb = + new RectF(center.x - outer, center.y - outer, center.x + outer, + center.y + outer); + RectF bbi = + new RectF(center.x - inner, center.y - inner, center.x + inner, + center.y + inner); + Path path = new Path(); + path.arcTo(bb, start, end - start, true); + path.arcTo(bbi, end, start - end); + path.close(); + return path; + } + + /** + * converts a + * @param angle from 0..PI to Android degrees (clockwise starting at 3 o'clock) + * @return skia angle + */ + private float getDegrees(double angle) { + return (float) (360 - 180 * angle / Math.PI); + } + + private void startFadeOut() { + mOverlay.animate().alpha(0).setListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator animation) { + deselect(); + show(false); + mOverlay.setAlpha(1); + super.onAnimationEnd(animation); + } + }).setDuration(PIE_SELECT_FADE_DURATION); + } + + @Override + public void onDraw(Canvas canvas) { + float alpha = 1; + if (mXFade != null) { + alpha = mXFade.getValue(); + } else if (mFadeIn != null) { + alpha = mFadeIn.getValue(); + } + int state = canvas.save(); + if (mFadeIn != null) { + float sf = 0.9f + alpha * 0.1f; + canvas.scale(sf, sf, mCenter.x, mCenter.y); + } + drawFocus(canvas); + if (mState == STATE_FINISHING) { + canvas.restoreToCount(state); + return; + } + if ((mOpenItem == null) || (mXFade != null)) { + // draw base menu + for (PieItem item : mItems) { + drawItem(canvas, item, alpha); + } + } + if (mOpenItem != null) { + for (PieItem inner : mOpenItem.getItems()) { + drawItem(canvas, inner, (mXFade != null) ? (1 - 0.5f * alpha) : 1); + } + } + canvas.restoreToCount(state); + } + + private void drawItem(Canvas canvas, PieItem item, float alpha) { + if (mState == STATE_PIE) { + if (item.getPath() != null) { + if (item.isSelected()) { + Paint p = mSelectedPaint; + int state = canvas.save(); + float r = getDegrees(item.getStartAngle()); + canvas.rotate(r, mCenter.x, mCenter.y); + canvas.drawPath(item.getPath(), p); + canvas.restoreToCount(state); + } + alpha = alpha * (item.isEnabled() ? 1 : 0.3f); + // draw the item view + item.setAlpha(alpha); + item.draw(canvas); + } + } + } + + @Override + public boolean onTouchEvent(MotionEvent evt) { + float x = evt.getX(); + float y = evt.getY(); + int action = evt.getActionMasked(); + PointF polar = getPolar(x, y, !(mTapMode)); + if (MotionEvent.ACTION_DOWN == action) { + mDown.x = (int) evt.getX(); + mDown.y = (int) evt.getY(); + mOpening = false; + if (mTapMode) { + PieItem item = findItem(polar); + if ((item != null) && (mCurrentItem != item)) { + mState = STATE_PIE; + onEnter(item); + } + } else { + setCenter((int) x, (int) y); + show(true); + } + return true; + } else if (MotionEvent.ACTION_UP == action) { + if (isVisible()) { + PieItem item = mCurrentItem; + if (mTapMode) { + item = findItem(polar); + if (item != null && mOpening) { + mOpening = false; + return true; + } + } + if (item == null) { + mTapMode = false; + show(false); + } else if (!mOpening + && !item.hasItems()) { + item.performClick(); + startFadeOut(); + mTapMode = false; + } + return true; + } + } else if (MotionEvent.ACTION_CANCEL == action) { + if (isVisible() || mTapMode) { + show(false); + } + deselect(); + return false; + } else if (MotionEvent.ACTION_MOVE == action) { + if (polar.y < mRadius) { + if (mOpenItem != null) { + mOpenItem = null; + } else { + deselect(); + } + return false; + } + PieItem item = findItem(polar); + boolean moved = hasMoved(evt); + if ((item != null) && (mCurrentItem != item) && (!mOpening || moved)) { + // only select if we didn't just open or have moved past slop + mOpening = false; + if (moved) { + // switch back to swipe mode + mTapMode = false; + } + onEnter(item); + } + } + return false; + } + + private boolean hasMoved(MotionEvent e) { + return mTouchSlopSquared < (e.getX() - mDown.x) * (e.getX() - mDown.x) + + (e.getY() - mDown.y) * (e.getY() - mDown.y); + } + + /** + * enter a slice for a view + * updates model only + * @param item + */ + private void onEnter(PieItem item) { + if (mCurrentItem != null) { + mCurrentItem.setSelected(false); + } + if (item != null && item.isEnabled()) { + item.setSelected(true); + mCurrentItem = item; + if ((mCurrentItem != mOpenItem) && mCurrentItem.hasItems()) { + openCurrentItem(); + } + } else { + mCurrentItem = null; + } + } + + private void deselect() { + if (mCurrentItem != null) { + mCurrentItem.setSelected(false); + } + if (mOpenItem != null) { + mOpenItem = null; + } + mCurrentItem = null; + } + + private void openCurrentItem() { + if ((mCurrentItem != null) && mCurrentItem.hasItems()) { + mCurrentItem.setSelected(false); + mOpenItem = mCurrentItem; + mOpening = true; + mXFade = new LinearAnimation(1, 0); + mXFade.setDuration(PIE_XFADE_DURATION); + mXFade.setAnimationListener(new AnimationListener() { + @Override + public void onAnimationStart(Animation animation) { + } + + @Override + public void onAnimationEnd(Animation animation) { + mXFade = null; + } + + @Override + public void onAnimationRepeat(Animation animation) { + } + }); + mXFade.startNow(); + mOverlay.startAnimation(mXFade); + } + } + + private PointF getPolar(float x, float y, boolean useOffset) { + PointF res = new PointF(); + // get angle and radius from x/y + res.x = (float) Math.PI / 2; + x = x - mCenter.x; + y = mCenter.y - y; + res.y = (float) Math.sqrt(x * x + y * y); + if (x != 0) { + res.x = (float) Math.atan2(y, x); + if (res.x < 0) { + res.x = (float) (2 * Math.PI + res.x); + } + } + res.y = res.y + (useOffset ? mTouchOffset : 0); + return res; + } + + /** + * @param polar x: angle, y: dist + * @return the item at angle/dist or null + */ + private PieItem findItem(PointF polar) { + // find the matching item: + List<PieItem> items = (mOpenItem != null) ? mOpenItem.getItems() : mItems; + for (PieItem item : items) { + if (inside(polar, item)) { + return item; + } + } + return null; + } + + private boolean inside(PointF polar, PieItem item) { + return (item.getInnerRadius() < polar.y) + && (item.getStartAngle() < polar.x) + && (item.getStartAngle() + item.getSweep() > polar.x) + && (!mTapMode || (item.getOuterRadius() > polar.y)); + } + + @Override + public boolean handlesTouch() { + return true; + } + + // focus specific code + + public void setBlockFocus(boolean blocked) { + mBlockFocus = blocked; + if (blocked) { + clear(); + } + } + + public void setFocus(int x, int y) { + mFocusX = x; + mFocusY = y; + setCircle(mFocusX, mFocusY); + } + + public void alignFocus(int x, int y) { + mOverlay.removeCallbacks(mDisappear); + mAnimation.cancel(); + mAnimation.reset(); + mFocusX = x; + mFocusY = y; + mDialAngle = DIAL_HORIZONTAL; + setCircle(x, y); + mFocused = false; + } + + public int getSize() { + return 2 * mCircleSize; + } + + private int getRandomRange() { + return (int) (-60 + 120 * Math.random()); + } + + @Override + public void layout(int l, int t, int r, int b) { + super.layout(l, t, r, b); + mCenterX = (r - l) / 2; + mCenterY = (b - t) / 2; + mFocusX = mCenterX; + mFocusY = mCenterY; + setCircle(mFocusX, mFocusY); + if (isVisible() && mState == STATE_PIE) { + setCenter(mCenterX, mCenterY); + layoutPie(); + } + } + + private void setCircle(int cx, int cy) { + mCircle.set(cx - mCircleSize, cy - mCircleSize, + cx + mCircleSize, cy + mCircleSize); + mDial.set(cx - mCircleSize + mInnerOffset, cy - mCircleSize + mInnerOffset, + cx + mCircleSize - mInnerOffset, cy + mCircleSize - mInnerOffset); + } + + public void drawFocus(Canvas canvas) { + if (mBlockFocus) { + return; + } + mFocusPaint.setStrokeWidth(mOuterStroke); + canvas.drawCircle((float) mFocusX, (float) mFocusY, (float) mCircleSize, mFocusPaint); + if (mState == STATE_PIE) { + return; + } + int color = mFocusPaint.getColor(); + if (mState == STATE_FINISHING) { + mFocusPaint.setColor(mFocused ? mSuccessColor : mFailColor); + } + mFocusPaint.setStrokeWidth(mInnerStroke); + drawLine(canvas, mDialAngle, mFocusPaint); + drawLine(canvas, mDialAngle + 45, mFocusPaint); + drawLine(canvas, mDialAngle + 180, mFocusPaint); + drawLine(canvas, mDialAngle + 225, mFocusPaint); + canvas.save(); + // rotate the arc instead of its offset to better use framework's shape caching + canvas.rotate(mDialAngle, mFocusX, mFocusY); + canvas.drawArc(mDial, 0, 45, false, mFocusPaint); + canvas.drawArc(mDial, 180, 45, false, mFocusPaint); + canvas.restore(); + mFocusPaint.setColor(color); + } + + private void drawLine(Canvas canvas, int angle, Paint p) { + convertCart(angle, mCircleSize - mInnerOffset, mPoint1); + convertCart(angle, mCircleSize - mInnerOffset + mInnerOffset / 3, mPoint2); + canvas.drawLine(mPoint1.x + mFocusX, mPoint1.y + mFocusY, + mPoint2.x + mFocusX, mPoint2.y + mFocusY, p); + } + + private static void convertCart(int angle, int radius, Point out) { + double a = 2 * Math.PI * (angle % 360) / 360; + out.x = (int) (radius * Math.cos(a) + 0.5); + out.y = (int) (radius * Math.sin(a) + 0.5); + } + + @Override + public void showStart() { + if (mState == STATE_PIE) { + return; + } + cancelFocus(); + mStartAnimationAngle = 67; + int range = getRandomRange(); + startAnimation(SCALING_UP_TIME, + false, mStartAnimationAngle, mStartAnimationAngle + range); + mState = STATE_FOCUSING; + } + + @Override + public void showSuccess(boolean timeout) { + if (mState == STATE_FOCUSING) { + startAnimation(SCALING_DOWN_TIME, + timeout, mStartAnimationAngle); + mState = STATE_FINISHING; + mFocused = true; + } + } + + @Override + public void showFail(boolean timeout) { + if (mState == STATE_FOCUSING) { + startAnimation(SCALING_DOWN_TIME, + timeout, mStartAnimationAngle); + mState = STATE_FINISHING; + mFocused = false; + } + } + + private void cancelFocus() { + mFocusCancelled = true; + mOverlay.removeCallbacks(mDisappear); + if (mAnimation != null) { + mAnimation.cancel(); + } + mFocusCancelled = false; + mFocused = false; + mState = STATE_IDLE; + } + + @Override + public void clear() { + if (mState == STATE_PIE) { + return; + } + cancelFocus(); + mOverlay.post(mDisappear); + } + + private void startAnimation(long duration, boolean timeout, + float toScale) { + startAnimation(duration, timeout, mDialAngle, + toScale); + } + + private void startAnimation(long duration, boolean timeout, + float fromScale, float toScale) { + setVisible(true); + mAnimation.reset(); + mAnimation.setDuration(duration); + mAnimation.setScale(fromScale, toScale); + mAnimation.setAnimationListener(timeout ? mEndAction : null); + mOverlay.startAnimation(mAnimation); + update(); + } + + private class EndAction implements Animation.AnimationListener { + @Override + public void onAnimationEnd(Animation animation) { + // Keep the focus indicator for some time. + if (!mFocusCancelled) { + mOverlay.postDelayed(mDisappear, DISAPPEAR_TIMEOUT); + } + } + + @Override + public void onAnimationRepeat(Animation animation) { + } + + @Override + public void onAnimationStart(Animation animation) { + } + } + + private class Disappear implements Runnable { + @Override + public void run() { + if (mState == STATE_PIE) { + return; + } + setVisible(false); + mFocusX = mCenterX; + mFocusY = mCenterY; + mState = STATE_IDLE; + setCircle(mFocusX, mFocusY); + mFocused = false; + } + } + + private class ScaleAnimation extends Animation { + private float mFrom = 1f; + private float mTo = 1f; + + public ScaleAnimation() { + setFillAfter(true); + } + + public void setScale(float from, float to) { + mFrom = from; + mTo = to; + } + + @Override + protected void applyTransformation(float interpolatedTime, Transformation t) { + mDialAngle = (int) (mFrom + (mTo - mFrom) * interpolatedTime); + } + } + + + private class LinearAnimation extends Animation { + private float mFrom; + private float mTo; + private float mValue; + + public LinearAnimation(float from, float to) { + setFillAfter(true); + setInterpolator(new LinearInterpolator()); + mFrom = from; + mTo = to; + } + + public float getValue() { + return mValue; + } + + @Override + protected void applyTransformation(float interpolatedTime, Transformation t) { + mValue = (mFrom + (mTo - mFrom) * interpolatedTime); + } + } + +}
\ No newline at end of file diff --git a/src/com/android/messaging/ui/mediapicker/camerafocus/README.txt b/src/com/android/messaging/ui/mediapicker/camerafocus/README.txt new file mode 100644 index 0000000..ed4e783 --- /dev/null +++ b/src/com/android/messaging/ui/mediapicker/camerafocus/README.txt @@ -0,0 +1,3 @@ +The files in this package were copied from the android-4.4.4_r1 branch of ASOP from the folders +com/android/camera/ and com/android/camera/ui from files with the same name. Some modifications +have been made to remove unneeded features and adjust to our needs.
\ No newline at end of file diff --git a/src/com/android/messaging/ui/mediapicker/camerafocus/RenderOverlay.java b/src/com/android/messaging/ui/mediapicker/camerafocus/RenderOverlay.java new file mode 100644 index 0000000..95cddc4 --- /dev/null +++ b/src/com/android/messaging/ui/mediapicker/camerafocus/RenderOverlay.java @@ -0,0 +1,178 @@ +/* + * 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.ui.mediapicker.camerafocus; + +import android.content.Context; +import android.graphics.Canvas; +import android.util.AttributeSet; +import android.view.MotionEvent; +import android.view.View; +import android.widget.FrameLayout; + +import java.util.ArrayList; +import java.util.List; + +public class RenderOverlay extends FrameLayout { + + interface Renderer { + + public boolean handlesTouch(); + public boolean onTouchEvent(MotionEvent evt); + public void setOverlay(RenderOverlay overlay); + public void layout(int left, int top, int right, int bottom); + public void draw(Canvas canvas); + + } + + private RenderView mRenderView; + private List<Renderer> mClients; + + // reverse list of touch clients + private List<Renderer> mTouchClients; + private int[] mPosition = new int[2]; + + public RenderOverlay(Context context, AttributeSet attrs) { + super(context, attrs); + mRenderView = new RenderView(context); + addView(mRenderView, new LayoutParams(LayoutParams.MATCH_PARENT, + LayoutParams.MATCH_PARENT)); + mClients = new ArrayList<Renderer>(10); + mTouchClients = new ArrayList<Renderer>(10); + setWillNotDraw(false); + + addRenderer(new PieRenderer(context)); + } + + public PieRenderer getPieRenderer() { + for (Renderer renderer : mClients) { + if (renderer instanceof PieRenderer) { + return (PieRenderer) renderer; + } + } + return null; + } + + public void addRenderer(Renderer renderer) { + mClients.add(renderer); + renderer.setOverlay(this); + if (renderer.handlesTouch()) { + mTouchClients.add(0, renderer); + } + renderer.layout(getLeft(), getTop(), getRight(), getBottom()); + } + + public void addRenderer(int pos, Renderer renderer) { + mClients.add(pos, renderer); + renderer.setOverlay(this); + renderer.layout(getLeft(), getTop(), getRight(), getBottom()); + } + + public void remove(Renderer renderer) { + mClients.remove(renderer); + renderer.setOverlay(null); + } + + public int getClientSize() { + return mClients.size(); + } + + @Override + public boolean dispatchTouchEvent(MotionEvent m) { + return false; + } + + public boolean directDispatchTouch(MotionEvent m, Renderer target) { + mRenderView.setTouchTarget(target); + boolean res = super.dispatchTouchEvent(m); + mRenderView.setTouchTarget(null); + return res; + } + + private void adjustPosition() { + getLocationInWindow(mPosition); + } + + public int getWindowPositionX() { + return mPosition[0]; + } + + public int getWindowPositionY() { + return mPosition[1]; + } + + public void update() { + mRenderView.invalidate(); + } + + private class RenderView extends View { + + private Renderer mTouchTarget; + + public RenderView(Context context) { + super(context); + setWillNotDraw(false); + } + + public void setTouchTarget(Renderer target) { + mTouchTarget = target; + } + + @Override + public boolean onTouchEvent(MotionEvent evt) { + if (mTouchTarget != null) { + return mTouchTarget.onTouchEvent(evt); + } + if (mTouchClients != null) { + boolean res = false; + for (Renderer client : mTouchClients) { + res |= client.onTouchEvent(evt); + } + return res; + } + return false; + } + + @Override + public void onLayout(boolean changed, int left, int top, int right, int bottom) { + adjustPosition(); + super.onLayout(changed, left, top, right, bottom); + if (mClients == null) { + return; + } + for (Renderer renderer : mClients) { + renderer.layout(left, top, right, bottom); + } + } + + @Override + public void draw(Canvas canvas) { + super.draw(canvas); + if (mClients == null) { + return; + } + boolean redraw = false; + for (Renderer renderer : mClients) { + renderer.draw(canvas); + redraw = redraw || ((OverlayRenderer) renderer).isVisible(); + } + if (redraw) { + invalidate(); + } + } + } + +}
\ No newline at end of file diff --git a/src/com/android/messaging/ui/photoviewer/BuglePhotoBitmapLoader.java b/src/com/android/messaging/ui/photoviewer/BuglePhotoBitmapLoader.java new file mode 100644 index 0000000..d139a38 --- /dev/null +++ b/src/com/android/messaging/ui/photoviewer/BuglePhotoBitmapLoader.java @@ -0,0 +1,192 @@ +/* + * 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.ui.photoviewer; + +import android.content.Context; +import android.graphics.drawable.Drawable; +import android.net.Uri; +import android.support.rastermill.FrameSequenceDrawable; +import android.support.v4.content.AsyncTaskLoader; + +import com.android.ex.photo.PhotoViewController; +import com.android.ex.photo.loaders.PhotoBitmapLoaderInterface; +import com.android.ex.photo.loaders.PhotoBitmapLoaderInterface.BitmapResult; +import com.android.messaging.datamodel.media.ImageRequestDescriptor; +import com.android.messaging.datamodel.media.ImageResource; +import com.android.messaging.datamodel.media.MediaRequest; +import com.android.messaging.datamodel.media.MediaResourceManager; +import com.android.messaging.datamodel.media.UriImageRequestDescriptor; +import com.android.messaging.util.ImageUtils; + +/** + * Loader for the bitmap of a photo. + */ +public class BuglePhotoBitmapLoader extends AsyncTaskLoader<BitmapResult> + implements PhotoBitmapLoaderInterface { + private String mPhotoUri; + private ImageResource mImageResource; + // The drawable that is currently "in use" and being presented to the user. This drawable + // should never exist without the image resource backing it. + private Drawable mDrawable; + + public BuglePhotoBitmapLoader(Context context, String photoUri) { + super(context); + mPhotoUri = photoUri; + } + + @Override + public void setPhotoUri(String photoUri) { + mPhotoUri = photoUri; + } + + @Override + public BitmapResult loadInBackground() { + final BitmapResult result = new BitmapResult(); + final Context context = getContext(); + if (context != null && mPhotoUri != null) { + final ImageRequestDescriptor descriptor = + new UriImageRequestDescriptor(Uri.parse(mPhotoUri), + PhotoViewController.sMaxPhotoSize, PhotoViewController.sMaxPhotoSize, + true /* allowCompression */, false /* isStatic */, + false /* cropToCircle */, + ImageUtils.DEFAULT_CIRCLE_BACKGROUND_COLOR /* circleBackgroundColor */, + ImageUtils.DEFAULT_CIRCLE_STROKE_COLOR /* circleStrokeColor */); + final MediaRequest<ImageResource> imageRequest = + descriptor.buildSyncMediaRequest(context); + final ImageResource imageResource = + MediaResourceManager.get().requestMediaResourceSync(imageRequest); + if (imageResource != null) { + setImageResource(imageResource); + result.status = BitmapResult.STATUS_SUCCESS; + result.drawable = mImageResource.getDrawable(context.getResources()); + } else { + releaseImageResource(); + result.status = BitmapResult.STATUS_EXCEPTION; + } + } else { + result.status = BitmapResult.STATUS_EXCEPTION; + } + return result; + } + + /** + * Called when there is new data to deliver to the client. The super class will take care of + * delivering it; the implementation here just adds a little more logic. + */ + @Override + public void deliverResult(BitmapResult result) { + final Drawable drawable = result != null ? result.drawable : null; + if (isReset()) { + // An async query came in while the loader is stopped. We don't need the result. + releaseDrawable(drawable); + return; + } + + // We are now going to display this drawable so set to mDrawable + mDrawable = drawable; + + if (isStarted()) { + // If the Loader is currently started, we can immediately deliver its results. + super.deliverResult(result); + } + } + + /** + * Handles a request to start the Loader. + */ + @Override + protected void onStartLoading() { + if (mDrawable != null) { + // If we currently have a result available, deliver it + // immediately. + final BitmapResult result = new BitmapResult(); + result.status = BitmapResult.STATUS_SUCCESS; + result.drawable = mDrawable; + deliverResult(result); + } + + if (takeContentChanged() || (mImageResource == null)) { + // If the data has changed since the last time it was loaded + // or is not currently available, start a load. + forceLoad(); + } + } + + /** + * Handles a request to stop the Loader. + */ + @Override + protected void onStopLoading() { + // Attempt to cancel the current load task if possible. + cancelLoad(); + } + + /** + * Handles a request to cancel a load. + */ + @Override + public void onCanceled(BitmapResult result) { + super.onCanceled(result); + + // At this point we can release the resources associated with 'drawable' if needed. + if (result != null) { + releaseDrawable(result.drawable); + } + } + + /** + * Handles a request to completely reset the Loader. + */ + @Override + protected void onReset() { + super.onReset(); + + // Ensure the loader is stopped + onStopLoading(); + + releaseImageResource(); + } + + private void releaseDrawable(Drawable drawable) { + if (drawable != null && drawable instanceof FrameSequenceDrawable + && !((FrameSequenceDrawable) drawable).isDestroyed()) { + ((FrameSequenceDrawable) drawable).destroy(); + } + + } + + private void setImageResource(final ImageResource resource) { + if (mImageResource != resource) { + // Clear out any information for what is currently used + releaseImageResource(); + mImageResource = resource; + // No need to add ref since a ref is already reserved as a result of + // requestMediaResourceSync. + } + } + + private void releaseImageResource() { + // If we are getting rid of the imageResource backing the drawable, we must also + // destroy the drawable before releasing it. + releaseDrawable(mDrawable); + mDrawable = null; + + if (mImageResource != null) { + mImageResource.release(); + } + mImageResource = null; + } +}
\ No newline at end of file diff --git a/src/com/android/messaging/ui/photoviewer/BuglePhotoPageAdapter.java b/src/com/android/messaging/ui/photoviewer/BuglePhotoPageAdapter.java new file mode 100644 index 0000000..52498c7 --- /dev/null +++ b/src/com/android/messaging/ui/photoviewer/BuglePhotoPageAdapter.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.ui.photoviewer; + +import android.content.Context; +import android.content.Intent; +import android.database.Cursor; +import android.support.v4.app.FragmentManager; + +import com.android.ex.photo.adapters.PhotoPagerAdapter; +import com.android.ex.photo.fragments.PhotoViewFragment; + +public class BuglePhotoPageAdapter extends PhotoPagerAdapter { + + public BuglePhotoPageAdapter(Context context, FragmentManager fm, Cursor c, float maxScale, + boolean thumbsFullScreen) { + super(context, fm, c, maxScale, thumbsFullScreen); + } + + @Override + protected PhotoViewFragment createPhotoViewFragment(Intent intent, int position, + boolean onlyShowSpinner) { + return BuglePhotoViewFragment.newInstance(intent, position, onlyShowSpinner); + } +} diff --git a/src/com/android/messaging/ui/photoviewer/BuglePhotoViewActivity.java b/src/com/android/messaging/ui/photoviewer/BuglePhotoViewActivity.java new file mode 100644 index 0000000..1924a96 --- /dev/null +++ b/src/com/android/messaging/ui/photoviewer/BuglePhotoViewActivity.java @@ -0,0 +1,31 @@ +/* + * 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.ui.photoviewer; + +import com.android.ex.photo.PhotoViewActivity; +import com.android.ex.photo.PhotoViewController; + +/** + * Activity to display the conversation images in full-screen. Most of the customization is in + * {@link BuglePhotoViewController}. + */ +public class BuglePhotoViewActivity extends PhotoViewActivity { + @Override + public PhotoViewController createController() { + return new BuglePhotoViewController(this); + } +} diff --git a/src/com/android/messaging/ui/photoviewer/BuglePhotoViewController.java b/src/com/android/messaging/ui/photoviewer/BuglePhotoViewController.java new file mode 100644 index 0000000..eb39886 --- /dev/null +++ b/src/com/android/messaging/ui/photoviewer/BuglePhotoViewController.java @@ -0,0 +1,179 @@ +/* + * 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.ui.photoviewer; + +import android.Manifest; +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.database.Cursor; +import android.net.Uri; +import android.os.Bundle; +import android.support.v4.app.FragmentManager; +import android.support.v4.content.Loader; +import android.text.TextUtils; +import android.view.Menu; +import android.view.MenuItem; +import android.widget.ShareActionProvider; +import android.widget.Toast; + +import com.android.ex.photo.PhotoViewController; +import com.android.ex.photo.adapters.PhotoPagerAdapter; +import com.android.ex.photo.loaders.PhotoBitmapLoaderInterface.BitmapResult; +import com.android.messaging.R; +import com.android.messaging.datamodel.ConversationImagePartsView.PhotoViewQuery; +import com.android.messaging.datamodel.MediaScratchFileProvider; +import com.android.messaging.ui.conversation.ConversationFragment; +import com.android.messaging.util.Dates; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.OsUtil; + +/** + * Customizations for the photoviewer to display conversation images in full screen. + */ +public class BuglePhotoViewController extends PhotoViewController { + private ShareActionProvider mShareActionProvider; + private MenuItem mShareItem; + private MenuItem mSaveItem; + + public BuglePhotoViewController(final ActivityInterface activity) { + super(activity); + } + + @Override + public Loader<BitmapResult> onCreateBitmapLoader( + final int id, final Bundle args, final String uri) { + switch (id) { + case BITMAP_LOADER_AVATAR: + case BITMAP_LOADER_THUMBNAIL: + case BITMAP_LOADER_PHOTO: + return new BuglePhotoBitmapLoader(getActivity().getContext(), uri); + default: + LogUtil.e(LogUtil.BUGLE_TAG, + "Photoviewer unable to open bitmap loader with unknown id: " + id); + return null; + } + } + + @Override + public void updateActionBar() { + final Cursor cursor = getCursorAtProperPosition(); + + if (mSaveItem == null || cursor == null) { + // Load not finished, called from framework code before ready + return; + } + // Show the name as the title + mActionBarTitle = cursor.getString(PhotoViewQuery.INDEX_SENDER_FULL_NAME); + if (TextUtils.isEmpty(mActionBarTitle)) { + // If the name is not known, fall back to the phone number + mActionBarTitle = cursor.getString(PhotoViewQuery.INDEX_DISPLAY_DESTINATION); + } + + // Show the timestamp as the subtitle + final long receivedTimestamp = cursor.getLong(PhotoViewQuery.INDEX_RECEIVED_TIMESTAMP); + mActionBarSubtitle = Dates.getMessageTimeString(receivedTimestamp).toString(); + + setActionBarTitles(getActivity().getActionBarInterface()); + mSaveItem.setVisible(!isTempFile()); + + updateShareActionProvider(); + } + + private void updateShareActionProvider() { + final PhotoPagerAdapter adapter = getAdapter(); + final Cursor cursor = getCursorAtProperPosition(); + if (mShareActionProvider == null || mShareItem == null || adapter == null || + cursor == null) { + // Not enough stuff loaded to update the share action + return; + } + final String photoUri = adapter.getPhotoUri(cursor); + if (isTempFile()) { + mShareItem.setVisible(false); + return; + } + final String contentType = adapter.getContentType(cursor); + + final Intent shareIntent = new Intent(); + shareIntent.setAction(Intent.ACTION_SEND); + shareIntent.setType(contentType); + shareIntent.putExtra(Intent.EXTRA_STREAM, Uri.parse(photoUri)); + mShareActionProvider.setShareIntent(shareIntent); + mShareItem.setVisible(true); + } + + /** + * Checks whether the current photo is a temp file. A temp file can be deleted at any time, so + * we need to disable share and save options because the file may no longer be there. + */ + private boolean isTempFile() { + final Cursor cursor = getCursorAtProperPosition(); + final Uri photoUri = Uri.parse(getAdapter().getPhotoUri(cursor)); + return MediaScratchFileProvider.isMediaScratchSpaceUri(photoUri); + } + + @Override + public boolean onCreateOptionsMenu(final Menu menu) { + ((Activity) getActivity()).getMenuInflater().inflate(R.menu.photo_view_menu, menu); + + // Get the ShareActionProvider + mShareItem = menu.findItem(R.id.action_share); + mShareActionProvider = (ShareActionProvider) mShareItem.getActionProvider(); + updateShareActionProvider(); + + mSaveItem = menu.findItem(R.id.action_save); + return true; + } + + @Override + public boolean onPrepareOptionsMenu(final Menu menu) { + return !mIsEmpty; + } + + @Override + public boolean onOptionsItemSelected(final MenuItem item) { + if (item.getItemId() == R.id.action_save) { + if (OsUtil.hasStoragePermission()) { + final PhotoPagerAdapter adapter = getAdapter(); + final Cursor cursor = getCursorAtProperPosition(); + if (cursor == null) { + final Context context = getActivity().getContext(); + final String error = context.getResources().getQuantityString( + R.plurals.attachment_save_error, 1, 1); + Toast.makeText(context, error, Toast.LENGTH_SHORT).show(); + return true; + } + final String photoUri = adapter.getPhotoUri(cursor); + new ConversationFragment.SaveAttachmentTask(((Activity) getActivity()), + Uri.parse(photoUri), adapter.getContentType(cursor)).executeOnThreadPool(); + } else { + ((Activity)getActivity()).requestPermissions( + new String[] { Manifest.permission.WRITE_EXTERNAL_STORAGE }, 0); + } + return true; + } else { + return super.onOptionsItemSelected(item); + } + } + + @Override + public PhotoPagerAdapter createPhotoPagerAdapter(final Context context, + final FragmentManager fm, final Cursor c, final float maxScale) { + return new BuglePhotoPageAdapter(context, fm, c, maxScale, mDisplayThumbsFullScreen); + } +} diff --git a/src/com/android/messaging/ui/photoviewer/BuglePhotoViewFragment.java b/src/com/android/messaging/ui/photoviewer/BuglePhotoViewFragment.java new file mode 100644 index 0000000..698c510 --- /dev/null +++ b/src/com/android/messaging/ui/photoviewer/BuglePhotoViewFragment.java @@ -0,0 +1,89 @@ +/* + * 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.ui.photoviewer; + +import android.content.Intent; +import android.graphics.drawable.Drawable; +import android.support.rastermill.FrameSequenceDrawable; +import android.support.v4.content.Loader; + +import com.android.ex.photo.PhotoViewCallbacks; +import com.android.ex.photo.fragments.PhotoViewFragment; +import com.android.ex.photo.loaders.PhotoBitmapLoaderInterface.BitmapResult; + +public class BuglePhotoViewFragment extends PhotoViewFragment { + + /** Public no-arg constructor for allowing the framework to handle orientation changes */ + public BuglePhotoViewFragment() { + // Do nothing. + } + + public static PhotoViewFragment newInstance(Intent intent, int position, + boolean onlyShowSpinner) { + final PhotoViewFragment f = new BuglePhotoViewFragment(); + initializeArguments(intent, position, onlyShowSpinner, f); + return f; + } + + @Override + public void onLoadFinished(Loader<BitmapResult> loader, BitmapResult result) { + super.onLoadFinished(loader, result); + // Need to check for the first time when we load the photos + if (PhotoViewCallbacks.BITMAP_LOADER_PHOTO == loader.getId() + && result.status == BitmapResult.STATUS_SUCCESS + && mCallback.isFragmentActive(this)) { + startGif(); + } + } + + @Override + public void onResume() { + super.onResume(); + startGif(); + } + + @Override + public void onPause() { + stopGif(); + super.onPause(); + } + + @Override + public void onViewActivated() { + super.onViewActivated(); + startGif(); + } + + @Override + public void resetViews() { + super.resetViews(); + stopGif(); + } + + private void stopGif() { + final Drawable drawable = getDrawable(); + if (drawable != null && drawable instanceof FrameSequenceDrawable) { + ((FrameSequenceDrawable) drawable).stop(); + } + } + + private void startGif() { + final Drawable drawable = getDrawable(); + if (drawable != null && drawable instanceof FrameSequenceDrawable) { + ((FrameSequenceDrawable) drawable).start(); + } + } +} |