summaryrefslogtreecommitdiffstats
path: root/src/com/android/messaging/ui
diff options
context:
space:
mode:
Diffstat (limited to 'src/com/android/messaging/ui')
-rw-r--r--src/com/android/messaging/ui/AsyncImageView.java457
-rw-r--r--src/com/android/messaging/ui/AttachmentPreview.java331
-rw-r--r--src/com/android/messaging/ui/AttachmentPreviewFactory.java299
-rw-r--r--src/com/android/messaging/ui/AudioAttachmentPlayPauseButton.java59
-rw-r--r--src/com/android/messaging/ui/AudioAttachmentView.java329
-rw-r--r--src/com/android/messaging/ui/AudioPlaybackProgressBar.java121
-rw-r--r--src/com/android/messaging/ui/BaseBugleActivity.java51
-rw-r--r--src/com/android/messaging/ui/BaseBugleFragmentActivity.java43
-rw-r--r--src/com/android/messaging/ui/BasePagerViewHolder.java106
-rw-r--r--src/com/android/messaging/ui/BlockedParticipantListItemView.java64
-rw-r--r--src/com/android/messaging/ui/BlockedParticipantsActivity.java57
-rw-r--r--src/com/android/messaging/ui/BlockedParticipantsFragment.java102
-rw-r--r--src/com/android/messaging/ui/BugleActionBarActivity.java356
-rw-r--r--src/com/android/messaging/ui/BugleAnimationTags.java38
-rw-r--r--src/com/android/messaging/ui/ClassZeroActivity.java205
-rw-r--r--src/com/android/messaging/ui/CompositeAdapter.java288
-rw-r--r--src/com/android/messaging/ui/ContactIconView.java152
-rw-r--r--src/com/android/messaging/ui/ConversationDrawables.java177
-rw-r--r--src/com/android/messaging/ui/CursorRecyclerAdapter.java333
-rw-r--r--src/com/android/messaging/ui/CustomHeaderPagerListViewHolder.java136
-rw-r--r--src/com/android/messaging/ui/CustomHeaderPagerViewHolder.java26
-rw-r--r--src/com/android/messaging/ui/CustomHeaderViewPager.java91
-rw-r--r--src/com/android/messaging/ui/CustomHeaderViewPagerAdapter.java33
-rw-r--r--src/com/android/messaging/ui/FixedViewPagerAdapter.java132
-rw-r--r--src/com/android/messaging/ui/ImeDetectFrameLayout.java46
-rw-r--r--src/com/android/messaging/ui/LicenseActivity.java35
-rw-r--r--src/com/android/messaging/ui/LineWrapLayout.java232
-rw-r--r--src/com/android/messaging/ui/ListEmptyView.java72
-rw-r--r--src/com/android/messaging/ui/MaxHeightScrollView.java50
-rw-r--r--src/com/android/messaging/ui/MultiAttachmentLayout.java424
-rw-r--r--src/com/android/messaging/ui/OrientedBitmapDrawable.java104
-rw-r--r--src/com/android/messaging/ui/PagerViewHolder.java34
-rw-r--r--src/com/android/messaging/ui/PagingAwareViewPager.java95
-rw-r--r--src/com/android/messaging/ui/PermissionCheckActivity.java141
-rw-r--r--src/com/android/messaging/ui/PersistentInstanceState.java39
-rw-r--r--src/com/android/messaging/ui/PersonItemView.java242
-rw-r--r--src/com/android/messaging/ui/PlaceholderInsetDrawable.java72
-rw-r--r--src/com/android/messaging/ui/PlainTextEditText.java85
-rw-r--r--src/com/android/messaging/ui/PlaybackStateView.java43
-rw-r--r--src/com/android/messaging/ui/RemoteInputEntrypointActivity.java58
-rw-r--r--src/com/android/messaging/ui/SmsStorageLowWarningActivity.java36
-rw-r--r--src/com/android/messaging/ui/SmsStorageLowWarningFragment.java267
-rw-r--r--src/com/android/messaging/ui/SnackBar.java314
-rw-r--r--src/com/android/messaging/ui/SnackBarInteraction.java67
-rw-r--r--src/com/android/messaging/ui/SnackBarManager.java365
-rw-r--r--src/com/android/messaging/ui/TestActivity.java88
-rw-r--r--src/com/android/messaging/ui/UIIntents.java378
-rw-r--r--src/com/android/messaging/ui/UIIntentsImpl.java577
-rw-r--r--src/com/android/messaging/ui/VCardDetailActivity.java60
-rw-r--r--src/com/android/messaging/ui/VCardDetailAdapter.java120
-rw-r--r--src/com/android/messaging/ui/VCardDetailFragment.java197
-rw-r--r--src/com/android/messaging/ui/VideoThumbnailView.java343
-rw-r--r--src/com/android/messaging/ui/ViewPagerTabStrip.java102
-rw-r--r--src/com/android/messaging/ui/ViewPagerTabs.java236
-rw-r--r--src/com/android/messaging/ui/WidgetPickConversationActivity.java114
-rw-r--r--src/com/android/messaging/ui/animation/PopupTransitionAnimation.java302
-rw-r--r--src/com/android/messaging/ui/animation/RectEvaluatorCompat.java45
-rw-r--r--src/com/android/messaging/ui/animation/ViewGroupItemVerticalExplodeAnimation.java208
-rw-r--r--src/com/android/messaging/ui/appsettings/ApnEditorActivity.java463
-rw-r--r--src/com/android/messaging/ui/appsettings/ApnPreference.java151
-rw-r--r--src/com/android/messaging/ui/appsettings/ApnSettingsActivity.java406
-rw-r--r--src/com/android/messaging/ui/appsettings/ApplicationSettingsActivity.java262
-rw-r--r--src/com/android/messaging/ui/appsettings/GroupMmsSettingDialog.java92
-rw-r--r--src/com/android/messaging/ui/appsettings/PerSubscriptionSettingsActivity.java246
-rw-r--r--src/com/android/messaging/ui/appsettings/PhoneNumberPreference.java116
-rw-r--r--src/com/android/messaging/ui/appsettings/SettingsActivity.java178
-rw-r--r--src/com/android/messaging/ui/attachmentchooser/AttachmentChooserActivity.java56
-rw-r--r--src/com/android/messaging/ui/attachmentchooser/AttachmentChooserFragment.java183
-rw-r--r--src/com/android/messaging/ui/attachmentchooser/AttachmentGridItemView.java119
-rw-r--r--src/com/android/messaging/ui/attachmentchooser/AttachmentGridView.java172
-rw-r--r--src/com/android/messaging/ui/contact/AddContactsConfirmationDialog.java85
-rw-r--r--src/com/android/messaging/ui/contact/AllContactsListViewHolder.java62
-rw-r--r--src/com/android/messaging/ui/contact/ContactDropdownLayouter.java138
-rw-r--r--src/com/android/messaging/ui/contact/ContactListAdapter.java86
-rw-r--r--src/com/android/messaging/ui/contact/ContactListItemView.java177
-rw-r--r--src/com/android/messaging/ui/contact/ContactPickerFragment.java607
-rw-r--r--src/com/android/messaging/ui/contact/ContactRecipientAdapter.java286
-rw-r--r--src/com/android/messaging/ui/contact/ContactRecipientAutoCompleteView.java289
-rw-r--r--src/com/android/messaging/ui/contact/ContactRecipientPhotoManager.java96
-rw-r--r--src/com/android/messaging/ui/contact/ContactSectionIndexer.java169
-rw-r--r--src/com/android/messaging/ui/contact/FrequentContactsListViewHolder.java63
-rw-r--r--src/com/android/messaging/ui/conversation/ComposeMessageView.java962
-rw-r--r--src/com/android/messaging/ui/conversation/ConversationActivity.java379
-rw-r--r--src/com/android/messaging/ui/conversation/ConversationActivityUiState.java306
-rw-r--r--src/com/android/messaging/ui/conversation/ConversationFastScroller.java489
-rw-r--r--src/com/android/messaging/ui/conversation/ConversationFragment.java1662
-rw-r--r--src/com/android/messaging/ui/conversation/ConversationInput.java103
-rw-r--r--src/com/android/messaging/ui/conversation/ConversationInputManager.java550
-rw-r--r--src/com/android/messaging/ui/conversation/ConversationMessageAdapter.java117
-rw-r--r--src/com/android/messaging/ui/conversation/ConversationMessageBubbleView.java132
-rw-r--r--src/com/android/messaging/ui/conversation/ConversationMessageView.java1206
-rw-r--r--src/com/android/messaging/ui/conversation/ConversationSimSelector.java128
-rw-r--r--src/com/android/messaging/ui/conversation/EnterSelfPhoneNumberDialog.java92
-rw-r--r--src/com/android/messaging/ui/conversation/LaunchConversationActivity.java134
-rw-r--r--src/com/android/messaging/ui/conversation/MessageBubbleBackground.java47
-rw-r--r--src/com/android/messaging/ui/conversation/MessageDetailsDialog.java381
-rw-r--r--src/com/android/messaging/ui/conversation/SimIconView.java51
-rw-r--r--src/com/android/messaging/ui/conversation/SimSelectorItemView.java90
-rw-r--r--src/com/android/messaging/ui/conversation/SimSelectorView.java169
-rw-r--r--src/com/android/messaging/ui/conversationlist/AbstractConversationListActivity.java339
-rw-r--r--src/com/android/messaging/ui/conversationlist/ArchivedConversationListActivity.java96
-rw-r--r--src/com/android/messaging/ui/conversationlist/ConversationListActivity.java144
-rw-r--r--src/com/android/messaging/ui/conversationlist/ConversationListAdapter.java77
-rw-r--r--src/com/android/messaging/ui/conversationlist/ConversationListFragment.java446
-rw-r--r--src/com/android/messaging/ui/conversationlist/ConversationListItemView.java643
-rw-r--r--src/com/android/messaging/ui/conversationlist/ConversationListSwipeHelper.java462
-rw-r--r--src/com/android/messaging/ui/conversationlist/ForwardMessageActivity.java81
-rw-r--r--src/com/android/messaging/ui/conversationlist/MultiSelectActionModeCallback.java219
-rw-r--r--src/com/android/messaging/ui/conversationlist/ShareIntentActivity.java177
-rw-r--r--src/com/android/messaging/ui/conversationlist/ShareIntentAdapter.java138
-rw-r--r--src/com/android/messaging/ui/conversationlist/ShareIntentFragment.java163
-rw-r--r--src/com/android/messaging/ui/conversationsettings/CopyContactDetailDialog.java66
-rw-r--r--src/com/android/messaging/ui/conversationsettings/PeopleAndOptionsActivity.java66
-rw-r--r--src/com/android/messaging/ui/conversationsettings/PeopleAndOptionsFragment.java329
-rw-r--r--src/com/android/messaging/ui/conversationsettings/PeopleOptionsItemView.java99
-rw-r--r--src/com/android/messaging/ui/debug/DebugMmsConfigActivity.java34
-rw-r--r--src/com/android/messaging/ui/debug/DebugMmsConfigFragment.java147
-rw-r--r--src/com/android/messaging/ui/debug/DebugMmsConfigItemView.java134
-rw-r--r--src/com/android/messaging/ui/debug/DebugSmsMmsFromDumpFileDialogFragment.java171
-rw-r--r--src/com/android/messaging/ui/mediapicker/AudioLevelSource.java73
-rw-r--r--src/com/android/messaging/ui/mediapicker/AudioMediaChooser.java130
-rw-r--r--src/com/android/messaging/ui/mediapicker/AudioRecordView.java351
-rw-r--r--src/com/android/messaging/ui/mediapicker/CameraManager.java1200
-rw-r--r--src/com/android/messaging/ui/mediapicker/CameraMediaChooser.java481
-rw-r--r--src/com/android/messaging/ui/mediapicker/CameraMediaChooserView.java102
-rw-r--r--src/com/android/messaging/ui/mediapicker/CameraPreview.java152
-rw-r--r--src/com/android/messaging/ui/mediapicker/DocumentImagePicker.java128
-rw-r--r--src/com/android/messaging/ui/mediapicker/GalleryGridAdapter.java62
-rw-r--r--src/com/android/messaging/ui/mediapicker/GalleryGridItemView.java159
-rw-r--r--src/com/android/messaging/ui/mediapicker/GalleryGridView.java315
-rw-r--r--src/com/android/messaging/ui/mediapicker/GalleryMediaChooser.java230
-rw-r--r--src/com/android/messaging/ui/mediapicker/HardwareCameraPreview.java118
-rw-r--r--src/com/android/messaging/ui/mediapicker/ImagePersistTask.java172
-rw-r--r--src/com/android/messaging/ui/mediapicker/LevelTrackingMediaRecorder.java223
-rw-r--r--src/com/android/messaging/ui/mediapicker/MediaChooser.java216
-rw-r--r--src/com/android/messaging/ui/mediapicker/MediaPicker.java736
-rw-r--r--src/com/android/messaging/ui/mediapicker/MediaPickerGridView.java44
-rw-r--r--src/com/android/messaging/ui/mediapicker/MediaPickerPanel.java563
-rw-r--r--src/com/android/messaging/ui/mediapicker/MmsVideoRecorder.java127
-rw-r--r--src/com/android/messaging/ui/mediapicker/PausableChronometer.java75
-rw-r--r--src/com/android/messaging/ui/mediapicker/SoftwareCameraPreview.java114
-rw-r--r--src/com/android/messaging/ui/mediapicker/SoundLevels.java212
-rw-r--r--src/com/android/messaging/ui/mediapicker/camerafocus/FocusIndicator.java24
-rw-r--r--src/com/android/messaging/ui/mediapicker/camerafocus/FocusOverlayManager.java589
-rw-r--r--src/com/android/messaging/ui/mediapicker/camerafocus/OverlayRenderer.java95
-rw-r--r--src/com/android/messaging/ui/mediapicker/camerafocus/PieItem.java202
-rw-r--r--src/com/android/messaging/ui/mediapicker/camerafocus/PieRenderer.java825
-rw-r--r--src/com/android/messaging/ui/mediapicker/camerafocus/README.txt3
-rw-r--r--src/com/android/messaging/ui/mediapicker/camerafocus/RenderOverlay.java178
-rw-r--r--src/com/android/messaging/ui/photoviewer/BuglePhotoBitmapLoader.java192
-rw-r--r--src/com/android/messaging/ui/photoviewer/BuglePhotoPageAdapter.java38
-rw-r--r--src/com/android/messaging/ui/photoviewer/BuglePhotoViewActivity.java31
-rw-r--r--src/com/android/messaging/ui/photoviewer/BuglePhotoViewController.java179
-rw-r--r--src/com/android/messaging/ui/photoviewer/BuglePhotoViewFragment.java89
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();
+ }
+ }
+}