Initial checkin of AOSP Messaging app.
b/23110861 Change-Id: I9aa980d7569247d6b2ca78f5dcb4502e1eaadb8a
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user