Initial checkin of AOSP Messaging app.

b/23110861

Change-Id: I9aa980d7569247d6b2ca78f5dcb4502e1eaadb8a
This commit is contained in:
Mike Dodd
2015-08-12 08:58:28 -07:00
parent 8b3e2b9c1b
commit 461a34b466
1645 changed files with 186271 additions and 0 deletions
@@ -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);
}
}
}
@@ -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();
}
}
}
@@ -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;
}
}
@@ -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);
}
}
File diff suppressed because it is too large Load Diff
@@ -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();
}
}
}
@@ -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;
}
}
}
@@ -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);
}
}
}
@@ -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();
}
}
File diff suppressed because it is too large Load Diff
@@ -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();
}
@@ -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();
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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);
}
}
}
}
@@ -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.
}
}
@@ -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);
}
}
@@ -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;
}
}