summaryrefslogtreecommitdiffstats
path: root/src/com/android/messaging/ui/conversationlist
diff options
context:
space:
mode:
Diffstat (limited to 'src/com/android/messaging/ui/conversationlist')
-rw-r--r--src/com/android/messaging/ui/conversationlist/AbstractConversationListActivity.java339
-rw-r--r--src/com/android/messaging/ui/conversationlist/ArchivedConversationListActivity.java96
-rw-r--r--src/com/android/messaging/ui/conversationlist/ConversationListActivity.java144
-rw-r--r--src/com/android/messaging/ui/conversationlist/ConversationListAdapter.java77
-rw-r--r--src/com/android/messaging/ui/conversationlist/ConversationListFragment.java446
-rw-r--r--src/com/android/messaging/ui/conversationlist/ConversationListItemView.java643
-rw-r--r--src/com/android/messaging/ui/conversationlist/ConversationListSwipeHelper.java462
-rw-r--r--src/com/android/messaging/ui/conversationlist/ForwardMessageActivity.java81
-rw-r--r--src/com/android/messaging/ui/conversationlist/MultiSelectActionModeCallback.java219
-rw-r--r--src/com/android/messaging/ui/conversationlist/ShareIntentActivity.java177
-rw-r--r--src/com/android/messaging/ui/conversationlist/ShareIntentAdapter.java138
-rw-r--r--src/com/android/messaging/ui/conversationlist/ShareIntentFragment.java163
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) {
+ }
+}