diff options
Diffstat (limited to 'src/com/android/messaging/ui/conversationlist')
12 files changed, 2985 insertions, 0 deletions
diff --git a/src/com/android/messaging/ui/conversationlist/AbstractConversationListActivity.java b/src/com/android/messaging/ui/conversationlist/AbstractConversationListActivity.java new file mode 100644 index 0000000..dbbbb15 --- /dev/null +++ b/src/com/android/messaging/ui/conversationlist/AbstractConversationListActivity.java @@ -0,0 +1,339 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui.conversationlist; + +import android.app.Activity; +import android.app.AlertDialog; +import android.app.Fragment; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.content.res.Resources; +import android.net.Uri; +import android.os.Bundle; +import android.support.annotation.NonNull; +import android.view.View; + +import com.android.messaging.R; +import com.android.messaging.datamodel.action.DeleteConversationAction; +import com.android.messaging.datamodel.action.UpdateConversationArchiveStatusAction; +import com.android.messaging.datamodel.action.UpdateConversationOptionsAction; +import com.android.messaging.datamodel.action.UpdateDestinationBlockedAction; +import com.android.messaging.datamodel.data.ConversationListData; +import com.android.messaging.datamodel.data.ConversationListItemData; +import com.android.messaging.ui.BugleActionBarActivity; +import com.android.messaging.ui.SnackBar; +import com.android.messaging.ui.SnackBarInteraction; +import com.android.messaging.ui.UIIntents; +import com.android.messaging.ui.contact.AddContactsConfirmationDialog; +import com.android.messaging.ui.conversationlist.ConversationListFragment.ConversationListFragmentHost; +import com.android.messaging.ui.conversationlist.MultiSelectActionModeCallback.SelectedConversation; +import com.android.messaging.util.BugleGservices; +import com.android.messaging.util.BugleGservicesKeys; +import com.android.messaging.util.DebugUtils; +import com.android.messaging.util.PhoneUtils; +import com.android.messaging.util.Trace; +import com.android.messaging.util.UiUtils; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +import javax.annotation.Nullable; + +/** + * Base class for many Conversation List activities. This will handle the common actions of multi + * select and common launching of intents. + */ +public abstract class AbstractConversationListActivity extends BugleActionBarActivity + implements ConversationListFragmentHost, MultiSelectActionModeCallback.Listener { + + private static final int REQUEST_SET_DEFAULT_SMS_APP = 1; + + protected ConversationListFragment mConversationListFragment; + + @Override + public void onAttachFragment(final Fragment fragment) { + Trace.beginSection("AbstractConversationListActivity.onAttachFragment"); + // Fragment could be debug dialog + if (fragment instanceof ConversationListFragment) { + mConversationListFragment = (ConversationListFragment) fragment; + mConversationListFragment.setHost(this); + } + Trace.endSection(); + } + + @Override + public void onBackPressed() { + // If action mode is active dismiss it + if (getActionMode() != null) { + dismissActionMode(); + return; + } + super.onBackPressed(); + } + + protected void startMultiSelectActionMode() { + startActionMode(new MultiSelectActionModeCallback(this)); + } + + protected void exitMultiSelectState() { + mConversationListFragment.showFab(); + dismissActionMode(); + mConversationListFragment.updateUi(); + } + + protected boolean isInConversationListSelectMode() { + return getActionModeCallback() instanceof MultiSelectActionModeCallback; + } + + @Override + public boolean isSelectionMode() { + return isInConversationListSelectMode(); + } + + @Override + public void onActivityResult(final int requestCode, final int resultCode, final Intent data) { + } + + @Override + public void onActionBarDelete(final Collection<SelectedConversation> conversations) { + if (!PhoneUtils.getDefault().isDefaultSmsApp()) { + // TODO: figure out a good way to combine this with the implementation in + // ConversationFragment doing similar things + final Activity activity = this; + UiUtils.showSnackBarWithCustomAction(this, + getWindow().getDecorView().getRootView(), + getString(R.string.requires_default_sms_app), + SnackBar.Action.createCustomAction(new Runnable() { + @Override + public void run() { + final Intent intent = + UIIntents.get().getChangeDefaultSmsAppIntent(activity); + startActivityForResult(intent, REQUEST_SET_DEFAULT_SMS_APP); + } + }, + getString(R.string.requires_default_sms_change_button)), + null /* interactions */, + null /* placement */); + return; + } + + new AlertDialog.Builder(this) + .setTitle(getResources().getQuantityString( + R.plurals.delete_conversations_confirmation_dialog_title, + conversations.size())) + .setPositiveButton(R.string.delete_conversation_confirmation_button, + new DialogInterface.OnClickListener() { + @Override + public void onClick(final DialogInterface dialog, + final int button) { + for (final SelectedConversation conversation : conversations) { + DeleteConversationAction.deleteConversation( + conversation.conversationId, + conversation.timestamp); + } + exitMultiSelectState(); + } + }) + .setNegativeButton(R.string.delete_conversation_decline_button, null) + .show(); + } + + @Override + public void onActionBarArchive(final Iterable<SelectedConversation> conversations, + final boolean isToArchive) { + final ArrayList<String> conversationIds = new ArrayList<String>(); + for (final SelectedConversation conversation : conversations) { + final String conversationId = conversation.conversationId; + conversationIds.add(conversationId); + if (isToArchive) { + UpdateConversationArchiveStatusAction.archiveConversation(conversationId); + } else { + UpdateConversationArchiveStatusAction.unarchiveConversation(conversationId); + } + } + + final Runnable undoRunnable = new Runnable() { + @Override + public void run() { + for (final String conversationId : conversationIds) { + if (isToArchive) { + UpdateConversationArchiveStatusAction.unarchiveConversation(conversationId); + } else { + UpdateConversationArchiveStatusAction.archiveConversation(conversationId); + } + } + } + }; + + final int textId = + isToArchive ? R.string.archived_toast_message : R.string.unarchived_toast_message; + final String message = getResources().getString(textId, conversationIds.size()); + UiUtils.showSnackBar(this, findViewById(android.R.id.list), message, undoRunnable, + SnackBar.Action.SNACK_BAR_UNDO, + mConversationListFragment.getSnackBarInteractions()); + exitMultiSelectState(); + } + + @Override + public void onActionBarNotification(final Iterable<SelectedConversation> conversations, + final boolean isNotificationOn) { + for (final SelectedConversation conversation : conversations) { + UpdateConversationOptionsAction.enableConversationNotifications( + conversation.conversationId, isNotificationOn); + } + + final int textId = isNotificationOn ? + R.string.notification_on_toast_message : R.string.notification_off_toast_message; + final String message = getResources().getString(textId, 1); + UiUtils.showSnackBar(this, findViewById(android.R.id.list), message, + null /* undoRunnable */, + SnackBar.Action.SNACK_BAR_UNDO, mConversationListFragment.getSnackBarInteractions()); + exitMultiSelectState(); + } + + @Override + public void onActionBarAddContact(final SelectedConversation conversation) { + final Uri avatarUri; + if (conversation.icon != null) { + avatarUri = Uri.parse(conversation.icon); + } else { + avatarUri = null; + } + final AddContactsConfirmationDialog dialog = new AddContactsConfirmationDialog( + this, avatarUri, conversation.otherParticipantNormalizedDestination); + dialog.show(); + exitMultiSelectState(); + } + + @Override + public void onActionBarBlock(final SelectedConversation conversation) { + final Resources res = getResources(); + new AlertDialog.Builder(this) + .setTitle(res.getString(R.string.block_confirmation_title, + conversation.otherParticipantNormalizedDestination)) + .setMessage(res.getString(R.string.block_confirmation_message)) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() { + @Override + public void onClick(final DialogInterface arg0, final int arg1) { + final Context context = AbstractConversationListActivity.this; + final View listView = findViewById(android.R.id.list); + final List<SnackBarInteraction> interactions = + mConversationListFragment.getSnackBarInteractions(); + final UpdateDestinationBlockedAction.UpdateDestinationBlockedActionListener + undoListener = + new UpdateDestinationBlockedActionSnackBar( + context, listView, null /* undoRunnable */, + interactions); + final Runnable undoRunnable = new Runnable() { + @Override + public void run() { + UpdateDestinationBlockedAction.updateDestinationBlocked( + conversation.otherParticipantNormalizedDestination, false, + conversation.conversationId, + undoListener); + } + }; + final UpdateDestinationBlockedAction.UpdateDestinationBlockedActionListener + listener = new UpdateDestinationBlockedActionSnackBar( + context, listView, undoRunnable, interactions); + UpdateDestinationBlockedAction.updateDestinationBlocked( + conversation.otherParticipantNormalizedDestination, true, + conversation.conversationId, + listener); + exitMultiSelectState(); + } + }) + .create() + .show(); + } + + @Override + public void onConversationClick(final ConversationListData listData, + final ConversationListItemData conversationListItemData, + final boolean isLongClick, + final ConversationListItemView conversationView) { + if (isLongClick && !isInConversationListSelectMode()) { + startMultiSelectActionMode(); + } + + if (isInConversationListSelectMode()) { + final MultiSelectActionModeCallback multiSelectActionMode = + (MultiSelectActionModeCallback) getActionModeCallback(); + multiSelectActionMode.toggleSelect(listData, conversationListItemData); + mConversationListFragment.updateUi(); + } else { + final String conversationId = conversationListItemData.getConversationId(); + Bundle sceneTransitionAnimationOptions = null; + boolean hasCustomTransitions = false; + + UIIntents.get().launchConversationActivity( + this, conversationId, null, + sceneTransitionAnimationOptions, + hasCustomTransitions); + } + } + + @Override + public void onCreateConversationClick() { + UIIntents.get().launchCreateNewConversationActivity(this, null); + } + + + @Override + public boolean isConversationSelected(final String conversationId) { + return isInConversationListSelectMode() && + ((MultiSelectActionModeCallback) getActionModeCallback()).isSelected( + conversationId); + } + + public void onActionBarDebug() { + DebugUtils.showDebugOptions(this); + } + + private static class UpdateDestinationBlockedActionSnackBar + implements UpdateDestinationBlockedAction.UpdateDestinationBlockedActionListener { + private final Context mContext; + private final View mParentView; + private final Runnable mUndoRunnable; + private final List<SnackBarInteraction> mInteractions; + + UpdateDestinationBlockedActionSnackBar(final Context context, + @NonNull final View parentView, @Nullable final Runnable undoRunnable, + @Nullable List<SnackBarInteraction> interactions) { + mContext = context; + mParentView = parentView; + mUndoRunnable = undoRunnable; + mInteractions = interactions; + } + + @Override + public void onUpdateDestinationBlockedAction( + final UpdateDestinationBlockedAction action, + final boolean success, final boolean block, + final String destination) { + if (success) { + final int messageId = block ? R.string.blocked_toast_message + : R.string.unblocked_toast_message; + final String message = mContext.getResources().getString(messageId, 1); + UiUtils.showSnackBar(mContext, mParentView, message, mUndoRunnable, + SnackBar.Action.SNACK_BAR_UNDO, mInteractions); + } + } + } +} diff --git a/src/com/android/messaging/ui/conversationlist/ArchivedConversationListActivity.java b/src/com/android/messaging/ui/conversationlist/ArchivedConversationListActivity.java new file mode 100644 index 0000000..366c7d3 --- /dev/null +++ b/src/com/android/messaging/ui/conversationlist/ArchivedConversationListActivity.java @@ -0,0 +1,96 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui.conversationlist; + +import android.graphics.drawable.ColorDrawable; +import android.os.Bundle; +import android.support.v7.app.ActionBar; +import android.view.Menu; +import android.view.MenuItem; + +import com.android.messaging.R; +import com.android.messaging.util.DebugUtils; + +public class ArchivedConversationListActivity extends AbstractConversationListActivity { + + @Override + protected void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + final ConversationListFragment fragment = + ConversationListFragment.createArchivedConversationListFragment(); + getFragmentManager().beginTransaction().add(android.R.id.content, fragment).commit(); + invalidateActionBar(); + } + + protected void updateActionBar(ActionBar actionBar) { + actionBar.setTitle(getString(R.string.archived_activity_title)); + actionBar.setDisplayShowTitleEnabled(true); + actionBar.setDisplayHomeAsUpEnabled(true); + actionBar.setBackgroundDrawable(new ColorDrawable( + getResources().getColor( + R.color.archived_conversation_action_bar_background_color_dark))); + actionBar.show(); + super.updateActionBar(actionBar); + } + + @Override + public void onBackPressed() { + if (isInConversationListSelectMode()) { + exitMultiSelectState(); + } else { + super.onBackPressed(); + } + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + if (super.onCreateOptionsMenu(menu)) { + return true; + } + getMenuInflater().inflate(R.menu.archived_conversation_list_menu, menu); + final MenuItem item = menu.findItem(R.id.action_debug_options); + if (item != null) { + final boolean enableDebugItems = DebugUtils.isDebugEnabled(); + item.setVisible(enableDebugItems).setEnabled(enableDebugItems); + } + return true; + } + + @Override + public boolean onOptionsItemSelected(MenuItem menuItem) { + switch(menuItem.getItemId()) { + case R.id.action_debug_options: + onActionBarDebug(); + return true; + case android.R.id.home: + onActionBarHome(); + return true; + default: + return super.onOptionsItemSelected(menuItem); + } + } + + @Override + public void onActionBarHome() { + onBackPressed(); + } + + @Override + public boolean isSwipeAnimatable() { + return false; + } +} diff --git a/src/com/android/messaging/ui/conversationlist/ConversationListActivity.java b/src/com/android/messaging/ui/conversationlist/ConversationListActivity.java new file mode 100644 index 0000000..f8abe81 --- /dev/null +++ b/src/com/android/messaging/ui/conversationlist/ConversationListActivity.java @@ -0,0 +1,144 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.ui.conversationlist; + +import android.graphics.drawable.ColorDrawable; +import android.os.Bundle; +import android.support.v7.app.ActionBar; +import android.view.Menu; +import android.view.MenuItem; + +import com.android.messaging.R; +import com.android.messaging.ui.UIIntents; +import com.android.messaging.util.DebugUtils; +import com.android.messaging.util.Trace; + +public class ConversationListActivity extends AbstractConversationListActivity { + @Override + protected void onCreate(final Bundle savedInstanceState) { + Trace.beginSection("ConversationListActivity.onCreate"); + super.onCreate(savedInstanceState); + setContentView(R.layout.conversation_list_activity); + Trace.endSection(); + invalidateActionBar(); + } + + @Override + protected void updateActionBar(final ActionBar actionBar) { + actionBar.setTitle(getString(R.string.app_name)); + actionBar.setDisplayShowTitleEnabled(true); + actionBar.setDisplayHomeAsUpEnabled(false); + actionBar.setNavigationMode(ActionBar.NAVIGATION_MODE_STANDARD); + actionBar.setBackgroundDrawable(new ColorDrawable( + getResources().getColor(R.color.action_bar_background_color))); + actionBar.show(); + super.updateActionBar(actionBar); + } + + @Override + public void onResume() { + super.onResume(); + // Invalidate the menu as items that are based on settings may have changed + // while not in the app (e.g. Talkback enabled/disable affects new conversation + // button) + supportInvalidateOptionsMenu(); + } + + @Override + public void onBackPressed() { + if (isInConversationListSelectMode()) { + exitMultiSelectState(); + } else { + super.onBackPressed(); + } + } + + @Override + public boolean onCreateOptionsMenu(final Menu menu) { + if (super.onCreateOptionsMenu(menu)) { + return true; + } + getMenuInflater().inflate(R.menu.conversation_list_fragment_menu, menu); + final MenuItem item = menu.findItem(R.id.action_debug_options); + if (item != null) { + final boolean enableDebugItems = DebugUtils.isDebugEnabled(); + item.setVisible(enableDebugItems).setEnabled(enableDebugItems); + } + return true; + } + + @Override + public boolean onOptionsItemSelected(final MenuItem menuItem) { + switch(menuItem.getItemId()) { + case R.id.action_start_new_conversation: + onActionBarStartNewConversation(); + return true; + case R.id.action_settings: + onActionBarSettings(); + return true; + case R.id.action_debug_options: + onActionBarDebug(); + return true; + case R.id.action_show_archived: + onActionBarArchived(); + return true; + case R.id.action_show_blocked_contacts: + onActionBarBlockedParticipants(); + return true; + } + return super.onOptionsItemSelected(menuItem); + } + + @Override + public void onActionBarHome() { + exitMultiSelectState(); + } + + public void onActionBarStartNewConversation() { + UIIntents.get().launchCreateNewConversationActivity(this, null); + } + + public void onActionBarSettings() { + UIIntents.get().launchSettingsActivity(this); + } + + public void onActionBarBlockedParticipants() { + UIIntents.get().launchBlockedParticipantsActivity(this); + } + + public void onActionBarArchived() { + UIIntents.get().launchArchivedConversationsActivity(this); + } + + @Override + public boolean isSwipeAnimatable() { + return !isInConversationListSelectMode(); + } + + @Override + public void onWindowFocusChanged(final boolean hasFocus) { + super.onWindowFocusChanged(hasFocus); + final ConversationListFragment conversationListFragment = + (ConversationListFragment) getFragmentManager().findFragmentById( + R.id.conversation_list_fragment); + // When the screen is turned on, the last used activity gets resumed, but it gets + // window focus only after the lock screen is unlocked. + if (hasFocus && conversationListFragment != null) { + conversationListFragment.setScrolledToNewestConversationIfNeeded(); + } + } +} diff --git a/src/com/android/messaging/ui/conversationlist/ConversationListAdapter.java b/src/com/android/messaging/ui/conversationlist/ConversationListAdapter.java new file mode 100644 index 0000000..629c4ae --- /dev/null +++ b/src/com/android/messaging/ui/conversationlist/ConversationListAdapter.java @@ -0,0 +1,77 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.ui.conversationlist; + +import android.content.Context; +import android.database.Cursor; +import android.support.v7.widget.RecyclerView; +import android.view.LayoutInflater; +import android.view.ViewGroup; + +import com.android.messaging.R; +import com.android.messaging.ui.CursorRecyclerAdapter; +import com.android.messaging.ui.conversationlist.ConversationListItemView.HostInterface; + +/** + * Provides an interface to expose Conversation List Cursor data to a UI widget like a ListView. + */ +public class ConversationListAdapter + extends CursorRecyclerAdapter<ConversationListAdapter.ConversationListViewHolder> { + + private final ConversationListItemView.HostInterface mClivHostInterface; + + public ConversationListAdapter(final Context context, final Cursor cursor, + final ConversationListItemView.HostInterface clivHostInterface) { + super(context, cursor, 0); + mClivHostInterface = clivHostInterface; + setHasStableIds(true); + } + + /** + * @see com.android.messaging.ui.CursorRecyclerAdapter#bindViewHolder( + * android.support.v7.widget.RecyclerView.ViewHolder, android.content.Context, + * android.database.Cursor) + */ + @Override + public void bindViewHolder(final ConversationListViewHolder holder, final Context context, + final Cursor cursor) { + final ConversationListItemView conversationListItemView = holder.mView; + conversationListItemView.bind(cursor, mClivHostInterface); + } + + @Override + public ConversationListViewHolder createViewHolder(final Context context, + final ViewGroup parent, final int viewType) { + final LayoutInflater layoutInflater = LayoutInflater.from(context); + final ConversationListItemView itemView = + (ConversationListItemView) layoutInflater.inflate( + R.layout.conversation_list_item_view, null); + return new ConversationListViewHolder(itemView); + } + + /** + * ViewHolder that holds a ConversationListItemView. + */ + public static class ConversationListViewHolder extends RecyclerView.ViewHolder { + final ConversationListItemView mView; + + public ConversationListViewHolder(final ConversationListItemView itemView) { + super(itemView); + mView = itemView; + } + } +} diff --git a/src/com/android/messaging/ui/conversationlist/ConversationListFragment.java b/src/com/android/messaging/ui/conversationlist/ConversationListFragment.java new file mode 100644 index 0000000..2f868d4 --- /dev/null +++ b/src/com/android/messaging/ui/conversationlist/ConversationListFragment.java @@ -0,0 +1,446 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui.conversationlist; + +import android.app.Activity; +import android.app.Fragment; +import android.content.Context; +import android.database.Cursor; +import android.graphics.Rect; +import android.net.Uri; +import android.os.Bundle; +import android.os.Parcelable; +import android.support.v4.view.ViewCompat; +import android.support.v4.view.ViewGroupCompat; +import android.support.v7.widget.LinearLayoutManager; +import android.support.v7.widget.RecyclerView; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.View.OnClickListener; +import android.view.ViewGroup; +import android.view.ViewGroup.MarginLayoutParams; +import android.view.ViewPropertyAnimator; +import android.view.accessibility.AccessibilityManager; +import android.widget.AbsListView; +import android.widget.ImageView; + +import com.android.messaging.R; +import com.android.messaging.annotation.VisibleForAnimation; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.binding.Binding; +import com.android.messaging.datamodel.binding.BindingBase; +import com.android.messaging.datamodel.data.ConversationListData; +import com.android.messaging.datamodel.data.ConversationListData.ConversationListDataListener; +import com.android.messaging.datamodel.data.ConversationListItemData; +import com.android.messaging.ui.BugleAnimationTags; +import com.android.messaging.ui.ListEmptyView; +import com.android.messaging.ui.SnackBarInteraction; +import com.android.messaging.ui.UIIntents; +import com.android.messaging.util.AccessibilityUtil; +import com.android.messaging.util.Assert; +import com.android.messaging.util.ImeUtil; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.UiUtils; +import com.google.common.annotations.VisibleForTesting; + +import java.util.ArrayList; +import java.util.List; + +/** + * Shows a list of conversations. + */ +public class ConversationListFragment extends Fragment implements ConversationListDataListener, + ConversationListItemView.HostInterface { + private static final String BUNDLE_ARCHIVED_MODE = "archived_mode"; + private static final String BUNDLE_FORWARD_MESSAGE_MODE = "forward_message_mode"; + private static final boolean VERBOSE = false; + + private MenuItem mShowBlockedMenuItem; + private boolean mArchiveMode; + private boolean mBlockedAvailable; + private boolean mForwardMessageMode; + + public interface ConversationListFragmentHost { + public void onConversationClick(final ConversationListData listData, + final ConversationListItemData conversationListItemData, + final boolean isLongClick, + final ConversationListItemView conversationView); + public void onCreateConversationClick(); + public boolean isConversationSelected(final String conversationId); + public boolean isSwipeAnimatable(); + public boolean isSelectionMode(); + public boolean hasWindowFocus(); + } + + private ConversationListFragmentHost mHost; + private RecyclerView mRecyclerView; + private ImageView mStartNewConversationButton; + private ListEmptyView mEmptyListMessageView; + private ConversationListAdapter mAdapter; + + // Saved Instance State Data - only for temporal data which is nice to maintain but not + // critical for correctness. + private static final String SAVED_INSTANCE_STATE_LIST_VIEW_STATE_KEY = + "conversationListViewState"; + private Parcelable mListState; + + @VisibleForTesting + final Binding<ConversationListData> mListBinding = BindingBase.createBinding(this); + + public static ConversationListFragment createArchivedConversationListFragment() { + return createConversationListFragment(BUNDLE_ARCHIVED_MODE); + } + + public static ConversationListFragment createForwardMessageConversationListFragment() { + return createConversationListFragment(BUNDLE_FORWARD_MESSAGE_MODE); + } + + public static ConversationListFragment createConversationListFragment(String modeKeyName) { + final ConversationListFragment fragment = new ConversationListFragment(); + final Bundle bundle = new Bundle(); + bundle.putBoolean(modeKeyName, true); + fragment.setArguments(bundle); + return fragment; + } + + /** + * {@inheritDoc} from Fragment + */ + @Override + public void onCreate(final Bundle bundle) { + super.onCreate(bundle); + mListBinding.getData().init(getLoaderManager(), mListBinding); + mAdapter = new ConversationListAdapter(getActivity(), null, this); + } + + @Override + public void onResume() { + super.onResume(); + + Assert.notNull(mHost); + setScrolledToNewestConversationIfNeeded(); + + updateUi(); + } + + public void setScrolledToNewestConversationIfNeeded() { + if (!mArchiveMode + && !mForwardMessageMode + && isScrolledToFirstConversation() + && mHost.hasWindowFocus()) { + mListBinding.getData().setScrolledToNewestConversation(true); + } + } + + private boolean isScrolledToFirstConversation() { + int firstItemPosition = ((LinearLayoutManager) mRecyclerView.getLayoutManager()) + .findFirstCompletelyVisibleItemPosition(); + return firstItemPosition == 0; + } + + /** + * {@inheritDoc} from Fragment + */ + @Override + public void onDestroy() { + super.onDestroy(); + mListBinding.unbind(); + mHost = null; + } + + /** + * {@inheritDoc} from Fragment + */ + @Override + public View onCreateView(final LayoutInflater inflater, final ViewGroup container, + final Bundle savedInstanceState) { + final ViewGroup rootView = (ViewGroup) inflater.inflate(R.layout.conversation_list_fragment, + container, false); + mRecyclerView = (RecyclerView) rootView.findViewById(android.R.id.list); + mEmptyListMessageView = (ListEmptyView) rootView.findViewById(R.id.no_conversations_view); + mEmptyListMessageView.setImageHint(R.drawable.ic_oobe_conv_list); + // The default behavior for default layout param generation by LinearLayoutManager is to + // provide width and height of WRAP_CONTENT, but this is not desirable for + // ConversationListFragment; the view in each row should be a width of MATCH_PARENT so that + // the entire row is tappable. + final Activity activity = getActivity(); + final LinearLayoutManager manager = new LinearLayoutManager(activity) { + @Override + public RecyclerView.LayoutParams generateDefaultLayoutParams() { + return new RecyclerView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT); + } + }; + mRecyclerView.setLayoutManager(manager); + mRecyclerView.setHasFixedSize(true); + mRecyclerView.setAdapter(mAdapter); + mRecyclerView.setOnScrollListener(new RecyclerView.OnScrollListener() { + int mCurrentState = AbsListView.OnScrollListener.SCROLL_STATE_IDLE; + + @Override + public void onScrolled(final RecyclerView recyclerView, final int dx, final int dy) { + if (mCurrentState == AbsListView.OnScrollListener.SCROLL_STATE_TOUCH_SCROLL + || mCurrentState == AbsListView.OnScrollListener.SCROLL_STATE_FLING) { + ImeUtil.get().hideImeKeyboard(getActivity(), mRecyclerView); + } + + if (isScrolledToFirstConversation()) { + setScrolledToNewestConversationIfNeeded(); + } else { + mListBinding.getData().setScrolledToNewestConversation(false); + } + } + + @Override + public void onScrollStateChanged(final RecyclerView recyclerView, final int newState) { + mCurrentState = newState; + } + }); + mRecyclerView.addOnItemTouchListener(new ConversationListSwipeHelper(mRecyclerView)); + + if (savedInstanceState != null) { + mListState = savedInstanceState.getParcelable(SAVED_INSTANCE_STATE_LIST_VIEW_STATE_KEY); + } + + mStartNewConversationButton = (ImageView) rootView.findViewById( + R.id.start_new_conversation_button); + if (mArchiveMode) { + mStartNewConversationButton.setVisibility(View.GONE); + } else { + mStartNewConversationButton.setVisibility(View.VISIBLE); + mStartNewConversationButton.setOnClickListener(new OnClickListener() { + @Override + public void onClick(final View clickView) { + mHost.onCreateConversationClick(); + } + }); + } + ViewCompat.setTransitionName(mStartNewConversationButton, BugleAnimationTags.TAG_FABICON); + + // The root view has a non-null background, which by default is deemed by the framework + // to be a "transition group," where all child views are animated together during an + // activity transition. However, we want each individual items in the recycler view to + // show explode animation themselves, so we explicitly tag the root view to be a non-group. + ViewGroupCompat.setTransitionGroup(rootView, false); + + setHasOptionsMenu(true); + return rootView; + } + + @Override + public void onAttach(final Activity activity) { + super.onAttach(activity); + if (VERBOSE) { + LogUtil.v(LogUtil.BUGLE_TAG, "Attaching List"); + } + final Bundle arguments = getArguments(); + if (arguments != null) { + mArchiveMode = arguments.getBoolean(BUNDLE_ARCHIVED_MODE, false); + mForwardMessageMode = arguments.getBoolean(BUNDLE_FORWARD_MESSAGE_MODE, false); + } + mListBinding.bind(DataModel.get().createConversationListData(activity, this, mArchiveMode)); + } + + + @Override + public void onSaveInstanceState(final Bundle outState) { + super.onSaveInstanceState(outState); + if (mListState != null) { + outState.putParcelable(SAVED_INSTANCE_STATE_LIST_VIEW_STATE_KEY, mListState); + } + } + + @Override + public void onPause() { + super.onPause(); + mListState = mRecyclerView.getLayoutManager().onSaveInstanceState(); + mListBinding.getData().setScrolledToNewestConversation(false); + } + + /** + * Call this immediately after attaching the fragment + */ + public void setHost(final ConversationListFragmentHost host) { + Assert.isNull(mHost); + mHost = host; + } + + @Override + public void onConversationListCursorUpdated(final ConversationListData data, + final Cursor cursor) { + mListBinding.ensureBound(data); + final Cursor oldCursor = mAdapter.swapCursor(cursor); + updateEmptyListUi(cursor == null || cursor.getCount() == 0); + if (mListState != null && cursor != null && oldCursor == null) { + mRecyclerView.getLayoutManager().onRestoreInstanceState(mListState); + } + } + + @Override + public void setBlockedParticipantsAvailable(final boolean blockedAvailable) { + mBlockedAvailable = blockedAvailable; + if (mShowBlockedMenuItem != null) { + mShowBlockedMenuItem.setVisible(blockedAvailable); + } + } + + public void updateUi() { + mAdapter.notifyDataSetChanged(); + } + + @Override + public void onPrepareOptionsMenu(final Menu menu) { + super.onPrepareOptionsMenu(menu); + final MenuItem startNewConversationMenuItem = + menu.findItem(R.id.action_start_new_conversation); + if (startNewConversationMenuItem != null) { + // It is recommended for the Floating Action button functionality to be duplicated as a + // menu + AccessibilityManager accessibilityManager = (AccessibilityManager) + getActivity().getSystemService(Context.ACCESSIBILITY_SERVICE); + startNewConversationMenuItem.setVisible(accessibilityManager + .isTouchExplorationEnabled()); + } + + final MenuItem archive = menu.findItem(R.id.action_show_archived); + if (archive != null) { + archive.setVisible(true); + } + } + + @Override + public void onCreateOptionsMenu(final Menu menu, final MenuInflater inflater) { + if (!isAdded()) { + // Guard against being called before we're added to the activity + return; + } + + mShowBlockedMenuItem = menu.findItem(R.id.action_show_blocked_contacts); + if (mShowBlockedMenuItem != null) { + mShowBlockedMenuItem.setVisible(mBlockedAvailable); + } + } + + /** + * {@inheritDoc} from ConversationListItemView.HostInterface + */ + @Override + public void onConversationClicked(final ConversationListItemData conversationListItemData, + final boolean isLongClick, final ConversationListItemView conversationView) { + final ConversationListData listData = mListBinding.getData(); + mHost.onConversationClick(listData, conversationListItemData, isLongClick, + conversationView); + } + + /** + * {@inheritDoc} from ConversationListItemView.HostInterface + */ + @Override + public boolean isConversationSelected(final String conversationId) { + return mHost.isConversationSelected(conversationId); + } + + @Override + public boolean isSwipeAnimatable() { + return mHost.isSwipeAnimatable(); + } + + // Show and hide empty list UI as needed with appropriate text based on view specifics + private void updateEmptyListUi(final boolean isEmpty) { + if (isEmpty) { + int emptyListText; + if (!mListBinding.getData().getHasFirstSyncCompleted()) { + emptyListText = R.string.conversation_list_first_sync_text; + } else if (mArchiveMode) { + emptyListText = R.string.archived_conversation_list_empty_text; + } else { + emptyListText = R.string.conversation_list_empty_text; + } + mEmptyListMessageView.setTextHint(emptyListText); + mEmptyListMessageView.setVisibility(View.VISIBLE); + mEmptyListMessageView.setIsImageVisible(true); + mEmptyListMessageView.setIsVerticallyCentered(true); + } else { + mEmptyListMessageView.setVisibility(View.GONE); + } + } + + @Override + public List<SnackBarInteraction> getSnackBarInteractions() { + final List<SnackBarInteraction> interactions = new ArrayList<SnackBarInteraction>(1); + final SnackBarInteraction fabInteraction = + new SnackBarInteraction.BasicSnackBarInteraction(mStartNewConversationButton); + interactions.add(fabInteraction); + return interactions; + } + + private ViewPropertyAnimator getNormalizedFabAnimator() { + return mStartNewConversationButton.animate() + .setInterpolator(UiUtils.DEFAULT_INTERPOLATOR) + .setDuration(getActivity().getResources().getInteger( + R.integer.fab_animation_duration_ms)); + } + + public ViewPropertyAnimator dismissFab() { + // To prevent clicking while animating. + mStartNewConversationButton.setEnabled(false); + final MarginLayoutParams lp = + (MarginLayoutParams) mStartNewConversationButton.getLayoutParams(); + final float fabWidthWithLeftRightMargin = mStartNewConversationButton.getWidth() + + lp.leftMargin + lp.rightMargin; + final int direction = AccessibilityUtil.isLayoutRtl(mStartNewConversationButton) ? -1 : 1; + return getNormalizedFabAnimator().translationX(direction * fabWidthWithLeftRightMargin); + } + + public ViewPropertyAnimator showFab() { + return getNormalizedFabAnimator().translationX(0).withEndAction(new Runnable() { + @Override + public void run() { + // Re-enable clicks after the animation. + mStartNewConversationButton.setEnabled(true); + } + }); + } + + public View getHeroElementForTransition() { + return mArchiveMode ? null : mStartNewConversationButton; + } + + @VisibleForAnimation + public RecyclerView getRecyclerView() { + return mRecyclerView; + } + + @Override + public void startFullScreenPhotoViewer( + final Uri initialPhoto, final Rect initialPhotoBounds, final Uri photosUri) { + UIIntents.get().launchFullScreenPhotoViewer( + getActivity(), initialPhoto, initialPhotoBounds, photosUri); + } + + @Override + public void startFullScreenVideoViewer(final Uri videoUri) { + UIIntents.get().launchFullScreenVideoViewer(getActivity(), videoUri); + } + + @Override + public boolean isSelectionMode() { + return mHost != null && mHost.isSelectionMode(); + } +} diff --git a/src/com/android/messaging/ui/conversationlist/ConversationListItemView.java b/src/com/android/messaging/ui/conversationlist/ConversationListItemView.java new file mode 100644 index 0000000..7525182 --- /dev/null +++ b/src/com/android/messaging/ui/conversationlist/ConversationListItemView.java @@ -0,0 +1,643 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui.conversationlist; + +import android.content.Context; +import android.content.res.Resources; +import android.database.Cursor; +import android.graphics.Color; +import android.graphics.Rect; +import android.graphics.Typeface; +import android.net.Uri; +import android.support.v4.text.BidiFormatter; +import android.support.v4.text.TextDirectionHeuristicsCompat; +import android.text.TextPaint; +import android.text.TextUtils; +import android.util.AttributeSet; +import android.view.View; +import android.view.View.OnClickListener; +import android.view.View.OnLayoutChangeListener; +import android.view.View.OnLongClickListener; +import android.view.ViewGroup; +import android.widget.FrameLayout; +import android.widget.ImageView; +import android.widget.TextView; + +import com.android.messaging.Factory; +import com.android.messaging.R; +import com.android.messaging.annotation.VisibleForAnimation; +import com.android.messaging.datamodel.MessagingContentProvider; +import com.android.messaging.datamodel.action.UpdateConversationArchiveStatusAction; +import com.android.messaging.datamodel.data.ConversationListItemData; +import com.android.messaging.datamodel.data.MessageData; +import com.android.messaging.datamodel.media.UriImageRequestDescriptor; +import com.android.messaging.sms.MmsUtils; +import com.android.messaging.ui.AsyncImageView; +import com.android.messaging.ui.AudioAttachmentView; +import com.android.messaging.ui.ContactIconView; +import com.android.messaging.ui.SnackBar; +import com.android.messaging.ui.SnackBarInteraction; +import com.android.messaging.util.Assert; +import com.android.messaging.util.ContentType; +import com.android.messaging.util.ImageUtils; +import com.android.messaging.util.OsUtil; +import com.android.messaging.util.PhoneUtils; +import com.android.messaging.util.Typefaces; +import com.android.messaging.util.UiUtils; +import com.android.messaging.util.UriUtil; + +import java.util.List; + +/** + * The view for a single entry in a conversation list. + */ +public class ConversationListItemView extends FrameLayout implements OnClickListener, + OnLongClickListener, OnLayoutChangeListener { + static final int UNREAD_SNIPPET_LINE_COUNT = 3; + static final int NO_UNREAD_SNIPPET_LINE_COUNT = 1; + private int mListItemReadColor; + private int mListItemUnreadColor; + private Typeface mListItemReadTypeface; + private Typeface mListItemUnreadTypeface; + private static String sPlusOneString; + private static String sPlusNString; + + public interface HostInterface { + boolean isConversationSelected(final String conversationId); + void onConversationClicked(final ConversationListItemData conversationListItemData, + boolean isLongClick, final ConversationListItemView conversationView); + boolean isSwipeAnimatable(); + List<SnackBarInteraction> getSnackBarInteractions(); + void startFullScreenPhotoViewer(final Uri initialPhoto, final Rect initialPhotoBounds, + final Uri photosUri); + void startFullScreenVideoViewer(final Uri videoUri); + boolean isSelectionMode(); + } + + private final OnClickListener fullScreenPreviewClickListener = new OnClickListener() { + @Override + public void onClick(final View v) { + final String previewType = mData.getShowDraft() ? + mData.getDraftPreviewContentType() : mData.getPreviewContentType(); + Assert.isTrue(ContentType.isImageType(previewType) || + ContentType.isVideoType(previewType)); + + final Uri previewUri = mData.getShowDraft() ? + mData.getDraftPreviewUri() : mData.getPreviewUri(); + if (ContentType.isImageType(previewType)) { + final Uri imagesUri = mData.getShowDraft() ? + MessagingContentProvider.buildDraftImagesUri(mData.getConversationId()) : + MessagingContentProvider + .buildConversationImagesUri(mData.getConversationId()); + final Rect previewImageBounds = UiUtils.getMeasuredBoundsOnScreen(v); + mHostInterface.startFullScreenPhotoViewer( + previewUri, previewImageBounds, imagesUri); + } else { + mHostInterface.startFullScreenVideoViewer(previewUri); + } + } + }; + + private final ConversationListItemData mData; + + private int mAnimatingCount; + private ViewGroup mSwipeableContainer; + private ViewGroup mCrossSwipeBackground; + private ViewGroup mSwipeableContent; + private TextView mConversationNameView; + private TextView mSnippetTextView; + private TextView mSubjectTextView; + private TextView mTimestampTextView; + private ContactIconView mContactIconView; + private ImageView mContactCheckmarkView; + private ImageView mNotificationBellView; + private ImageView mFailedStatusIconView; + private ImageView mCrossSwipeArchiveLeftImageView; + private ImageView mCrossSwipeArchiveRightImageView; + private AsyncImageView mImagePreviewView; + private AudioAttachmentView mAudioAttachmentView; + private HostInterface mHostInterface; + + public ConversationListItemView(final Context context, final AttributeSet attrs) { + super(context, attrs); + mData = new ConversationListItemData(); + final Resources res = context.getResources(); + } + + @Override + protected void onFinishInflate() { + mSwipeableContainer = (ViewGroup) findViewById(R.id.swipeableContainer); + mCrossSwipeBackground = (ViewGroup) findViewById(R.id.crossSwipeBackground); + mSwipeableContent = (ViewGroup) findViewById(R.id.swipeableContent); + mConversationNameView = (TextView) findViewById(R.id.conversation_name); + mSnippetTextView = (TextView) findViewById(R.id.conversation_snippet); + mSubjectTextView = (TextView) findViewById(R.id.conversation_subject); + mTimestampTextView = (TextView) findViewById(R.id.conversation_timestamp); + mContactIconView = (ContactIconView) findViewById(R.id.conversation_icon); + mContactCheckmarkView = (ImageView) findViewById(R.id.conversation_checkmark); + mNotificationBellView = (ImageView) findViewById(R.id.conversation_notification_bell); + mFailedStatusIconView = (ImageView) findViewById(R.id.conversation_failed_status_icon); + mCrossSwipeArchiveLeftImageView = (ImageView) findViewById(R.id.crossSwipeArchiveIconLeft); + mCrossSwipeArchiveRightImageView = + (ImageView) findViewById(R.id.crossSwipeArchiveIconRight); + mImagePreviewView = (AsyncImageView) findViewById(R.id.conversation_image_preview); + mAudioAttachmentView = (AudioAttachmentView) findViewById(R.id.audio_attachment_view); + mConversationNameView.addOnLayoutChangeListener(this); + mSnippetTextView.addOnLayoutChangeListener(this); + + final Resources resources = getContext().getResources(); + mListItemReadColor = resources.getColor(R.color.conversation_list_item_read); + mListItemUnreadColor = resources.getColor(R.color.conversation_list_item_unread); + + mListItemReadTypeface = Typefaces.getRobotoNormal(); + mListItemUnreadTypeface = Typefaces.getRobotoBold(); + + if (OsUtil.isAtLeastL()) { + setTransitionGroup(true); + } + } + + @Override + public void onLayoutChange(final View v, final int left, final int top, final int right, + final int bottom, final int oldLeft, final int oldTop, final int oldRight, + final int oldBottom) { + if (v == mConversationNameView) { + setConversationName(); + } else if (v == mSnippetTextView) { + setSnippet(); + } else if (v == mSubjectTextView) { + setSubject(); + } + } + + private void setConversationName() { + if (mData.getIsRead() || mData.getShowDraft()) { + mConversationNameView.setTextColor(mListItemReadColor); + mConversationNameView.setTypeface(mListItemReadTypeface); + } else { + mConversationNameView.setTextColor(mListItemUnreadColor); + mConversationNameView.setTypeface(mListItemUnreadTypeface); + } + + final String conversationName = mData.getName(); + + // For group conversations, ellipsize the group members that do not fit + final CharSequence ellipsizedName = UiUtils.commaEllipsize( + conversationName, + mConversationNameView.getPaint(), + mConversationNameView.getMeasuredWidth(), + getPlusOneString(), + getPlusNString()); + // RTL : To format conversation name if it happens to be phone number. + final BidiFormatter bidiFormatter = BidiFormatter.getInstance(); + final String bidiFormattedName = bidiFormatter.unicodeWrap( + ellipsizedName.toString(), + TextDirectionHeuristicsCompat.LTR); + + mConversationNameView.setText(bidiFormattedName); + } + + private static String getPlusOneString() { + if (sPlusOneString == null) { + sPlusOneString = Factory.get().getApplicationContext().getResources() + .getString(R.string.plus_one); + } + return sPlusOneString; + } + + private static String getPlusNString() { + if (sPlusNString == null) { + sPlusNString = Factory.get().getApplicationContext().getResources() + .getString(R.string.plus_n); + } + return sPlusNString; + } + + private void setSubject() { + final String subjectText = mData.getShowDraft() ? + mData.getDraftSubject() : + MmsUtils.cleanseMmsSubject(getContext().getResources(), mData.getSubject()); + if (!TextUtils.isEmpty(subjectText)) { + final String subjectPrepend = getResources().getString(R.string.subject_label); + mSubjectTextView.setText(TextUtils.concat(subjectPrepend, subjectText)); + mSubjectTextView.setVisibility(VISIBLE); + } else { + mSubjectTextView.setVisibility(GONE); + } + } + + private void setSnippet() { + mSnippetTextView.setText(getSnippetText()); + } + + // Resource Ids of content descriptions prefixes for different message status. + private static final int [][][] sPrimaryContentDescriptions = { + // 1:1 conversation + { + // Incoming message + { + R.string.one_on_one_incoming_failed_message_prefix, + R.string.one_on_one_incoming_successful_message_prefix + }, + // Outgoing message + { + R.string.one_on_one_outgoing_failed_message_prefix, + R.string.one_on_one_outgoing_successful_message_prefix, + R.string.one_on_one_outgoing_draft_message_prefix, + R.string.one_on_one_outgoing_sending_message_prefix, + } + }, + + // Group conversation + { + // Incoming message + { + R.string.group_incoming_failed_message_prefix, + R.string.group_incoming_successful_message_prefix, + }, + // Outgoing message + { + R.string.group_outgoing_failed_message_prefix, + R.string.group_outgoing_successful_message_prefix, + R.string.group_outgoing_draft_message_prefix, + R.string.group_outgoing_sending_message_prefix, + } + } + }; + + // Resource Id of the secondary part of the content description for an edge case of a message + // which is in both draft status and failed status. + private static final int sSecondaryContentDescription = + R.string.failed_message_content_description; + + // 1:1 versus group + private static final int CONV_TYPE_ONE_ON_ONE_INDEX = 0; + private static final int CONV_TYPE_ONE_GROUP_INDEX = 1; + // Direction + private static final int DIRECTION_INCOMING_INDEX = 0; + private static final int DIRECTION_OUTGOING_INDEX = 1; + // Message status + private static final int MESSAGE_STATUS_FAILED_INDEX = 0; + private static final int MESSAGE_STATUS_SUCCESSFUL_INDEX = 1; + private static final int MESSAGE_STATUS_DRAFT_INDEX = 2; + private static final int MESSAGE_STATUS_SENDING_INDEX = 3; + + private static final int WIDTH_FOR_ACCESSIBLE_CONVERSATION_NAME = 600; + + public static String buildContentDescription(final Resources resources, + final ConversationListItemData data, final TextPaint conversationNameViewPaint) { + int messageStatusIndex; + boolean outgoingSnippet = data.getIsMessageTypeOutgoing() || data.getShowDraft(); + if (outgoingSnippet) { + if (data.getShowDraft()) { + messageStatusIndex = MESSAGE_STATUS_DRAFT_INDEX; + } else if (data.getIsSendRequested()) { + messageStatusIndex = MESSAGE_STATUS_SENDING_INDEX; + } else { + messageStatusIndex = data.getIsFailedStatus() ? MESSAGE_STATUS_FAILED_INDEX + : MESSAGE_STATUS_SUCCESSFUL_INDEX; + } + } else { + messageStatusIndex = data.getIsFailedStatus() ? MESSAGE_STATUS_FAILED_INDEX + : MESSAGE_STATUS_SUCCESSFUL_INDEX; + } + + int resId = sPrimaryContentDescriptions + [data.getIsGroup() ? CONV_TYPE_ONE_GROUP_INDEX : CONV_TYPE_ONE_ON_ONE_INDEX] + [outgoingSnippet ? DIRECTION_OUTGOING_INDEX : DIRECTION_INCOMING_INDEX] + [messageStatusIndex]; + + final String snippetText = data.getShowDraft() ? + data.getDraftSnippetText() : data.getSnippetText(); + + final String conversationName = data.getName(); + String senderOrConvName = outgoingSnippet ? conversationName : data.getSnippetSenderName(); + + String primaryContentDescription = resources.getString(resId, senderOrConvName, + snippetText == null ? "" : snippetText, + data.getFormattedTimestamp(), + // This is used only for incoming group messages + conversationName); + String contentDescription = primaryContentDescription; + + // An edge case : for an outgoing message, it might be in both draft status and + // failed status. + if (outgoingSnippet && data.getShowDraft() && data.getIsFailedStatus()) { + StringBuilder contentDescriptionBuilder = new StringBuilder(); + contentDescriptionBuilder.append(primaryContentDescription); + + String secondaryContentDescription = + resources.getString(sSecondaryContentDescription); + contentDescriptionBuilder.append(" "); + contentDescriptionBuilder.append(secondaryContentDescription); + contentDescription = contentDescriptionBuilder.toString(); + } + return contentDescription; + } + + /** + * Fills in the data associated with this view. + * + * @param cursor The cursor from a ConversationList that this view is in, pointing to its + * entry. + */ + public void bind(final Cursor cursor, final HostInterface hostInterface) { + // Update our UI model + mHostInterface = hostInterface; + mData.bind(cursor); + + resetAnimatingState(); + + mSwipeableContainer.setOnClickListener(this); + mSwipeableContainer.setOnLongClickListener(this); + + final Resources resources = getContext().getResources(); + + int color; + final int maxLines; + final Typeface typeface; + final int typefaceStyle = mData.getShowDraft() ? Typeface.ITALIC : Typeface.NORMAL; + final String snippetText = getSnippetText(); + + if (mData.getIsRead() || mData.getShowDraft()) { + maxLines = TextUtils.isEmpty(snippetText) ? 0 : NO_UNREAD_SNIPPET_LINE_COUNT; + color = mListItemReadColor; + typeface = mListItemReadTypeface; + } else { + maxLines = TextUtils.isEmpty(snippetText) ? 0 : UNREAD_SNIPPET_LINE_COUNT; + color = mListItemUnreadColor; + typeface = mListItemUnreadTypeface; + } + + mSnippetTextView.setMaxLines(maxLines); + mSnippetTextView.setTextColor(color); + mSnippetTextView.setTypeface(typeface, typefaceStyle); + mSubjectTextView.setTextColor(color); + mSubjectTextView.setTypeface(typeface, typefaceStyle); + + setSnippet(); + setConversationName(); + setSubject(); + setContentDescription(buildContentDescription(resources, mData, + mConversationNameView.getPaint())); + + final boolean isDefaultSmsApp = PhoneUtils.getDefault().isDefaultSmsApp(); + // don't show the error state unless we're the default sms app + if (mData.getIsFailedStatus() && isDefaultSmsApp) { + mTimestampTextView.setTextColor(resources.getColor(R.color.conversation_list_error)); + mTimestampTextView.setTypeface(mListItemReadTypeface, typefaceStyle); + int failureMessageId = R.string.message_status_download_failed; + if (mData.getIsMessageTypeOutgoing()) { + failureMessageId = MmsUtils.mapRawStatusToErrorResourceId(mData.getMessageStatus(), + mData.getMessageRawTelephonyStatus()); + } + mTimestampTextView.setText(resources.getString(failureMessageId)); + } else if (mData.getShowDraft() + || mData.getMessageStatus() == MessageData.BUGLE_STATUS_OUTGOING_DRAFT + // also check for unknown status which we get because sometimes the conversation + // row is left with a latest_message_id of a no longer existing message and + // therefore the join values come back as null (or in this case zero). + || mData.getMessageStatus() == MessageData.BUGLE_STATUS_UNKNOWN) { + mTimestampTextView.setTextColor(mListItemReadColor); + mTimestampTextView.setTypeface(mListItemReadTypeface, typefaceStyle); + mTimestampTextView.setText(resources.getString( + R.string.conversation_list_item_view_draft_message)); + } else { + mTimestampTextView.setTextColor(mListItemReadColor); + mTimestampTextView.setTypeface(mListItemReadTypeface, typefaceStyle); + final String formattedTimestamp = mData.getFormattedTimestamp(); + if (mData.getIsSendRequested()) { + mTimestampTextView.setText(R.string.message_status_sending); + } else { + mTimestampTextView.setText(formattedTimestamp); + } + } + + final boolean isSelected = mHostInterface.isConversationSelected(mData.getConversationId()); + setSelected(isSelected); + Uri iconUri = null; + int contactIconVisibility = GONE; + int checkmarkVisiblity = GONE; + int failStatusVisiblity = GONE; + if (isSelected) { + checkmarkVisiblity = VISIBLE; + } else { + contactIconVisibility = VISIBLE; + // Only show the fail icon if it is not a group conversation. + // And also require that we be the default sms app. + if (mData.getIsFailedStatus() && !mData.getIsGroup() && isDefaultSmsApp) { + failStatusVisiblity = VISIBLE; + } + } + if (mData.getIcon() != null) { + iconUri = Uri.parse(mData.getIcon()); + } + mContactIconView.setImageResourceUri(iconUri, mData.getParticipantContactId(), + mData.getParticipantLookupKey(), mData.getOtherParticipantNormalizedDestination()); + mContactIconView.setVisibility(contactIconVisibility); + mContactIconView.setOnLongClickListener(this); + mContactIconView.setClickable(!mHostInterface.isSelectionMode()); + mContactIconView.setLongClickable(!mHostInterface.isSelectionMode()); + + mContactCheckmarkView.setVisibility(checkmarkVisiblity); + mFailedStatusIconView.setVisibility(failStatusVisiblity); + + final Uri previewUri = mData.getShowDraft() ? + mData.getDraftPreviewUri() : mData.getPreviewUri(); + final String previewContentType = mData.getShowDraft() ? + mData.getDraftPreviewContentType() : mData.getPreviewContentType(); + OnClickListener previewClickListener = null; + Uri previewImageUri = null; + int previewImageVisibility = GONE; + int audioPreviewVisiblity = GONE; + if (previewUri != null && !TextUtils.isEmpty(previewContentType)) { + if (ContentType.isAudioType(previewContentType)) { + mAudioAttachmentView.bind(previewUri, false); + audioPreviewVisiblity = VISIBLE; + } else if (ContentType.isVideoType(previewContentType)) { + previewImageUri = UriUtil.getUriForResourceId( + getContext(), R.drawable.ic_preview_play); + previewClickListener = fullScreenPreviewClickListener; + previewImageVisibility = VISIBLE; + } else if (ContentType.isImageType(previewContentType)) { + previewImageUri = previewUri; + previewClickListener = fullScreenPreviewClickListener; + previewImageVisibility = VISIBLE; + } + } + + final int imageSize = resources.getDimensionPixelSize( + R.dimen.conversation_list_image_preview_size); + mImagePreviewView.setImageResourceId( + new UriImageRequestDescriptor(previewImageUri, imageSize, imageSize, + true /* allowCompression */, false /* isStatic */, false /*cropToCircle*/, + ImageUtils.DEFAULT_CIRCLE_BACKGROUND_COLOR /* circleBackgroundColor */, + ImageUtils.DEFAULT_CIRCLE_STROKE_COLOR /* circleStrokeColor */)); + mImagePreviewView.setOnLongClickListener(this); + mImagePreviewView.setVisibility(previewImageVisibility); + mImagePreviewView.setOnClickListener(previewClickListener); + mAudioAttachmentView.setOnLongClickListener(this); + mAudioAttachmentView.setVisibility(audioPreviewVisiblity); + + final int notificationBellVisiblity = mData.getNotificationEnabled() ? GONE : VISIBLE; + mNotificationBellView.setVisibility(notificationBellVisiblity); + } + + public boolean isSwipeAnimatable() { + return mHostInterface.isSwipeAnimatable(); + } + + @VisibleForAnimation + public float getSwipeTranslationX() { + return mSwipeableContainer.getTranslationX(); + } + + @VisibleForAnimation + public void setSwipeTranslationX(final float translationX) { + mSwipeableContainer.setTranslationX(translationX); + if (translationX == 0) { + mCrossSwipeBackground.setVisibility(View.GONE); + mCrossSwipeArchiveLeftImageView.setVisibility(GONE); + mCrossSwipeArchiveRightImageView.setVisibility(GONE); + + mSwipeableContainer.setBackgroundColor(Color.TRANSPARENT); + } else { + mCrossSwipeBackground.setVisibility(View.VISIBLE); + if (translationX > 0) { + mCrossSwipeArchiveLeftImageView.setVisibility(VISIBLE); + mCrossSwipeArchiveRightImageView.setVisibility(GONE); + } else { + mCrossSwipeArchiveLeftImageView.setVisibility(GONE); + mCrossSwipeArchiveRightImageView.setVisibility(VISIBLE); + } + mSwipeableContainer.setBackgroundResource(R.drawable.swipe_shadow_drag); + } + } + + public void onSwipeComplete() { + final String conversationId = mData.getConversationId(); + UpdateConversationArchiveStatusAction.archiveConversation(conversationId); + + final Runnable undoRunnable = new Runnable() { + @Override + public void run() { + UpdateConversationArchiveStatusAction.unarchiveConversation(conversationId); + } + }; + final String message = getResources().getString(R.string.archived_toast_message, 1); + UiUtils.showSnackBar(getContext(), getRootView(), message, undoRunnable, + SnackBar.Action.SNACK_BAR_UNDO, + mHostInterface.getSnackBarInteractions()); + } + + private void setShortAndLongClickable(final boolean clickable) { + setClickable(clickable); + setLongClickable(clickable); + } + + private void resetAnimatingState() { + mAnimatingCount = 0; + setShortAndLongClickable(true); + setSwipeTranslationX(0); + } + + /** + * Notifies this view that it is undergoing animation. This view should disable its click + * targets. + * + * The animating counter is used to reset the swipe controller when the counter becomes 0. A + * positive counter also makes the view not clickable. + */ + public final void setAnimating(final boolean animating) { + final int oldAnimatingCount = mAnimatingCount; + if (animating) { + mAnimatingCount++; + } else { + mAnimatingCount--; + if (mAnimatingCount < 0) { + mAnimatingCount = 0; + } + } + + if (mAnimatingCount == 0) { + // New count is 0. All animations ended. + setShortAndLongClickable(true); + } else if (oldAnimatingCount == 0) { + // New count is > 0. Waiting for some animations to end. + setShortAndLongClickable(false); + } + } + + public boolean isAnimating() { + return mAnimatingCount > 0; + } + + /** + * {@inheritDoc} from OnClickListener + */ + @Override + public void onClick(final View v) { + processClick(v, false); + } + + /** + * {@inheritDoc} from OnLongClickListener + */ + @Override + public boolean onLongClick(final View v) { + return processClick(v, true); + } + + private boolean processClick(final View v, final boolean isLongClick) { + Assert.isTrue(v == mSwipeableContainer || v == mContactIconView || v == mImagePreviewView); + Assert.notNull(mData.getName()); + + if (mHostInterface != null) { + mHostInterface.onConversationClicked(mData, isLongClick, this); + return true; + } + return false; + } + + public View getSwipeableContent() { + return mSwipeableContent; + } + + public View getContactIconView() { + return mContactIconView; + } + + private String getSnippetText() { + String snippetText = mData.getShowDraft() ? + mData.getDraftSnippetText() : mData.getSnippetText(); + final String previewContentType = mData.getShowDraft() ? + mData.getDraftPreviewContentType() : mData.getPreviewContentType(); + if (TextUtils.isEmpty(snippetText)) { + Resources resources = getResources(); + // Use the attachment type as a snippet so the preview doesn't look odd + if (ContentType.isAudioType(previewContentType)) { + snippetText = resources.getString(R.string.conversation_list_snippet_audio_clip); + } else if (ContentType.isImageType(previewContentType)) { + snippetText = resources.getString(R.string.conversation_list_snippet_picture); + } else if (ContentType.isVideoType(previewContentType)) { + snippetText = resources.getString(R.string.conversation_list_snippet_video); + } else if (ContentType.isVCardType(previewContentType)) { + snippetText = resources.getString(R.string.conversation_list_snippet_vcard); + } + } + return snippetText; + } +} diff --git a/src/com/android/messaging/ui/conversationlist/ConversationListSwipeHelper.java b/src/com/android/messaging/ui/conversationlist/ConversationListSwipeHelper.java new file mode 100644 index 0000000..4988259 --- /dev/null +++ b/src/com/android/messaging/ui/conversationlist/ConversationListSwipeHelper.java @@ -0,0 +1,462 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui.conversationlist; + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.animation.ObjectAnimator; +import android.animation.TimeInterpolator; +import android.content.Context; +import android.content.res.Resources; +import android.support.v4.view.ViewCompat; +import android.support.v7.widget.RecyclerView; +import android.support.v7.widget.RecyclerView.OnItemTouchListener; +import android.view.MotionEvent; +import android.view.VelocityTracker; +import android.view.View; +import android.view.ViewConfiguration; + +import com.android.messaging.R; +import com.android.messaging.util.Assert; +import com.android.messaging.util.UiUtils; + +/** + * Animation and touch helper class for Conversation List swipe. + */ +public class ConversationListSwipeHelper implements OnItemTouchListener { + private static final int UNIT_SECONDS = 1000; + private static final boolean ANIMATING = true; + + private static final float ERROR_FACTOR_MULTIPLIER = 1.2f; + private static final float PERCENTAGE_OF_WIDTH_TO_DISMISS = 0.4f; + private static final float FLING_PERCENTAGE_OF_WIDTH_TO_DISMISS = 0.05f; + + private static final int SWIPE_DIRECTION_NONE = 0; + private static final int SWIPE_DIRECTION_LEFT = 1; + private static final int SWIPE_DIRECTION_RIGHT = 2; + + private final RecyclerView mRecyclerView; + private final long mDefaultRestoreAnimationDuration; + private final long mDefaultDismissAnimationDuration; + private final long mMaxTranslationAnimationDuration; + private final int mTouchSlop; + private final int mMinimumFlingVelocity; + private final int mMaximumFlingVelocity; + + /* Valid throughout a single gesture. */ + private VelocityTracker mVelocityTracker; + private float mInitialX; + private float mInitialY; + private boolean mIsSwiping; + private ConversationListItemView mListItemView; + + public ConversationListSwipeHelper(final RecyclerView recyclerView) { + mRecyclerView = recyclerView; + + final Context context = mRecyclerView.getContext(); + final Resources res = context.getResources(); + mDefaultRestoreAnimationDuration = res.getInteger(R.integer.swipe_duration_ms); + mDefaultDismissAnimationDuration = res.getInteger(R.integer.swipe_duration_ms); + mMaxTranslationAnimationDuration = res.getInteger(R.integer.swipe_duration_ms); + + final ViewConfiguration viewConfiguration = ViewConfiguration.get(context); + mTouchSlop = viewConfiguration.getScaledPagingTouchSlop(); + mMaximumFlingVelocity = Math.min( + viewConfiguration.getScaledMaximumFlingVelocity(), + res.getInteger(R.integer.swipe_max_fling_velocity_px_per_s)); + mMinimumFlingVelocity = viewConfiguration.getScaledMinimumFlingVelocity(); + } + + @Override + public boolean onInterceptTouchEvent(final RecyclerView recyclerView, final MotionEvent event) { + if (event.getPointerCount() > 1) { + // Ignore subsequent pointers. + return false; + } + + // We are not yet tracking a swipe gesture. Begin detection by spying on + // touch events bubbling down to our children. + final int action = event.getActionMasked(); + switch (action) { + case MotionEvent.ACTION_DOWN: + if (!hasGestureSwipeTarget()) { + onGestureStart(); + + mVelocityTracker.addMovement(event); + mInitialX = event.getX(); + mInitialY = event.getY(); + + final View viewAtPoint = mRecyclerView.findChildViewUnder(mInitialX, mInitialY); + final ConversationListItemView child = (ConversationListItemView) viewAtPoint; + if (viewAtPoint instanceof ConversationListItemView && + child != null && child.isSwipeAnimatable()) { + // Begin detecting swipe on the target for the rest of the gesture. + mListItemView = child; + if (mListItemView.isAnimating()) { + mListItemView = null; + } + } else { + mListItemView = null; + } + } + break; + case MotionEvent.ACTION_MOVE: + if (hasValidGestureSwipeTarget()) { + mVelocityTracker.addMovement(event); + + final int historicalCount = event.getHistorySize(); + // First consume the historical events, then consume the current ones. + for (int i = 0; i < historicalCount + 1; i++) { + float currX; + float currY; + if (i < historicalCount) { + currX = event.getHistoricalX(i); + currY = event.getHistoricalY(i); + } else { + currX = event.getX(); + currY = event.getY(); + } + final float deltaX = currX - mInitialX; + final float deltaY = currY - mInitialY; + final float absDeltaX = Math.abs(deltaX); + final float absDeltaY = Math.abs(deltaY); + + if (!mIsSwiping && absDeltaY > mTouchSlop + && absDeltaY > (ERROR_FACTOR_MULTIPLIER * absDeltaX)) { + // Stop detecting swipe for the remainder of this gesture. + onGestureEnd(); + return false; + } + + if (absDeltaX > mTouchSlop) { + // Swipe detected. Return true so we can handle the gesture in + // onTouchEvent. + mIsSwiping = true; + + // We don't want to suddenly jump the slop distance. + mInitialX = event.getX(); + mInitialY = event.getY(); + + onSwipeGestureStart(mListItemView); + return true; + } + } + } + break; + case MotionEvent.ACTION_UP: + case MotionEvent.ACTION_CANCEL: + if (hasGestureSwipeTarget()) { + onGestureEnd(); + } + break; + } + + // Start intercepting touch events from children if we detect a swipe. + return mIsSwiping; + } + + @Override + public void onTouchEvent(final RecyclerView recyclerView, final MotionEvent event) { + // We should only be here if we intercepted the touch due to swipe. + Assert.isTrue(mIsSwiping); + + // We are now tracking a swipe gesture. + mVelocityTracker.addMovement(event); + + final int action = event.getActionMasked(); + switch (action) { + case MotionEvent.ACTION_OUTSIDE: + case MotionEvent.ACTION_MOVE: + if (hasValidGestureSwipeTarget()) { + mListItemView.setSwipeTranslationX(event.getX() - mInitialX); + } + break; + case MotionEvent.ACTION_UP: + if (hasValidGestureSwipeTarget()) { + final float maxVelocity = mMaximumFlingVelocity; + mVelocityTracker.computeCurrentVelocity(UNIT_SECONDS, maxVelocity); + final float velocityX = getLastComputedXVelocity(); + + final float translationX = mListItemView.getSwipeTranslationX(); + + int swipeDirection = SWIPE_DIRECTION_NONE; + if (translationX != 0) { + swipeDirection = + translationX > 0 ? SWIPE_DIRECTION_RIGHT : SWIPE_DIRECTION_LEFT; + } else if (velocityX != 0) { + swipeDirection = + velocityX > 0 ? SWIPE_DIRECTION_RIGHT : SWIPE_DIRECTION_LEFT; + } + + final boolean fastEnough = isTargetSwipedFastEnough(); + final boolean farEnough = isTargetSwipedFarEnough(); + + final boolean shouldDismiss = (fastEnough || farEnough); + + if (shouldDismiss) { + if (fastEnough) { + animateDismiss(mListItemView, velocityX); + } else { + animateDismiss(mListItemView, swipeDirection); + } + } else { + animateRestore(mListItemView, velocityX); + } + + onSwipeGestureEnd(mListItemView, + shouldDismiss ? swipeDirection : SWIPE_DIRECTION_NONE); + } else { + onGestureEnd(); + } + break; + case MotionEvent.ACTION_CANCEL: + if (hasValidGestureSwipeTarget()) { + animateRestore(mListItemView, 0f); + onSwipeGestureEnd(mListItemView, SWIPE_DIRECTION_NONE); + } else { + onGestureEnd(); + } + break; + } + } + + + @Override + public void onRequestDisallowInterceptTouchEvent(boolean disallowIntercept) { + } + + /** + * We have started to intercept a series of touch events. + */ + private void onGestureStart() { + mIsSwiping = false; + // Work around bug in RecyclerView that sends two identical ACTION_DOWN + // events to #onInterceptTouchEvent. + if (mVelocityTracker == null) { + mVelocityTracker = VelocityTracker.obtain(); + } + mVelocityTracker.clear(); + } + + /** + * The series of touch events has been detected as a swipe. + * + * Now that the gesture is a swipe, we will begin translating the view of the + * given viewHolder. + */ + private void onSwipeGestureStart(final ConversationListItemView itemView) { + mRecyclerView.getParent().requestDisallowInterceptTouchEvent(true); + setHardwareAnimatingLayerType(itemView, ANIMATING); + itemView.setAnimating(true); + } + + /** + * The current swipe gesture is complete. + */ + private void onSwipeGestureEnd(final ConversationListItemView itemView, + final int swipeDirection) { + if (swipeDirection == SWIPE_DIRECTION_RIGHT || swipeDirection == SWIPE_DIRECTION_LEFT) { + itemView.onSwipeComplete(); + } + + // Balances out onSwipeGestureStart. + itemView.setAnimating(false); + + onGestureEnd(); + } + + /** + * The series of touch events has ended in an {@link MotionEvent#ACTION_UP} + * or {@link MotionEvent#ACTION_CANCEL}. + */ + private void onGestureEnd() { + mVelocityTracker.recycle(); + mVelocityTracker = null; + mIsSwiping = false; + mListItemView = null; + } + + /** + * A swipe animation has started. + */ + private void onSwipeAnimationStart(final ConversationListItemView itemView) { + // Disallow interactions. + itemView.setAnimating(true); + ViewCompat.setHasTransientState(itemView, true); + setHardwareAnimatingLayerType(itemView, ANIMATING); + } + + /** + * The swipe animation has ended. + */ + private void onSwipeAnimationEnd(final ConversationListItemView itemView) { + // Restore interactions. + itemView.setAnimating(false); + ViewCompat.setHasTransientState(itemView, false); + setHardwareAnimatingLayerType(itemView, !ANIMATING); + } + + /** + * Animate the dismissal of the given item. The given velocityX is taken into consideration for + * the animation duration. Whether the item is dismissed to the left or right is dependent on + * the given velocityX. + */ + private void animateDismiss(final ConversationListItemView itemView, final float velocityX) { + Assert.isTrue(velocityX != 0); + final int direction = velocityX > 0 ? SWIPE_DIRECTION_RIGHT : SWIPE_DIRECTION_LEFT; + animateDismiss(itemView, direction, velocityX); + } + + /** + * Animate the dismissal of the given item. The velocityX is assumed to be 0. + */ + private void animateDismiss(final ConversationListItemView itemView, final int swipeDirection) { + animateDismiss(itemView, swipeDirection, 0f); + } + + /** + * Animate the dismissal of the given item. + */ + private void animateDismiss(final ConversationListItemView itemView, + final int swipeDirection, final float velocityX) { + Assert.isTrue(swipeDirection != SWIPE_DIRECTION_NONE); + + onSwipeAnimationStart(itemView); + + final float animateTo = (swipeDirection == SWIPE_DIRECTION_RIGHT) ? + mRecyclerView.getWidth() : -mRecyclerView.getWidth(); + final long duration; + if (velocityX != 0) { + final float deltaX = animateTo - itemView.getSwipeTranslationX(); + duration = calculateTranslationDuration(deltaX, velocityX); + } else { + duration = mDefaultDismissAnimationDuration; + } + + final ObjectAnimator animator = getSwipeTranslationXAnimator( + itemView, animateTo, duration, UiUtils.DEFAULT_INTERPOLATOR); + animator.addListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(final Animator animation) { + onSwipeAnimationEnd(itemView); + } + }); + animator.start(); + } + + /** + * Animate the bounce back of the given item. + */ + private void animateRestore(final ConversationListItemView itemView, + final float velocityX) { + onSwipeAnimationStart(itemView); + + final float translationX = itemView.getSwipeTranslationX(); + final long duration; + if (velocityX != 0 // Has velocity. + && velocityX > 0 != translationX > 0) { // Right direction. + duration = calculateTranslationDuration(translationX, velocityX); + } else { + duration = mDefaultRestoreAnimationDuration; + } + final ObjectAnimator animator = getSwipeTranslationXAnimator( + itemView, 0f, duration, UiUtils.DEFAULT_INTERPOLATOR); + animator.addListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(final Animator animation) { + onSwipeAnimationEnd(itemView); + } + }); + animator.start(); + } + + /** + * Create and start an animator that animates the given view's translationX + * from its current value to the value given by animateTo. + */ + private ObjectAnimator getSwipeTranslationXAnimator(final ConversationListItemView itemView, + final float animateTo, final long duration, final TimeInterpolator interpolator) { + final ObjectAnimator animator = + ObjectAnimator.ofFloat(itemView, "swipeTranslationX", animateTo); + animator.setDuration(duration); + animator.setInterpolator(interpolator); + return animator; + } + + /** + * Determine if the swipe has enough velocity to be dismissed. + */ + private boolean isTargetSwipedFastEnough() { + final float velocityX = getLastComputedXVelocity(); + final float velocityY = mVelocityTracker.getYVelocity(); + final float minVelocity = mMinimumFlingVelocity; + final float translationX = mListItemView.getSwipeTranslationX(); + final float width = mListItemView.getWidth(); + return (Math.abs(velocityX) > minVelocity) // Fast enough. + && (Math.abs(velocityX) > Math.abs(velocityY)) // Not unintentional. + && (velocityX > 0) == (translationX > 0) // Right direction. + && Math.abs(translationX) > + FLING_PERCENTAGE_OF_WIDTH_TO_DISMISS * width; // Enough movement. + } + + /** + * Only used during a swipe gesture. Determine if the swipe has enough distance to be + * dismissed. + */ + private boolean isTargetSwipedFarEnough() { + final float velocityX = getLastComputedXVelocity(); + + final float translationX = mListItemView.getSwipeTranslationX(); + final float width = mListItemView.getWidth(); + + return (velocityX >= 0) == (translationX > 0) // Right direction. + && Math.abs(translationX) > + PERCENTAGE_OF_WIDTH_TO_DISMISS * width; // Enough movement. + } + + private long calculateTranslationDuration(final float deltaPosition, final float velocity) { + Assert.isTrue(velocity != 0); + final float durationInSeconds = Math.abs(deltaPosition / velocity); + return Math.min((int) (durationInSeconds * UNIT_SECONDS), mMaxTranslationAnimationDuration); + } + + private boolean hasGestureSwipeTarget() { + return mListItemView != null; + } + + private boolean hasValidGestureSwipeTarget() { + return hasGestureSwipeTarget() && mListItemView.getParent() == mRecyclerView; + } + + /** + * Enable a hardware layer for the it view and build that layer. + */ + private void setHardwareAnimatingLayerType(final ConversationListItemView itemView, + final boolean animating) { + if (animating) { + itemView.setLayerType(View.LAYER_TYPE_HARDWARE, null); + if (itemView.getWindowToken() != null) { + itemView.buildLayer(); + } + } else { + itemView.setLayerType(View.LAYER_TYPE_NONE, null); + } + } + + private float getLastComputedXVelocity() { + return mVelocityTracker.getXVelocity(); + } +}
\ No newline at end of file diff --git a/src/com/android/messaging/ui/conversationlist/ForwardMessageActivity.java b/src/com/android/messaging/ui/conversationlist/ForwardMessageActivity.java new file mode 100644 index 0000000..61e3640 --- /dev/null +++ b/src/com/android/messaging/ui/conversationlist/ForwardMessageActivity.java @@ -0,0 +1,81 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.ui.conversationlist; + +import android.app.Fragment; +import android.os.Bundle; + +import com.android.messaging.datamodel.data.ConversationListData; +import com.android.messaging.datamodel.data.ConversationListItemData; +import com.android.messaging.datamodel.data.MessageData; +import com.android.messaging.ui.BaseBugleActivity; +import com.android.messaging.ui.UIIntents; +import com.android.messaging.ui.conversationlist.ConversationListFragment.ConversationListFragmentHost; +import com.android.messaging.util.Assert; + +/** + * An activity that lets the user forward a SMS/MMS message by picking from a conversation in the + * conversation list. + */ +public class ForwardMessageActivity extends BaseBugleActivity + implements ConversationListFragmentHost { + private MessageData mDraftMessage; + + @Override + protected void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + final ConversationListFragment fragment = + ConversationListFragment.createForwardMessageConversationListFragment(); + getFragmentManager().beginTransaction().add(android.R.id.content, fragment).commit(); + mDraftMessage = getIntent().getParcelableExtra(UIIntents.UI_INTENT_EXTRA_DRAFT_DATA); + } + + @Override + public void onAttachFragment(final Fragment fragment) { + Assert.isTrue(fragment instanceof ConversationListFragment); + final ConversationListFragment clf = (ConversationListFragment) fragment; + clf.setHost(this); + } + + @Override + public void onConversationClick(final ConversationListData listData, + final ConversationListItemData conversationListItemData, + final boolean isLongClick, final ConversationListItemView converastionView) { + UIIntents.get().launchConversationActivity( + this, conversationListItemData.getConversationId(), mDraftMessage); + } + + @Override + public void onCreateConversationClick() { + UIIntents.get().launchCreateNewConversationActivity(this, mDraftMessage); + } + + @Override + public boolean isConversationSelected(final String conversationId) { + return false; + } + + @Override + public boolean isSwipeAnimatable() { + return false; + } + + @Override + public boolean isSelectionMode() { + return false; + } +} diff --git a/src/com/android/messaging/ui/conversationlist/MultiSelectActionModeCallback.java b/src/com/android/messaging/ui/conversationlist/MultiSelectActionModeCallback.java new file mode 100644 index 0000000..bfeec51 --- /dev/null +++ b/src/com/android/messaging/ui/conversationlist/MultiSelectActionModeCallback.java @@ -0,0 +1,219 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui.conversationlist; + +import android.support.v4.util.ArrayMap; +import android.text.TextUtils; +import android.view.ActionMode; +import android.view.ActionMode.Callback; +import android.view.Menu; +import android.view.MenuItem; + +import com.android.messaging.R; +import com.android.messaging.datamodel.data.ConversationListData; +import com.android.messaging.datamodel.data.ConversationListItemData; +import com.android.messaging.util.Assert; + +import java.util.Collection; +import java.util.HashSet; + +public class MultiSelectActionModeCallback implements Callback { + private HashSet<String> mBlockedSet; + + public interface Listener { + void onActionBarDelete(Collection<SelectedConversation> conversations); + void onActionBarArchive(Iterable<SelectedConversation> conversations, + boolean isToArchive); + void onActionBarNotification(Iterable<SelectedConversation> conversations, + boolean isNotificationOn); + void onActionBarAddContact(final SelectedConversation conversation); + void onActionBarBlock(final SelectedConversation conversation); + void onActionBarHome(); + } + + static class SelectedConversation { + public final String conversationId; + public final long timestamp; + public final String icon; + public final String otherParticipantNormalizedDestination; + public final CharSequence participantLookupKey; + public final boolean isGroup; + public final boolean isArchived; + public final boolean notificationEnabled; + public SelectedConversation(ConversationListItemData data) { + conversationId = data.getConversationId(); + timestamp = data.getTimestamp(); + icon = data.getIcon(); + otherParticipantNormalizedDestination = data.getOtherParticipantNormalizedDestination(); + participantLookupKey = data.getParticipantLookupKey(); + isGroup = data.getIsGroup(); + isArchived = data.getIsArchived(); + notificationEnabled = data.getNotificationEnabled(); + } + } + + private final ArrayMap<String, SelectedConversation> mSelectedConversations; + + private Listener mListener; + private MenuItem mArchiveMenuItem; + private MenuItem mUnarchiveMenuItem; + private MenuItem mAddContactMenuItem; + private MenuItem mBlockMenuItem; + private MenuItem mNotificationOnMenuItem; + private MenuItem mNotificationOffMenuItem; + private boolean mHasInflated; + + public MultiSelectActionModeCallback(final Listener listener) { + mListener = listener; + mSelectedConversations = new ArrayMap<>(); + + } + + @Override + public boolean onCreateActionMode(ActionMode actionMode, Menu menu) { + actionMode.getMenuInflater().inflate(R.menu.conversation_list_fragment_select_menu, menu); + mArchiveMenuItem = menu.findItem(R.id.action_archive); + mUnarchiveMenuItem = menu.findItem(R.id.action_unarchive); + mAddContactMenuItem = menu.findItem(R.id.action_add_contact); + mBlockMenuItem = menu.findItem(R.id.action_block); + mNotificationOffMenuItem = menu.findItem(R.id.action_notification_off); + mNotificationOnMenuItem = menu.findItem(R.id.action_notification_on); + mHasInflated = true; + updateActionIconsVisiblity(); + return true; + } + + @Override + public boolean onPrepareActionMode(ActionMode actionMode, Menu menu) { + return true; + } + + @Override + public boolean onActionItemClicked(ActionMode actionMode, MenuItem menuItem) { + switch(menuItem.getItemId()) { + case R.id.action_delete: + mListener.onActionBarDelete(mSelectedConversations.values()); + return true; + case R.id.action_archive: + mListener.onActionBarArchive(mSelectedConversations.values(), true); + return true; + case R.id.action_unarchive: + mListener.onActionBarArchive(mSelectedConversations.values(), false); + return true; + case R.id.action_notification_off: + mListener.onActionBarNotification(mSelectedConversations.values(), false); + return true; + case R.id.action_notification_on: + mListener.onActionBarNotification(mSelectedConversations.values(), true); + return true; + case R.id.action_add_contact: + Assert.isTrue(mSelectedConversations.size() == 1); + mListener.onActionBarAddContact(mSelectedConversations.valueAt(0)); + return true; + case R.id.action_block: + Assert.isTrue(mSelectedConversations.size() == 1); + mListener.onActionBarBlock(mSelectedConversations.valueAt(0)); + return true; + case android.R.id.home: + mListener.onActionBarHome(); + return true; + default: + return false; + } + } + + @Override + public void onDestroyActionMode(ActionMode actionMode) { + mListener = null; + mSelectedConversations.clear(); + mHasInflated = false; + } + + public void toggleSelect(final ConversationListData listData, + final ConversationListItemData conversationListItemData) { + Assert.notNull(conversationListItemData); + mBlockedSet = listData.getBlockedParticipants(); + final String id = conversationListItemData.getConversationId(); + if (mSelectedConversations.containsKey(id)) { + mSelectedConversations.remove(id); + } else { + mSelectedConversations.put(id, new SelectedConversation(conversationListItemData)); + } + + if (mSelectedConversations.isEmpty()) { + mListener.onActionBarHome(); + } else { + updateActionIconsVisiblity(); + } + } + + public boolean isSelected(final String selectedId) { + return mSelectedConversations.containsKey(selectedId); + } + + private void updateActionIconsVisiblity() { + if (!mHasInflated) { + return; + } + + if (mSelectedConversations.size() == 1) { + final SelectedConversation conversation = mSelectedConversations.valueAt(0); + // The look up key is a key given to us by contacts app, so if we have a look up key, + // we know that the participant is already in contacts. + final boolean isInContacts = !TextUtils.isEmpty(conversation.participantLookupKey); + mAddContactMenuItem.setVisible(!conversation.isGroup && !isInContacts); + // ParticipantNormalizedDestination is always null for group conversations. + final String otherParticipant = conversation.otherParticipantNormalizedDestination; + mBlockMenuItem.setVisible(otherParticipant != null + && !mBlockedSet.contains(otherParticipant)); + } else { + mBlockMenuItem.setVisible(false); + mAddContactMenuItem.setVisible(false); + } + + boolean hasCurrentlyArchived = false; + boolean hasCurrentlyUnarchived = false; + boolean hasCurrentlyOnNotification = false; + boolean hasCurrentlyOffNotification = false; + final Iterable<SelectedConversation> conversations = mSelectedConversations.values(); + for (final SelectedConversation conversation : conversations) { + if (conversation.notificationEnabled) { + hasCurrentlyOnNotification = true; + } else { + hasCurrentlyOffNotification = true; + } + + if (conversation.isArchived) { + hasCurrentlyArchived = true; + } else { + hasCurrentlyUnarchived = true; + } + + // If we found at least one of each example we don't need to keep looping. + if (hasCurrentlyOffNotification && hasCurrentlyOnNotification && + hasCurrentlyArchived && hasCurrentlyUnarchived) { + break; + } + } + // If we have notification off conversations we show on button, if we have notification on + // conversation we show off button. We can show both if we have a mixture. + mNotificationOffMenuItem.setVisible(hasCurrentlyOnNotification); + mNotificationOnMenuItem.setVisible(hasCurrentlyOffNotification); + + mArchiveMenuItem.setVisible(hasCurrentlyUnarchived); + mUnarchiveMenuItem.setVisible(hasCurrentlyArchived); + } +} diff --git a/src/com/android/messaging/ui/conversationlist/ShareIntentActivity.java b/src/com/android/messaging/ui/conversationlist/ShareIntentActivity.java new file mode 100644 index 0000000..ef7fcef --- /dev/null +++ b/src/com/android/messaging/ui/conversationlist/ShareIntentActivity.java @@ -0,0 +1,177 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.ui.conversationlist; + +import android.app.Fragment; +import android.content.ContentResolver; +import android.content.Intent; +import android.media.MediaMetadataRetriever; +import android.net.Uri; +import android.os.Bundle; +import android.text.TextUtils; + +import com.android.messaging.Factory; +import com.android.messaging.datamodel.data.ConversationListItemData; +import com.android.messaging.datamodel.data.MessageData; +import com.android.messaging.datamodel.data.PendingAttachmentData; +import com.android.messaging.ui.BaseBugleActivity; +import com.android.messaging.ui.UIIntents; +import com.android.messaging.util.Assert; +import com.android.messaging.util.ContentType; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.MediaMetadataRetrieverWrapper; + +import java.io.IOException; +import java.util.ArrayList; + +public class ShareIntentActivity extends BaseBugleActivity implements + ShareIntentFragment.HostInterface { + + private MessageData mDraftMessage; + + @Override + protected void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + final Intent intent = getIntent(); + if (Intent.ACTION_SEND.equals(intent.getAction()) && + (!TextUtils.isEmpty(intent.getStringExtra("address")) || + !TextUtils.isEmpty(intent.getStringExtra(Intent.EXTRA_EMAIL)))) { + // This is really more like a SENDTO intent because a destination is supplied. + // It's coming through the SEND intent because that's the intent that is used + // when invoking the chooser with Intent.createChooser(). + final Intent convIntent = UIIntents.get().getLaunchConversationActivityIntent(this); + // Copy the important items from the original intent to the new intent. + convIntent.putExtras(intent); + convIntent.setAction(Intent.ACTION_SENDTO); + convIntent.setDataAndType(intent.getData(), intent.getType()); + // We have to fire off the intent and finish before trying to show the fragment, + // otherwise we get some flashing. + startActivity(convIntent); + finish(); + return; + } + new ShareIntentFragment().show(getFragmentManager(), "ShareIntentFragment"); + } + + @Override + public void onAttachFragment(final Fragment fragment) { + final Intent intent = getIntent(); + final String action = intent.getAction(); + if (Intent.ACTION_SEND.equals(action)) { + final Uri contentUri = (Uri) intent.getParcelableExtra(Intent.EXTRA_STREAM); + final String contentType = extractContentType(contentUri, intent.getType()); + if (LogUtil.isLoggable(LogUtil.BUGLE_TAG, LogUtil.DEBUG)) { + LogUtil.d(LogUtil.BUGLE_TAG, String.format( + "onAttachFragment: contentUri=%s, intent.getType()=%s, inferredType=%s", + contentUri, intent.getType(), contentType)); + } + if (ContentType.TEXT_PLAIN.equals(contentType)) { + final String sharedText = intent.getStringExtra(Intent.EXTRA_TEXT); + if (sharedText != null) { + mDraftMessage = MessageData.createSharedMessage(sharedText); + } else { + mDraftMessage = null; + } + } else if (ContentType.isImageType(contentType) || + ContentType.isVCardType(contentType) || + ContentType.isAudioType(contentType) || + ContentType.isVideoType(contentType)) { + if (contentUri != null) { + mDraftMessage = MessageData.createSharedMessage(null); + addSharedImagePartToDraft(contentType, contentUri); + } else { + mDraftMessage = null; + } + } else { + // Unsupported content type. + Assert.fail("Unsupported shared content type for " + contentUri + ": " + contentType + + " (" + intent.getType() + ")"); + } + } else if (Intent.ACTION_SEND_MULTIPLE.equals(action)) { + final String contentType = intent.getType(); + if (ContentType.isImageType(contentType)) { + // Handle sharing multiple images. + final ArrayList<Uri> imageUris = intent.getParcelableArrayListExtra( + Intent.EXTRA_STREAM); + if (imageUris != null && imageUris.size() > 0) { + mDraftMessage = MessageData.createSharedMessage(null); + for (final Uri imageUri : imageUris) { + final String actualContentType = extractContentType(imageUri, contentType); + addSharedImagePartToDraft(actualContentType, imageUri); + } + } else { + mDraftMessage = null; + } + } else { + // Unsupported content type. + Assert.fail("Unsupported shared content type: " + contentType); + } + } else { + // Unsupported action. + Assert.fail("Unsupported action type for sharing: " + action); + } + } + + private static String extractContentType(final Uri uri, final String contentType) { + if (uri == null) { + return contentType; + } + // First try looking at file extension. This is less reliable in some ways but it's + // recommended by + // https://developer.android.com/training/secure-file-sharing/retrieve-info.html + // Some implementations of MediaMetadataRetriever get things horribly + // wrong for common formats such as jpeg (reports as video/ffmpeg) + final ContentResolver resolver = Factory.get().getApplicationContext().getContentResolver(); + final String typeFromExtension = resolver.getType(uri); + if (typeFromExtension != null) { + return typeFromExtension; + } + final MediaMetadataRetrieverWrapper retriever = new MediaMetadataRetrieverWrapper(); + try { + retriever.setDataSource(uri); + final String extractedType = retriever.extractMetadata( + MediaMetadataRetriever.METADATA_KEY_MIMETYPE); + if (extractedType != null) { + return extractedType; + } + } catch (final IOException e) { + LogUtil.i(LogUtil.BUGLE_TAG, "Could not determine type of " + uri, e); + } finally { + retriever.release(); + } + return contentType; + } + + private void addSharedImagePartToDraft(final String contentType, final Uri imageUri) { + mDraftMessage.addPart(PendingAttachmentData.createPendingAttachmentData(contentType, + imageUri)); + } + + @Override + public void onConversationClick(final ConversationListItemData conversationListItemData) { + UIIntents.get().launchConversationActivity( + this, conversationListItemData.getConversationId(), mDraftMessage); + finish(); + } + + @Override + public void onCreateConversationClick() { + UIIntents.get().launchCreateNewConversationActivity(this, mDraftMessage); + finish(); + } +} diff --git a/src/com/android/messaging/ui/conversationlist/ShareIntentAdapter.java b/src/com/android/messaging/ui/conversationlist/ShareIntentAdapter.java new file mode 100644 index 0000000..e894145 --- /dev/null +++ b/src/com/android/messaging/ui/conversationlist/ShareIntentAdapter.java @@ -0,0 +1,138 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.messaging.ui.conversationlist; + +import android.content.Context; +import android.content.Intent; +import android.database.Cursor; +import android.net.Uri; +import android.support.v7.widget.RecyclerView; +import android.view.LayoutInflater; +import android.view.ViewGroup; + +import com.android.messaging.R; +import com.android.messaging.datamodel.data.ConversationListItemData; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.datamodel.data.PersonItemData; +import com.android.messaging.ui.CursorRecyclerAdapter; +import com.android.messaging.ui.PersonItemView; +import com.android.messaging.ui.PersonItemView.PersonItemViewListener; +import com.android.messaging.util.PhoneUtils; + +/** + * Turn conversation rows into PeopleItemViews + */ +public class ShareIntentAdapter + extends CursorRecyclerAdapter<ShareIntentAdapter.ShareIntentViewHolder> { + + public interface HostInterface { + void onConversationClicked(final ConversationListItemData conversationListItemData); + } + + private final HostInterface mHostInterface; + + public ShareIntentAdapter(final Context context, final Cursor cursor, + final HostInterface hostInterface) { + super(context, cursor, 0); + mHostInterface = hostInterface; + setHasStableIds(true); + } + + @Override + public void bindViewHolder(final ShareIntentViewHolder holder, final Context context, + final Cursor cursor) { + holder.bind(cursor); + } + + @Override + public ShareIntentViewHolder createViewHolder(final Context context, + final ViewGroup parent, final int viewType) { + final PersonItemView itemView = (PersonItemView) LayoutInflater.from(context).inflate( + R.layout.people_list_item_view, null); + return new ShareIntentViewHolder(itemView); + } + + /** + * Holds a PersonItemView and keeps it synced with a ConversationListItemData. + */ + public class ShareIntentViewHolder extends RecyclerView.ViewHolder implements + PersonItemView.PersonItemViewListener { + private final ConversationListItemData mData = new ConversationListItemData(); + private final PersonItemData mItemData = new PersonItemData() { + @Override + public Uri getAvatarUri() { + return mData.getIcon() == null ? null : Uri.parse(mData.getIcon()); + } + + @Override + public String getDisplayName() { + return mData.getName(); + } + + @Override + public String getDetails() { + final String conversationName = mData.getName(); + final String conversationPhone = PhoneUtils.getDefault().formatForDisplay( + mData.getOtherParticipantNormalizedDestination()); + if (conversationPhone == null || conversationPhone.equals(conversationName)) { + return null; + } + return conversationPhone; + } + + @Override + public Intent getClickIntent() { + return null; + } + + @Override + public long getContactId() { + return ParticipantData.PARTICIPANT_CONTACT_ID_NOT_RESOLVED; + } + + @Override + public String getLookupKey() { + return null; + } + + @Override + public String getNormalizedDestination() { + return null; + } + }; + + public ShareIntentViewHolder(final PersonItemView itemView) { + super(itemView); + itemView.setListener(this); + } + + public void bind(Cursor cursor) { + mData.bind(cursor); + ((PersonItemView) itemView).bind(mItemData); + } + + @Override + public void onPersonClicked(PersonItemData data) { + mHostInterface.onConversationClicked(mData); + } + + @Override + public boolean onPersonLongClicked(PersonItemData data) { + return false; + } + } +} diff --git a/src/com/android/messaging/ui/conversationlist/ShareIntentFragment.java b/src/com/android/messaging/ui/conversationlist/ShareIntentFragment.java new file mode 100644 index 0000000..bc549ea --- /dev/null +++ b/src/com/android/messaging/ui/conversationlist/ShareIntentFragment.java @@ -0,0 +1,163 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.messaging.ui.conversationlist; + +import android.app.Activity; +import android.app.AlertDialog; +import android.app.AlertDialog.Builder; +import android.app.Dialog; +import android.app.DialogFragment; +import android.content.DialogInterface; +import android.content.DialogInterface.OnClickListener; +import android.database.Cursor; +import android.os.Bundle; +import android.support.v7.widget.LinearLayoutManager; +import android.support.v7.widget.RecyclerView; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import com.android.messaging.R; +import com.android.messaging.datamodel.binding.Binding; +import com.android.messaging.datamodel.binding.BindingBase; +import com.android.messaging.datamodel.data.ConversationListData; +import com.android.messaging.datamodel.data.ConversationListItemData; +import com.android.messaging.datamodel.data.ConversationListData.ConversationListDataListener; +import com.android.messaging.ui.ListEmptyView; +import com.android.messaging.datamodel.DataModel; + +/** + * Allow user to pick conversation to which an incoming attachment will be shared. + */ +public class ShareIntentFragment extends DialogFragment implements ConversationListDataListener, + ShareIntentAdapter.HostInterface { + public static final String HIDE_NEW_CONVERSATION_BUTTON_KEY = "hide_conv_button_key"; + + public interface HostInterface { + public void onConversationClick(final ConversationListItemData conversationListItemData); + public void onCreateConversationClick(); + } + + private final Binding<ConversationListData> mListBinding = BindingBase.createBinding(this); + private RecyclerView mRecyclerView; + private ListEmptyView mEmptyListMessageView; + private ShareIntentAdapter mAdapter; + private HostInterface mHost; + private boolean mDismissed; + + /** + * {@inheritDoc} from Fragment + */ + @Override + public Dialog onCreateDialog(final Bundle bundle) { + final Activity activity = getActivity(); + final LayoutInflater inflater = activity.getLayoutInflater(); + View view = inflater.inflate(R.layout.share_intent_conversation_list_view, null); + mEmptyListMessageView = (ListEmptyView) view.findViewById(R.id.no_conversations_view); + mEmptyListMessageView.setImageHint(R.drawable.ic_oobe_conv_list); + // The default behavior for default layout param generation by LinearLayoutManager is to + // provide width and height of WRAP_CONTENT, but this is not desirable for + // ShareIntentFragment; the view in each row should be a width of MATCH_PARENT so that + // the entire row is tappable. + final LinearLayoutManager manager = new LinearLayoutManager(activity) { + @Override + public RecyclerView.LayoutParams generateDefaultLayoutParams() { + return new RecyclerView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT); + } + }; + mListBinding.getData().init(getLoaderManager(), mListBinding); + mAdapter = new ShareIntentAdapter(activity, null, this); + mRecyclerView = (RecyclerView) view.findViewById(android.R.id.list); + mRecyclerView.setLayoutManager(manager); + mRecyclerView.setHasFixedSize(true); + mRecyclerView.setAdapter(mAdapter); + final Builder dialogBuilder = new AlertDialog.Builder(activity) + .setView(view) + .setTitle(R.string.share_intent_activity_label); + + final Bundle arguments = getArguments(); + if (arguments == null || !arguments.getBoolean(HIDE_NEW_CONVERSATION_BUTTON_KEY)) { + dialogBuilder.setPositiveButton(R.string.share_new_message, new OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + mDismissed = true; + mHost.onCreateConversationClick(); + } + }); + } + return dialogBuilder.setNegativeButton(R.string.share_cancel, null) + .create(); + } + + @Override + public void onDismiss(DialogInterface dialog) { + if (!mDismissed) { + final Activity activity = getActivity(); + if (activity != null) { + activity.finish(); + } + } + } + + /** + * {@inheritDoc} from Fragment + */ + @Override + public void onDestroy() { + super.onDestroy(); + mListBinding.unbind(); + } + + @Override + public void onAttach(final Activity activity) { + super.onAttach(activity); + if (activity instanceof HostInterface) { + mHost = (HostInterface) activity; + } + mListBinding.bind(DataModel.get().createConversationListData(activity, this, false)); + } + + @Override + public void onConversationListCursorUpdated(final ConversationListData data, + final Cursor cursor) { + mListBinding.ensureBound(data); + mAdapter.swapCursor(cursor); + updateEmptyListUi(cursor == null || cursor.getCount() == 0); + } + + /** + * {@inheritDoc} from SharIntentItemView.HostInterface + */ + @Override + public void onConversationClicked(final ConversationListItemData conversationListItemData) { + mHost.onConversationClick(conversationListItemData); + } + + // Show and hide empty list UI as needed with appropriate text based on view specifics + private void updateEmptyListUi(final boolean isEmpty) { + if (isEmpty) { + mEmptyListMessageView.setTextHint(R.string.conversation_list_empty_text); + mEmptyListMessageView.setVisibility(View.VISIBLE); + } else { + mEmptyListMessageView.setVisibility(View.GONE); + } + } + + @Override + public void setBlockedParticipantsAvailable(boolean blockedAvailable) { + } +} |