From a6b23f0f3c8c7709a776057aa3ace59d013d82a7 Mon Sep 17 00:00:00 2001 From: "MINIPC\\frivaong" Date: Tue, 29 Sep 2026 06:16:01 +0900 Subject: [PATCH] Messaging: Split trash from archive and match home selection chrome Inbox delete now parks conversations in a 30-day trash, and spam/archive/trash lists share the home select/search chrome. Co-authored-by: Cursor --- app/src/main/AndroidManifest.xml | 16 + .../datamodel/BugleDatabaseOperations.java | 15 + .../datamodel/BugleNotifications.java | 5 +- .../messaging/datamodel/DataModel.java | 8 +- .../messaging/datamodel/DataModelImpl.java | 7 +- .../messaging/datamodel/DatabaseHelper.java | 10 + .../datamodel/DatabaseUpgradeHelper.java | 14 + .../datamodel/MessageNotificationState.java | 7 + .../action/InsertNewMessageAction.java | 8 + .../action/PurgeExpiredTrashAction.java | 77 +++ .../datamodel/action/ResendMessageAction.java | 7 + .../UpdateConversationTrashStatusAction.java | 86 +++ .../datamodel/data/ConversationData.java | 29 +- .../datamodel/data/ConversationListData.java | 31 +- .../data/ConversationListItemData.java | 23 +- .../data/ParticipantListItemData.java | 5 + .../messaging/ui/BugleActionBarActivity.java | 3 + .../com/android/messaging/ui/UIIntents.java | 12 + .../android/messaging/ui/UIIntentsImpl.java | 19 + .../ui/conversation/ConversationFragment.java | 198 ++++-- .../AbstractConversationListActivity.java | 342 +++++++++-- .../ArchivedConversationListActivity.java | 6 +- .../ConversationListActivity.java | 15 - .../ConversationListAdapter.java | 57 ++ .../ConversationListFragment.java | 574 ++++++++++++++++-- .../ConversationListItemView.java | 58 +- ...centlyDeletedConversationListActivity.java | 105 ++++ .../ui/home/HomeAppLockActivity.java | 37 ++ .../ui/home/HomeDeleteAlertDialog.java | 69 +-- .../messaging/ui/home/HomeInboxFilter.java | 139 +++++ .../messaging/ui/home/HomeInboxMenu.java | 281 +++++++++ .../ui/home/HomeMessageDetailsActivity.java | 214 +++++++ .../android/messaging/ui/home/HomeModal.java | 18 +- .../ui/home/HomeProfileActivity.java | 70 ++- .../messaging/ui/home/HomeSpamActivity.java | 64 +- .../messaging/ui/home/HomeSpamFragment.java | 423 +++++++++++-- .../messaging/ui/home/HomeSubListSearch.java | 363 +++++++++++ .../messaging/ui/home/HomeUiMotion.java | 229 ++++++- .../android/messaging/util/SimNameUtil.java | 40 +- .../widget/WidgetConversationListService.java | 2 +- ...rt_dialog.xml => home_bg_details_card.xml} | 2 +- .../main/res/drawable/home_bg_mute_badge.xml | 17 + .../res/drawable/home_bg_unblock_pill.xml | 10 + .../main/res/layout/conversation_fragment.xml | 2 + .../res/layout/conversation_list_fragment.xml | 70 ++- .../layout/conversation_list_item_view.xml | 16 +- .../res/layout/home_archived_activity.xml | 34 +- .../layout/home_conversation_trash_bar.xml | 36 ++ .../res/layout/home_delete_alert_dialog.xml | 44 -- .../res/layout/home_lock_pattern_body.xml | 13 +- .../main/res/layout/home_lock_pin_body.xml | 25 +- .../res/layout/home_message_action_popup.xml | 22 - .../layout/home_message_details_activity.xml | 216 +++++++ app/src/main/res/layout/home_modal.xml | 12 + .../res/layout/home_popover_conversation.xml | 75 ++- .../home_popover_conversation_overflow.xml | 40 +- .../main/res/layout/home_popover_filter.xml | 103 +++- .../res/layout/home_profile_rename_dialog.xml | 17 +- .../main/res/layout/home_spam_activity.xml | 26 +- .../main/res/layout/home_spam_fragment.xml | 41 ++ .../main/res/layout/home_spam_list_item.xml | 108 ++++ app/src/main/res/layout/home_sub_header.xml | 146 +++-- .../main/res/layout/home_sub_search_field.xml | 63 ++ .../main/res/layout/home_sub_select_bar.xml | 38 ++ app/src/main/res/values-ko/looply_strings.xml | 84 ++- app/src/main/res/values/looply_dimens.xml | 4 + app/src/main/res/values/looply_strings.xml | 80 ++- app/src/main/res/values/versions.xml | 2 +- 68 files changed, 4477 insertions(+), 555 deletions(-) create mode 100644 app/src/main/java/com/android/messaging/datamodel/action/PurgeExpiredTrashAction.java create mode 100644 app/src/main/java/com/android/messaging/datamodel/action/UpdateConversationTrashStatusAction.java create mode 100644 app/src/main/java/com/android/messaging/ui/conversationlist/RecentlyDeletedConversationListActivity.java create mode 100644 app/src/main/java/com/android/messaging/ui/home/HomeInboxFilter.java create mode 100644 app/src/main/java/com/android/messaging/ui/home/HomeInboxMenu.java create mode 100644 app/src/main/java/com/android/messaging/ui/home/HomeMessageDetailsActivity.java create mode 100644 app/src/main/java/com/android/messaging/ui/home/HomeSubListSearch.java rename app/src/main/res/drawable/{home_bg_alert_dialog.xml => home_bg_details_card.xml} (80%) create mode 100644 app/src/main/res/drawable/home_bg_mute_badge.xml create mode 100644 app/src/main/res/drawable/home_bg_unblock_pill.xml create mode 100644 app/src/main/res/layout/home_conversation_trash_bar.xml delete mode 100644 app/src/main/res/layout/home_delete_alert_dialog.xml create mode 100644 app/src/main/res/layout/home_message_details_activity.xml create mode 100644 app/src/main/res/layout/home_spam_fragment.xml create mode 100644 app/src/main/res/layout/home_spam_list_item.xml create mode 100644 app/src/main/res/layout/home_sub_search_field.xml create mode 100644 app/src/main/res/layout/home_sub_select_bar.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 557b420..4dac443 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -146,6 +146,14 @@ + + + + 0) { + values.put(ConversationColumns.ARCHIVE_STATUS, 0); + } + updateConversationRowIfExists(dbWrapper, conversationId, values); + } + static void addSnippetTextAndPreviewToContentValues(final MessageData message, final boolean showDraft, final ContentValues values) { values.put(ConversationColumns.SHOW_DRAFT, showDraft ? 1 : 0); diff --git a/app/src/main/java/com/android/messaging/datamodel/BugleNotifications.java b/app/src/main/java/com/android/messaging/datamodel/BugleNotifications.java index 4ef4305..4632562 100644 --- a/app/src/main/java/com/android/messaging/datamodel/BugleNotifications.java +++ b/app/src/main/java/com/android/messaging/datamodel/BugleNotifications.java @@ -539,7 +539,10 @@ public class BugleNotifications { private static void createMessageNotification(final boolean silent, final String conversationId) { final NotificationState state = MessageNotificationState.getNotificationState(); - final boolean softSound = DataModel.get().isNewMessageObservable(conversationId); + final boolean muted = MutedConversationStore.isMuted( + Factory.get().getApplicationContext(), conversationId); + final boolean softSound = !muted + && DataModel.get().isNewMessageObservable(conversationId); if (state == null) { cancel(PendingIntentConstants.SMS_NOTIFICATION_ID); if (softSound && !TextUtils.isEmpty(conversationId)) { diff --git a/app/src/main/java/com/android/messaging/datamodel/DataModel.java b/app/src/main/java/com/android/messaging/datamodel/DataModel.java index 16cb145..564feb7 100644 --- a/app/src/main/java/com/android/messaging/datamodel/DataModel.java +++ b/app/src/main/java/com/android/messaging/datamodel/DataModel.java @@ -68,8 +68,14 @@ public abstract class DataModel { get().getActionService().scheduleAction(action, code, delayMs); } + public ConversationListData createConversationListData(final Context context, + final ConversationListDataListener listener, final boolean archivedMode) { + return createConversationListData(context, listener, archivedMode, false); + } + public abstract ConversationListData createConversationListData(final Context context, - final ConversationListDataListener listener, final boolean archivedMode); + final ConversationListDataListener listener, final boolean archivedMode, + final boolean trashMode); public abstract ConversationData createConversationData(final Context context, final ConversationDataListener listener, final String conversationId); diff --git a/app/src/main/java/com/android/messaging/datamodel/DataModelImpl.java b/app/src/main/java/com/android/messaging/datamodel/DataModelImpl.java index 564aeb1..66c87b5 100644 --- a/app/src/main/java/com/android/messaging/datamodel/DataModelImpl.java +++ b/app/src/main/java/com/android/messaging/datamodel/DataModelImpl.java @@ -26,6 +26,7 @@ import com.android.messaging.datamodel.action.ActionService; import com.android.messaging.datamodel.action.BackgroundWorker; import com.android.messaging.datamodel.action.FixupMessageStatusOnStartupAction; import com.android.messaging.datamodel.action.ProcessPendingMessagesAction; +import com.android.messaging.datamodel.action.PurgeExpiredTrashAction; import com.android.messaging.datamodel.data.BlockedParticipantsData; import com.android.messaging.datamodel.data.BlockedParticipantsData.BlockedParticipantsDataListener; import com.android.messaging.datamodel.data.ConversationSearchData; @@ -81,8 +82,9 @@ public class DataModelImpl extends DataModel { @Override public ConversationListData createConversationListData(final Context context, - final ConversationListDataListener listener, final boolean archivedMode) { - return new ConversationListData(context, listener, archivedMode); + final ConversationListDataListener listener, final boolean archivedMode, + final boolean trashMode) { + return new ConversationListData(context, listener, archivedMode, trashMode); } @Override @@ -211,6 +213,7 @@ public class DataModelImpl extends DataModel { createConnectivityUtilForEachActiveSubscription(); FixupMessageStatusOnStartupAction.fixupMessageStatus(); + PurgeExpiredTrashAction.purgeExpired(); ProcessPendingMessagesAction.processFirstPendingMessage(); SyncManager.immediateSync(); diff --git a/app/src/main/java/com/android/messaging/datamodel/DatabaseHelper.java b/app/src/main/java/com/android/messaging/datamodel/DatabaseHelper.java index 3eb6a26..ed3803d 100644 --- a/app/src/main/java/com/android/messaging/datamodel/DatabaseHelper.java +++ b/app/src/main/java/com/android/messaging/datamodel/DatabaseHelper.java @@ -101,6 +101,9 @@ public class DatabaseHelper extends SQLiteOpenHelper { /* If this conversation is archived */ public static final String ARCHIVE_STATUS = "archive_status"; + /* When the conversation was moved to Trash; 0 if not in Trash */ + public static final String TRASH_TIMESTAMP = "trash_timestamp"; + /* Timestamp for sorting purposes */ public static final String SORT_TIMESTAMP = "sort_timestamp"; @@ -159,6 +162,7 @@ public class DatabaseHelper extends SQLiteOpenHelper { + ConversationColumns.DRAFT_PREVIEW_URI + " TEXT, " + ConversationColumns.DRAFT_PREVIEW_CONTENT_TYPE + " TEXT, " + ConversationColumns.ARCHIVE_STATUS + " INT DEFAULT(0), " + + ConversationColumns.TRASH_TIMESTAMP + " INT DEFAULT(0), " + ConversationColumns.SORT_TIMESTAMP + " INT DEFAULT(0), " + ConversationColumns.LAST_READ_TIMESTAMP + " INT DEFAULT(0), " + ConversationColumns.ICON + " TEXT, " @@ -183,6 +187,11 @@ public class DatabaseHelper extends SQLiteOpenHelper { + " ON " + CONVERSATIONS_TABLE + "(" + ConversationColumns.ARCHIVE_STATUS + ")"; + private static final String CONVERSATIONS_TABLE_TRASH_TIMESTAMP_INDEX_SQL = + "CREATE INDEX index_" + CONVERSATIONS_TABLE + "_" + ConversationColumns.TRASH_TIMESTAMP + + " ON " + CONVERSATIONS_TABLE + + "(" + ConversationColumns.TRASH_TIMESTAMP + ")"; + private static final String CONVERSATIONS_TABLE_SORT_TIMESTAMP_INDEX_SQL = "CREATE INDEX index_" + CONVERSATIONS_TABLE + "_" + ConversationColumns.SORT_TIMESTAMP + " ON " + CONVERSATIONS_TABLE @@ -525,6 +534,7 @@ public class DatabaseHelper extends SQLiteOpenHelper { private static final String[] CREATE_INDEX_SQLS = new String[] { CONVERSATIONS_TABLE_SMS_THREAD_ID_INDEX_SQL, CONVERSATIONS_TABLE_ARCHIVE_STATUS_INDEX_SQL, + CONVERSATIONS_TABLE_TRASH_TIMESTAMP_INDEX_SQL, CONVERSATIONS_TABLE_SORT_TIMESTAMP_INDEX_SQL, MESSAGES_TABLE_SORT_INDEX_SQL, MESSAGES_TABLE_STATUS_SEEN_INDEX_SQL, diff --git a/app/src/main/java/com/android/messaging/datamodel/DatabaseUpgradeHelper.java b/app/src/main/java/com/android/messaging/datamodel/DatabaseUpgradeHelper.java index 96aba86..9400a7f 100644 --- a/app/src/main/java/com/android/messaging/datamodel/DatabaseUpgradeHelper.java +++ b/app/src/main/java/com/android/messaging/datamodel/DatabaseUpgradeHelper.java @@ -48,6 +48,9 @@ public class DatabaseUpgradeHelper { if (currentVersion < 2) { currentVersion = upgradeToVersion2(db); } + if (currentVersion < 3) { + currentVersion = upgradeToVersion3(db); + } // Rebuild all the views final Context context = Factory.get().getApplicationContext(); DatabaseHelper.dropAllViews(db); @@ -63,6 +66,17 @@ public class DatabaseUpgradeHelper { return 2; } + private int upgradeToVersion3(final SQLiteDatabase db) { + db.execSQL("ALTER TABLE " + DatabaseHelper.CONVERSATIONS_TABLE + " ADD COLUMN " + + DatabaseHelper.ConversationColumns.TRASH_TIMESTAMP + " INT DEFAULT(0)"); + db.execSQL("CREATE INDEX IF NOT EXISTS index_" + DatabaseHelper.CONVERSATIONS_TABLE + + "_" + DatabaseHelper.ConversationColumns.TRASH_TIMESTAMP + + " ON " + DatabaseHelper.CONVERSATIONS_TABLE + + "(" + DatabaseHelper.ConversationColumns.TRASH_TIMESTAMP + ")"); + LogUtil.i(TAG, "Upgraded database to version 3"); + return 3; + } + /** * Checks db version correctness at the end of each milestone release. If target database * version lies beyond the version range that the current release may handle, we snap the diff --git a/app/src/main/java/com/android/messaging/datamodel/MessageNotificationState.java b/app/src/main/java/com/android/messaging/datamodel/MessageNotificationState.java index 23a8baa..5079031 100644 --- a/app/src/main/java/com/android/messaging/datamodel/MessageNotificationState.java +++ b/app/src/main/java/com/android/messaging/datamodel/MessageNotificationState.java @@ -51,6 +51,7 @@ import com.android.messaging.datamodel.data.MessagePartData; import com.android.messaging.datamodel.data.ParticipantData; import com.android.messaging.sms.MmsUtils; import com.android.messaging.ui.UIIntents; +import com.android.messaging.ui.home.MutedConversationStore; import com.android.messaging.util.Assert; import com.android.messaging.util.AvatarUriUtil; import com.android.messaging.util.BugleGservicesKeys; @@ -802,6 +803,9 @@ public abstract class MessageNotificationState extends NotificationState { final String messageText = convMessageData.getText(); final String convId = convMessageData.getConversationId(); + if (MutedConversationStore.isMuted(context, convId)) { + continue; + } final String messageId = convMessageData.getMessageId(); CharSequence text = messageText; @@ -818,6 +822,9 @@ public abstract class MessageNotificationState extends NotificationState { if (currConvInfo == null) { final ConversationListItemData convData = ConversationListItemData.getExistingConversation(db, convId); + if (convData == null || convData.getIsTrashed()) { + continue; + } final int subId = BugleDatabaseOperations.getSelfSubscriptionId(db, convData.getSelfId()); groupConversationName = convData.getName(); diff --git a/app/src/main/java/com/android/messaging/datamodel/action/InsertNewMessageAction.java b/app/src/main/java/com/android/messaging/datamodel/action/InsertNewMessageAction.java index 754b58c..ddc5801 100644 --- a/app/src/main/java/com/android/messaging/datamodel/action/InsertNewMessageAction.java +++ b/app/src/main/java/com/android/messaging/datamodel/action/InsertNewMessageAction.java @@ -132,6 +132,13 @@ public class InsertNewMessageAction extends Action implements Parcelable { final DatabaseWrapper db = DataModel.get().getDatabase(); final String conversationId = message.getConversationId(); + final ConversationListItemData conversation = + ConversationListItemData.getExistingConversation(db, conversationId); + if (conversation != null && conversation.getIsTrashed()) { + LogUtil.w(TAG, "InsertNewMessageAction: conversation is in trash"); + return null; + } + final ParticipantData self = getSelf(db, conversationId, message); if (self == null) { return null; @@ -190,6 +197,7 @@ public class InsertNewMessageAction extends Action implements Parcelable { messageToSend, BugleDatabaseOperations.UPDATE_MODE_CLEAR_DRAFT); } MessagingContentProvider.notifyConversationListChanged(); + MessagingContentProvider.notifyConversationMetadataChanged(conversationId); ProcessPendingMessagesAction.scheduleProcessPendingMessagesAction(false, this); return message; diff --git a/app/src/main/java/com/android/messaging/datamodel/action/PurgeExpiredTrashAction.java b/app/src/main/java/com/android/messaging/datamodel/action/PurgeExpiredTrashAction.java new file mode 100644 index 0000000..97b3405 --- /dev/null +++ b/app/src/main/java/com/android/messaging/datamodel/action/PurgeExpiredTrashAction.java @@ -0,0 +1,77 @@ +package com.android.messaging.datamodel.action; + +import android.database.Cursor; +import android.os.Parcel; +import android.os.Parcelable; + +import androidx.annotation.NonNull; + +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.DatabaseHelper; +import com.android.messaging.datamodel.DatabaseHelper.ConversationColumns; +import com.android.messaging.datamodel.DatabaseWrapper; +import com.android.messaging.util.LogUtil; + +import java.util.ArrayList; + +/** Permanently deletes conversations that have been in Trash longer than 30 days. */ +public class PurgeExpiredTrashAction extends Action { + + private static final String TAG = LogUtil.BUGLE_DATAMODEL_TAG; + + public static void purgeExpired() { + new PurgeExpiredTrashAction().start(); + } + + private PurgeExpiredTrashAction() { + } + + @Override + protected Object executeAction() { + final long cutoff = System.currentTimeMillis() + - UpdateConversationTrashStatusAction.RETENTION_MS; + final DatabaseWrapper db = DataModel.get().getDatabase(); + final ArrayList ids = new ArrayList<>(); + final ArrayList timestamps = new ArrayList<>(); + try (Cursor cursor = db.query(DatabaseHelper.CONVERSATIONS_TABLE, + new String[] { ConversationColumns._ID, ConversationColumns.SORT_TIMESTAMP }, + ConversationColumns.TRASH_TIMESTAMP + " > 0 AND " + + ConversationColumns.TRASH_TIMESTAMP + " < ?", + new String[] { Long.toString(cutoff) }, + null, null, null)) { + while (cursor != null && cursor.moveToNext()) { + ids.add(cursor.getString(0)); + timestamps.add(cursor.getLong(1)); + } + } + for (int i = 0; i < ids.size(); i++) { + DeleteConversationAction.deleteConversation(ids.get(i), timestamps.get(i)); + } + if (!ids.isEmpty()) { + LogUtil.i(TAG, "Purged " + ids.size() + " expired trash conversations"); + } + return null; + } + + private PurgeExpiredTrashAction(final Parcel in) { + super(in); + } + + public static final Parcelable.Creator CREATOR + = new Parcelable.Creator<>() { + @Override + public PurgeExpiredTrashAction createFromParcel(final Parcel in) { + return new PurgeExpiredTrashAction(in); + } + + @Override + public PurgeExpiredTrashAction[] newArray(final int size) { + return new PurgeExpiredTrashAction[size]; + } + }; + + @Override + public void writeToParcel(@NonNull final Parcel parcel, final int flags) { + writeActionToParcel(parcel, flags); + } +} diff --git a/app/src/main/java/com/android/messaging/datamodel/action/ResendMessageAction.java b/app/src/main/java/com/android/messaging/datamodel/action/ResendMessageAction.java index 147fdd7..95ae4f6 100644 --- a/app/src/main/java/com/android/messaging/datamodel/action/ResendMessageAction.java +++ b/app/src/main/java/com/android/messaging/datamodel/action/ResendMessageAction.java @@ -28,6 +28,7 @@ import com.android.messaging.datamodel.DataModel; import com.android.messaging.datamodel.DatabaseHelper.MessageColumns; import com.android.messaging.datamodel.DatabaseWrapper; import com.android.messaging.datamodel.MessagingContentProvider; +import com.android.messaging.datamodel.data.ConversationListItemData; import com.android.messaging.datamodel.data.MessageData; import com.android.messaging.util.LogUtil; @@ -70,6 +71,12 @@ public class ResendMessageAction extends Action implements Parcelable { final MessageData message = BugleDatabaseOperations.readMessage(db, messageId); // Check message can be resent if (message != null && message.canResendMessage()) { + final ConversationListItemData conversation = + ConversationListItemData.getExistingConversation(db, message.getConversationId()); + if (conversation != null && conversation.getIsTrashed()) { + LogUtil.w(TAG, "ResendMessageAction: conversation is in trash"); + return null; + } final boolean isMms = message.getIsMms(); long timestamp = System.currentTimeMillis(); if (isMms) { diff --git a/app/src/main/java/com/android/messaging/datamodel/action/UpdateConversationTrashStatusAction.java b/app/src/main/java/com/android/messaging/datamodel/action/UpdateConversationTrashStatusAction.java new file mode 100644 index 0000000..de633d1 --- /dev/null +++ b/app/src/main/java/com/android/messaging/datamodel/action/UpdateConversationTrashStatusAction.java @@ -0,0 +1,86 @@ +package com.android.messaging.datamodel.action; + +import android.os.Parcel; +import android.os.Parcelable; +import android.text.TextUtils; + +import androidx.annotation.NonNull; + +import com.android.messaging.Factory; +import com.android.messaging.datamodel.BugleDatabaseOperations; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.DatabaseWrapper; +import com.android.messaging.datamodel.MessagingContentProvider; +import com.android.messaging.ui.home.PinnedConversationStore; +import com.android.messaging.util.Assert; + +public class UpdateConversationTrashStatusAction extends Action { + + public static final long RETENTION_MS = 30L * 24 * 60 * 60 * 1000; + + public static void moveToTrash(final String conversationId) { + final UpdateConversationTrashStatusAction action = + new UpdateConversationTrashStatusAction(conversationId, true); + action.start(); + } + + public static void restoreFromTrash(final String conversationId) { + final UpdateConversationTrashStatusAction action = + new UpdateConversationTrashStatusAction(conversationId, false); + action.start(); + } + + private static final String KEY_CONVERSATION_ID = "conversation_id"; + private static final String KEY_TO_TRASH = "to_trash"; + + private UpdateConversationTrashStatusAction(final String conversationId, + final boolean toTrash) { + Assert.isTrue(!TextUtils.isEmpty(conversationId)); + actionParameters.putString(KEY_CONVERSATION_ID, conversationId); + actionParameters.putBoolean(KEY_TO_TRASH, toTrash); + } + + @Override + protected Object executeAction() { + final String conversationId = actionParameters.getString(KEY_CONVERSATION_ID); + final boolean toTrash = actionParameters.getBoolean(KEY_TO_TRASH); + final DatabaseWrapper db = DataModel.get().getDatabase(); + db.beginTransaction(); + try { + BugleDatabaseOperations.updateConversationTrashTimestampInTransaction( + db, conversationId, toTrash ? System.currentTimeMillis() : 0L); + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + if (toTrash) { + PinnedConversationStore.unpin(Factory.get().getApplicationContext(), conversationId); + UpdateMessageNotificationAction.updateMessageNotification(); + } + MessagingContentProvider.notifyConversationListChanged(); + MessagingContentProvider.notifyConversationMetadataChanged(conversationId); + return null; + } + + private UpdateConversationTrashStatusAction(final Parcel in) { + super(in); + } + + public static final Parcelable.Creator CREATOR + = new Parcelable.Creator<>() { + @Override + public UpdateConversationTrashStatusAction createFromParcel(final Parcel in) { + return new UpdateConversationTrashStatusAction(in); + } + + @Override + public UpdateConversationTrashStatusAction[] newArray(final int size) { + return new UpdateConversationTrashStatusAction[size]; + } + }; + + @Override + public void writeToParcel(@NonNull final Parcel parcel, final int flags) { + writeActionToParcel(parcel, flags); + } +} diff --git a/app/src/main/java/com/android/messaging/datamodel/data/ConversationData.java b/app/src/main/java/com/android/messaging/datamodel/data/ConversationData.java index 95c60a1..19ad161 100644 --- a/app/src/main/java/com/android/messaging/datamodel/data/ConversationData.java +++ b/app/src/main/java/com/android/messaging/datamodel/data/ConversationData.java @@ -42,6 +42,7 @@ import com.android.messaging.datamodel.action.InsertNewMessageAction; import com.android.messaging.datamodel.action.RedownloadMmsAction; import com.android.messaging.datamodel.action.ResendMessageAction; import com.android.messaging.datamodel.action.UpdateConversationArchiveStatusAction; +import com.android.messaging.datamodel.action.UpdateConversationTrashStatusAction; import com.android.messaging.datamodel.binding.BindableData; import com.android.messaging.datamodel.binding.Binding; import com.android.messaging.datamodel.binding.BindingBase; @@ -460,7 +461,11 @@ public class ConversationData extends BindableData { } public boolean getIsArchived() { - return mConversationMetadata.getIsArchived(); + return mConversationMetadata != null && mConversationMetadata.getIsArchived(); + } + + public boolean getIsTrashed() { + return mConversationMetadata != null && mConversationMetadata.getIsTrashed(); } public String getIcon() { @@ -569,6 +574,12 @@ public class ConversationData extends BindableData { final MessageData message) { Assert.isTrue(TextUtils.equals(mConversationId, message.getConversationId())); Assert.isTrue(binding.getData() == this); + if (getIsTrashed()) { + return; + } + if (getIsArchived()) { + unarchiveConversation(binding); + } if (message.getSelfId() == null) { InsertNewMessageAction.insertNewMessage(message); @@ -612,6 +623,12 @@ public class ConversationData extends BindableData { public void resendMessage(final BindingBase binding, final String messageId) { Assert.isTrue(binding.getData() == this); Assert.notNull(messageId); + if (getIsTrashed()) { + return; + } + if (getIsArchived()) { + unarchiveConversation(binding); + } ResendMessageAction.resendMessage(messageId); } @@ -642,6 +659,16 @@ public class ConversationData extends BindableData { UpdateConversationArchiveStatusAction.unarchiveConversation(mConversationId); } + public void moveConversationToTrash(final BindingBase binding) { + Assert.isTrue(binding.getData() == this); + UpdateConversationTrashStatusAction.moveToTrash(mConversationId); + } + + public void restoreConversationFromTrash(final BindingBase binding) { + Assert.isTrue(binding.getData() == this); + UpdateConversationTrashStatusAction.restoreFromTrash(mConversationId); + } + public ConversationParticipantsData getParticipants() { return mParticipantData; } diff --git a/app/src/main/java/com/android/messaging/datamodel/data/ConversationListData.java b/app/src/main/java/com/android/messaging/datamodel/data/ConversationListData.java index c33ebf8..d499dd3 100644 --- a/app/src/main/java/com/android/messaging/datamodel/data/ConversationListData.java +++ b/app/src/main/java/com/android/messaging/datamodel/data/ConversationListData.java @@ -52,6 +52,12 @@ public class ConversationListData extends BindableData "(" + ConversationListViewColumns.ARCHIVE_STATUS + " = 1)"; public static final String WHERE_NOT_ARCHIVED = "(" + ConversationListViewColumns.ARCHIVE_STATUS + " = 0)"; + public static final String WHERE_NOT_TRASHED = + "(" + ConversationListViewColumns.TRASH_TIMESTAMP + " = 0)"; + public static final String WHERE_TRASHED = + "(" + ConversationListViewColumns.TRASH_TIMESTAMP + " > 0)"; + public static final String WHERE_INBOX = WHERE_NOT_ARCHIVED + " AND " + WHERE_NOT_TRASHED; + public static final String WHERE_ARCHIVED_VISIBLE = WHERE_ARCHIVED + " AND " + WHERE_NOT_TRASHED; public interface ConversationListDataListener { void onConversationListCursorUpdated(ConversationListData data, Cursor cursor); @@ -61,13 +67,20 @@ public class ConversationListData extends BindableData private ConversationListDataListener mListener; private final Context mContext; private final boolean mArchivedMode; + private final boolean mTrashMode; private LoaderManager mLoaderManager; public ConversationListData(final Context context, final ConversationListDataListener listener, final boolean archivedMode) { + this(context, listener, archivedMode, false); + } + + public ConversationListData(final Context context, final ConversationListDataListener listener, + final boolean archivedMode, final boolean trashMode) { mListener = listener; mContext = context; - mArchivedMode = archivedMode; + mTrashMode = trashMode; + mArchivedMode = archivedMode && !trashMode; } private static final int CONVERSATION_LIST_LOADER = 1; @@ -101,7 +114,7 @@ public class ConversationListData extends BindableData loader = new BoundCursorLoader(bindingId, mContext, MessagingContentProvider.CONVERSATIONS_URI, ConversationListItemData.PROJECTION, - mArchivedMode ? WHERE_ARCHIVED : WHERE_NOT_ARCHIVED, + listSelection(), null, // selection args SORT_ORDER); break; @@ -212,4 +225,18 @@ public class ConversationListData extends BindableData public HashSet getBlockedParticipants() { return mBlockedParticipants; } + + public boolean isTrashMode() { + return mTrashMode; + } + + private String listSelection() { + if (mTrashMode) { + return WHERE_TRASHED; + } + if (mArchivedMode) { + return WHERE_ARCHIVED_VISIBLE; + } + return WHERE_INBOX; + } } diff --git a/app/src/main/java/com/android/messaging/datamodel/data/ConversationListItemData.java b/app/src/main/java/com/android/messaging/datamodel/data/ConversationListItemData.java index 73cfbd2..c7ae40b 100644 --- a/app/src/main/java/com/android/messaging/datamodel/data/ConversationListItemData.java +++ b/app/src/main/java/com/android/messaging/datamodel/data/ConversationListItemData.java @@ -28,6 +28,7 @@ import com.android.messaging.datamodel.DatabaseHelper.MessageColumns; import com.android.messaging.datamodel.DatabaseHelper.ParticipantColumns; import com.android.messaging.datamodel.DatabaseWrapper; import com.android.messaging.datamodel.action.DeleteConversationAction; +import com.android.messaging.datamodel.action.UpdateConversationTrashStatusAction; import com.android.messaging.util.Assert; import com.android.messaging.util.ContactUtil; import com.android.messaging.util.Dates; @@ -61,6 +62,7 @@ public class ConversationListItemData { private String mDraftPreviewContentType; private String mDraftSnippetText; private boolean mIsArchived; + private long mTrashTimestamp; private String mSubject; private String mDraftSubject; private String mSnippetSenderFirstName; @@ -110,6 +112,8 @@ public class ConversationListItemData { } mIsArchived = cursor.getInt(INDEX_ARCHIVE_STATUS) == 1; + final int trashIndex = cursor.getColumnIndex(ConversationListViewColumns.TRASH_TIMESTAMP); + mTrashTimestamp = trashIndex >= 0 ? cursor.getLong(trashIndex) : 0; mSubject = cursor.getString(INDEX_SUBJECT_TEXT); mSnippetSenderFirstName = cursor.getString(INDEX_SNIPPET_SENDER_FIRST_NAME); mSnippetSenderDisplayDestination = @@ -240,6 +244,14 @@ public class ConversationListItemData { return mIsArchived; } + public boolean getIsTrashed() { + return mTrashTimestamp > 0; + } + + public long getTrashTimestamp() { + return mTrashTimestamp; + } + public String getSubject() { return mSubject; } @@ -256,7 +268,11 @@ public class ConversationListItemData { } public void deleteConversation() { - DeleteConversationAction.deleteConversation(mConversationId, mTimestamp); + if (getIsTrashed()) { + DeleteConversationAction.deleteConversation(mConversationId, mTimestamp); + } else { + UpdateConversationTrashStatusAction.moveToTrash(mConversationId); + } } /** @@ -281,6 +297,8 @@ public class ConversationListItemData { + " as " + ConversationListViewColumns.CURRENT_SELF_ID + ", " + DatabaseHelper.CONVERSATIONS_TABLE + '.' + ConversationColumns.ARCHIVE_STATUS + " as " + ConversationListViewColumns.ARCHIVE_STATUS + ", " + + DatabaseHelper.CONVERSATIONS_TABLE + '.' + ConversationColumns.TRASH_TIMESTAMP + + " as " + ConversationListViewColumns.TRASH_TIMESTAMP + ", " + DatabaseHelper.MESSAGES_TABLE + '.' + MessageColumns.READ + " as " + ConversationListViewColumns.READ + ", " + DatabaseHelper.CONVERSATIONS_TABLE + '.' + ConversationColumns.ICON @@ -354,6 +372,7 @@ public class ConversationListItemData { public static final String _ID = ConversationColumns._ID; static final String NAME = ConversationColumns.NAME; static final String ARCHIVE_STATUS = ConversationColumns.ARCHIVE_STATUS; + static final String TRASH_TIMESTAMP = ConversationColumns.TRASH_TIMESTAMP; static final String READ = MessageColumns.READ; static final String SORT_TIMESTAMP = ConversationColumns.SORT_TIMESTAMP; static final String PREVIEW_URI = ConversationColumns.PREVIEW_URI; @@ -412,6 +431,7 @@ public class ConversationListItemData { ConversationListViewColumns.SNIPPET_SENDER_FIRST_NAME, ConversationListViewColumns.SNIPPET_SENDER_DISPLAY_DESTINATION, ConversationListViewColumns.IS_ENTERPRISE, + ConversationListViewColumns.TRASH_TIMESTAMP, }; private static final int INDEX_ID = 0; @@ -441,6 +461,7 @@ public class ConversationListItemData { private static final int INDEX_SNIPPET_SENDER_FIRST_NAME = 24; private static final int INDEX_SNIPPET_SENDER_DISPLAY_DESTINATION = 25; private static final int INDEX_IS_ENTERPRISE = 26; + private static final int INDEX_TRASH_TIMESTAMP = 27; private static final String DIVIDER_TEXT = ", "; diff --git a/app/src/main/java/com/android/messaging/datamodel/data/ParticipantListItemData.java b/app/src/main/java/com/android/messaging/datamodel/data/ParticipantListItemData.java index f6c9b5f..9a94607 100644 --- a/app/src/main/java/com/android/messaging/datamodel/data/ParticipantListItemData.java +++ b/app/src/main/java/com/android/messaging/datamodel/data/ParticipantListItemData.java @@ -87,6 +87,11 @@ public class ParticipantListItemData extends PersonItemData { return mNormalizedDestination; } + public void unblockQuietly(final Context context) { + UpdateDestinationBlockedAction.updateDestinationBlocked( + mNormalizedDestination, false, null, (action, success, block, destination) -> { }); + } + public void unblock(final Context context) { UpdateDestinationBlockedAction.updateDestinationBlocked( mNormalizedDestination, false, null, diff --git a/app/src/main/java/com/android/messaging/ui/BugleActionBarActivity.java b/app/src/main/java/com/android/messaging/ui/BugleActionBarActivity.java index 1afe232..75fd06f 100644 --- a/app/src/main/java/com/android/messaging/ui/BugleActionBarActivity.java +++ b/app/src/main/java/com/android/messaging/ui/BugleActionBarActivity.java @@ -346,6 +346,9 @@ public class BugleActionBarActivity extends AppCompatActivity implements ImeUtil } public void updateActionBar(final ActionBar actionBar) { + if (actionBar == null) { + return; + } if (!BugleActionBarActivity.this.shouldShowContextualActionBar(mCallback)) { actionBar.hide(); return; diff --git a/app/src/main/java/com/android/messaging/ui/UIIntents.java b/app/src/main/java/com/android/messaging/ui/UIIntents.java index c270efc..4c079d4 100644 --- a/app/src/main/java/com/android/messaging/ui/UIIntents.java +++ b/app/src/main/java/com/android/messaging/ui/UIIntents.java @@ -30,6 +30,7 @@ import androidx.activity.result.ActivityResultLauncher; import androidx.fragment.app.Fragment; import com.android.messaging.Factory; +import com.android.messaging.datamodel.data.ConversationMessageData; import com.android.messaging.datamodel.data.MessageData; import com.android.messaging.util.ConversationIdSet; @@ -183,6 +184,12 @@ public abstract class UIIntents { public abstract void launchHomeConversationSearchActivity(final Context context, final String conversationId); + /** + * Launch home UI for a single message's details. + */ + public abstract void launchHomeMessageDetailsActivity(final Context context, + final ConversationMessageData data); + /** * Launch an external activity to handle a phone call * @param phoneNumber the phone number to call @@ -196,6 +203,11 @@ public abstract class UIIntents { */ public abstract void launchArchivedConversationsActivity(final Context context); + /** + * Launch an activity to show conversations in Trash. + */ + public abstract void launchRecentlyDeletedConversationsActivity(final Context context); + /** * Launch an activity to show blocked participants. */ diff --git a/app/src/main/java/com/android/messaging/ui/UIIntentsImpl.java b/app/src/main/java/com/android/messaging/ui/UIIntentsImpl.java index a72ad3a..79b8d3c 100644 --- a/app/src/main/java/com/android/messaging/ui/UIIntentsImpl.java +++ b/app/src/main/java/com/android/messaging/ui/UIIntentsImpl.java @@ -45,6 +45,7 @@ import com.android.messaging.R; import com.android.messaging.datamodel.ConversationImagePartsView; import com.android.messaging.datamodel.MediaScratchFileProvider; import com.android.messaging.datamodel.MessagingContentProvider; +import com.android.messaging.datamodel.data.ConversationMessageData; import com.android.messaging.datamodel.data.MessageData; import com.android.messaging.datamodel.data.MessagePartData; import com.android.messaging.datamodel.data.ParticipantData; @@ -56,8 +57,10 @@ import com.android.messaging.ui.appsettings.SettingsActivity; import com.android.messaging.ui.conversation.ConversationActivity; import com.android.messaging.ui.conversation.LaunchConversationActivity; import com.android.messaging.ui.conversationlist.ArchivedConversationListActivity; +import com.android.messaging.ui.conversationlist.RecentlyDeletedConversationListActivity; import com.android.messaging.ui.conversationlist.ConversationListActivity; import com.android.messaging.ui.conversationlist.ForwardMessageActivity; +import com.android.messaging.ui.home.HomeMessageDetailsActivity; import com.android.messaging.ui.photoviewer.BuglePhotoViewActivity; import com.android.messaging.util.Assert; import com.android.messaging.util.ConversationIdSet; @@ -208,6 +211,12 @@ public class UIIntentsImpl extends UIIntents { context.startActivity(intent); } + @Override + public void launchRecentlyDeletedConversationsActivity(final Context context) { + final Intent intent = new Intent(context, RecentlyDeletedConversationListActivity.class); + context.startActivity(intent); + } + @Override public void launchBlockedParticipantsActivity(final Context context) { final Intent intent = new Intent(context, BlockedParticipantsActivity.class); @@ -280,6 +289,16 @@ public class UIIntentsImpl extends UIIntents { context.startActivity(intent); } + @Override + public void launchHomeMessageDetailsActivity(final Context context, + final ConversationMessageData data) { + HomeMessageDetailsActivity.start(context, data); + if (context instanceof Activity) { + ((Activity) context).overridePendingTransition( + R.anim.home_activity_open, R.anim.home_activity_hold); + } + } + @Override public void launchPhoneCallActivity(final Context context, final String phoneNumber, final Point clickPosition) { diff --git a/app/src/main/java/com/android/messaging/ui/conversation/ConversationFragment.java b/app/src/main/java/com/android/messaging/ui/conversation/ConversationFragment.java index 92632d8..f6009e8 100644 --- a/app/src/main/java/com/android/messaging/ui/conversation/ConversationFragment.java +++ b/app/src/main/java/com/android/messaging/ui/conversation/ConversationFragment.java @@ -115,6 +115,8 @@ import com.android.messaging.ui.home.HomeBottomSheet; import com.android.messaging.ui.home.HomeComposeAttachPanel; import com.android.messaging.ui.home.HomeEmptyState; import com.android.messaging.ui.home.HomeGallerySheet; +import com.android.messaging.ui.home.HomeModal; +import com.android.messaging.ui.home.HomeToast; import com.android.messaging.ui.home.HomeUiMotion; import com.android.messaging.ui.home.StarredMessagesStore; import com.android.messaging.ui.home.UnknownSenderPromptStore; @@ -193,6 +195,7 @@ public class ConversationFragment extends Fragment implements ConversationDataLi private TextView mMessageSelectionTitle; private View mComposeContainer; private View mComposeStage; + private View mTrashBar; private View mMessageActionBar; private View mMessageActionCopy; private View mMessageActionForward; @@ -354,7 +357,8 @@ public class ConversationFragment extends Fragment implements ConversationDataLi result -> { if (result.getResultCode() == Activity.RESULT_OK && result.getData() != null - && mComposeMessageView != null) { + && mComposeMessageView != null + && !isConversationTrashed()) { mComposeMessageView.applySharedLocation(result.getData()); } }); @@ -371,7 +375,7 @@ public class ConversationFragment extends Fragment implements ConversationDataLi public void onCreate(final Bundle savedInstanceState) { super.onCreate(savedInstanceState); mGalleryPicker = new DocumentImagePicker(this, pendingItem -> { - if (mComposeMessageView != null) { + if (mComposeMessageView != null && !isConversationTrashed()) { mComposeMessageView.onPendingAttachmentAdded(pendingItem); } }); @@ -467,6 +471,7 @@ public class ConversationFragment extends Fragment implements ConversationDataLi mEmptyContainer = view.findViewById(R.id.home_conversation_empty); setupConversationHeader(view); setupUnknownSenderBanner(view); + setupTrashComposeBar(view); setupMessageSelectionChrome(view); bindConversationEdgeFade(view); final LinearLayoutManager manager = new LinearLayoutManager(getActivity()); @@ -673,7 +678,6 @@ public class ConversationFragment extends Fragment implements ConversationDataLi final View copy = content.findViewById(R.id.home_message_menu_copy); final View star = content.findViewById(R.id.home_message_menu_star); final TextView starLabel = content.findViewById(R.id.home_message_menu_star_label); - final View delete = content.findViewById(R.id.home_message_menu_delete); final View selectMore = content.findViewById(R.id.home_message_menu_select_more); final View details = content.findViewById(R.id.home_message_menu_details); @@ -699,18 +703,16 @@ public class ConversationFragment extends Fragment implements ConversationDataLi dismissMessageActionPopup(); dismissMessageSelection(); }); - delete.setOnClickListener(v -> { - dismissMessageActionPopup(); - performSelectedMessageAction(R.id.home_message_action_delete); - }); selectMore.setOnClickListener(v -> { mClearSelectionOnPopupDismiss = false; dismissMessageActionPopup(); selectMessage(messageView, mSelectedAttachment, false /* showPopup */); }); details.setOnClickListener(v -> { + mClearSelectionOnPopupDismiss = false; dismissMessageActionPopup(); - performSelectedMessageAction(R.id.home_message_action_details); + UIIntents.get().launchHomeMessageDetailsActivity(getActivity(), data); + dismissMessageSelection(); }); content.setLayoutParams(new ViewGroup.LayoutParams( @@ -964,7 +966,7 @@ public class ConversationFragment extends Fragment implements ConversationDataLi // On resume, check if there's a pending request for resuming message compose. This // may happen when the user commits the contact selection for a group conversation and // goes from compose back to the conversation fragment. - if (mHost.shouldResumeComposeMessage()) { + if (!isConversationTrashed() && mHost.shouldResumeComposeMessage()) { mComposeMessageView.resumeComposeMessage(); } @@ -974,10 +976,8 @@ public class ConversationFragment extends Fragment implements ConversationDataLi if (mConversationHeader != null) { mConversationHeader.setVisibility(View.VISIBLE); } - if (mComposeContainer != null) { - mComposeContainer.setVisibility(View.VISIBLE); - } } + updateTrashComposeLock(); if (mHost != null) { mHost.invalidateActionBar(); } @@ -1030,9 +1030,10 @@ public class ConversationFragment extends Fragment implements ConversationDataLi menu.findItem(R.id.action_add_contact).setVisible(addContactActionVisible); // See if we should show archive or unarchive. + final boolean isTrashed = data.getIsTrashed(); final boolean isArchived = data.getIsArchived(); - menu.findItem(R.id.action_archive).setVisible(!isArchived); - menu.findItem(R.id.action_unarchive).setVisible(isArchived); + menu.findItem(R.id.action_archive).setVisible(!isArchived && !isTrashed); + menu.findItem(R.id.action_unarchive).setVisible(isArchived && !isTrashed); // Call lives on the header pill; keep it out of the overflow. menu.findItem(R.id.action_call).setVisible(false); @@ -1069,18 +1070,7 @@ public class ConversationFragment extends Fragment implements ConversationDataLi (new AddContactsConfirmationDialog(getActivity(), avatarUri, destination)).show(); return true; } else if (itemId == R.id.action_delete) { - if (isReadyForDeleteAction()) { - new AlertDialog.Builder(getActivity(), R.style.AlertDialogTheme) - .setTitle(getResources().getQuantityString( - R.plurals.delete_conversations_confirmation_dialog_title, 1)) - .setPositiveButton(R.string.delete_conversation_confirmation_button, - (dialog, button) -> deleteConversation()) - .setNegativeButton(R.string.delete_conversation_decline_button, null) - .show(); - } else { - warnOfMissingActionConditions(false /*sending*/, - null /*commandToRunAfterActionConditionResolved*/); - } + confirmDeleteConversation(); return true; } return super.onOptionsItemSelected(item); @@ -1196,6 +1186,7 @@ public class ConversationFragment extends Fragment implements ConversationDataLi mAdapter.notifyDataSetChanged(); updateHomeEmptyState(); updateUnknownSenderBanner(); + updateTrashComposeLock(); mHost.invalidateActionBar(); } @@ -1280,6 +1271,9 @@ public class ConversationFragment extends Fragment implements ConversationDataLi @Override public void sendMessage(final MessageData message) { + if (isConversationTrashed()) { + return; + } if (isReadyForAction()) { if (ensureKnownRecipients()) { // Merge the caption text from attachments into the text body of the messages @@ -1381,6 +1375,9 @@ public class ConversationFragment extends Fragment implements ConversationDataLi } public void retrySend(final String messageId) { + if (isConversationTrashed()) { + return; + } if (isReadyForAction()) { if (ensureKnownRecipients()) { showSendingStatus(messageId); @@ -1427,8 +1424,14 @@ public class ConversationFragment extends Fragment implements ConversationDataLi public void deleteConversation() { if (isReadyForDeleteAction()) { - final Context context = getActivity(); - mBinding.getData().deleteConversation(mBinding); + if (mBinding.getData().getIsTrashed()) { + mBinding.getData().deleteConversation(mBinding); + } else { + mBinding.getData().moveConversationToTrash(mBinding); + if (getActivity() != null) { + HomeToast.show(getActivity(), R.string.home_trash_moved_toast_one); + } + } closeConversation(mConversationId); } else { warnOfMissingActionConditions(false /*sending*/, @@ -1458,6 +1461,7 @@ public class ConversationFragment extends Fragment implements ConversationDataLi mRecyclerView.setVisibility(View.VISIBLE); updateUnknownSenderBanner(); + updateTrashComposeLock(); } } @@ -1934,6 +1938,34 @@ public class ConversationFragment extends Fragment implements ConversationDataLi .show(); } + private void confirmReportConversationSpam() { + if (getActivity() == null || !mBinding.isBound()) { + return; + } + final ParticipantData other = mBinding.getData().getOtherParticipant(); + if (other == null) { + HomeToast.show(getActivity(), R.string.home_report_spam_groups_only); + return; + } + HomeModal.builder(getActivity()) + .icon(R.drawable.ic_home_menu_spam) + .title(R.string.home_report_spam_title) + .message(R.string.home_report_spam_message_one) + .negative(android.R.string.cancel, null) + .positive(R.string.home_unknown_sender_report_spam, () -> { + if (!mBinding.isBound()) { + return; + } + UpdateDestinationBlockedAction.updateDestinationBlocked( + other.getNormalizedDestination(), true, mConversationId, + BugleActionToasts.makeUpdateDestinationBlockedActionListener( + getActivity())); + HomeToast.show(getActivity(), R.string.home_report_spam_toast_one); + closeConversation(mConversationId); + }) + .show(); + } + private void updateUnknownSenderBanner() { if (mUnknownSenderBanner == null || !mBinding.isBound()) { return; @@ -1962,6 +1994,54 @@ public class ConversationFragment extends Fragment implements ConversationDataLi HomeUiMotion.unknownSenderBannerEnter(mUnknownSenderBanner); } + private void setupTrashComposeBar(final View root) { + mTrashBar = root.findViewById(R.id.home_conversation_trash_bar); + final View restore = root.findViewById(R.id.home_conversation_trash_restore); + if (restore != null) { + restore.setOnClickListener(v -> restoreConversationFromTrash()); + } + } + + private boolean isConversationTrashed() { + return mBinding.isBound() && mBinding.getData().getIsTrashed(); + } + + private void restoreConversationFromTrash() { + if (!isConversationTrashed()) { + return; + } + mBinding.getData().restoreConversationFromTrash(mBinding); + if (getActivity() != null) { + HomeToast.show(getActivity(), R.string.home_trash_restored_toast); + } + } + + private void updateTrashComposeLock() { + final boolean trashed = isConversationTrashed() && mSelectedMessage == null; + if (mTrashBar != null) { + mTrashBar.setVisibility(trashed ? View.VISIBLE : View.GONE); + } + if (mComposeContainer != null) { + mComposeContainer.setVisibility(trashed ? View.GONE : View.VISIBLE); + } + if (trashed) { + HomeComposeAttachPanel.dismissIfShowing(getActivity()); + HomeGallerySheet.dismissIfShowing(getActivity()); + if (mComposeMessageView != null) { + mComposeMessageView.hideAllComposeInputs(false /* animate */); + if (getActivity() != null) { + ImeUtil.get().hideImeKeyboard(getActivity(), mComposeMessageView); + } + } + } + if (getView() != null) { + getView().post(() -> applyConversationEdgeFade( + getView().findViewById(R.id.conversation_fade_top), + getView().findViewById(R.id.conversation_fade_bottom), + getResources().getDimensionPixelSize(R.dimen.home_conversation_edge_fade))); + } + } + private void hideUnknownSenderBanner(final boolean animate) { if (mUnknownSenderBanner == null) { return; @@ -2205,6 +2285,11 @@ public class ConversationFragment extends Fragment implements ConversationDataLi getResources().getDimensionPixelSize(R.dimen.home_conversation_edge_fade))); } HomeUiMotion.swapBottomDock(mComposeContainer, mMessageActionBar, active, null); + if (!active) { + updateTrashComposeLock(); + } else if (mTrashBar != null) { + mTrashBar.setVisibility(View.GONE); + } } private void refreshMessageActionButtons() { @@ -2218,7 +2303,8 @@ public class ConversationFragment extends Fragment implements ConversationDataLi setMessageActionVisible(mMessageActionShare, canForward); setMessageActionVisible(mMessageActionDetails, true); setMessageActionVisible(mMessageActionSave, mSelectedAttachment != null); - setMessageActionVisible(mMessageActionSend, data.getShowResendMessage()); + setMessageActionVisible(mMessageActionSend, + data.getShowResendMessage() && !isConversationTrashed()); setMessageActionVisible(mMessageActionDownload, data.getShowDownloadMessage()); setMessageActionVisible(mMessageActionDelete, true); } @@ -2261,9 +2347,7 @@ public class ConversationFragment extends Fragment implements ConversationDataLi ClipData.newPlainText(null /* label */, data.getText())); dismissMessageSelection(); } else if (actionId == R.id.home_message_action_details) { - MessageDetailsDialog.show( - getActivity(), data, mBinding.getData().getParticipants(), - mBinding.getData().getSelfParticipantById(data.getSelfParticipantId())); + UIIntents.get().launchHomeMessageDetailsActivity(getActivity(), data); dismissMessageSelection(); } else if (actionId == R.id.home_message_action_share) { shareSelectedMessage(data); @@ -2349,20 +2433,37 @@ public class ConversationFragment extends Fragment implements ConversationDataLi UIIntents.get().launchHomeConversationSearchActivity(getActivity(), mConversationId); }); + final boolean trashed = data.getIsTrashed(); final boolean archived = data.getIsArchived(); final TextView archiveLabel = content.findViewById(R.id.popover_overflow_archive_label); - archiveLabel.setText(archived - ? R.string.home_profile_option_unarchive - : R.string.home_profile_option_archive); + archiveLabel.setText(trashed + ? R.string.home_archive_restore + : (archived + ? R.string.home_profile_option_unarchive + : R.string.home_profile_option_archive)); content.findViewById(R.id.popover_overflow_archive).setOnClickListener(v -> { dismissConversationOverflowPopover(); - if (archived) { + if (trashed) { + mBinding.getData().restoreConversationFromTrash(mBinding); + if (getActivity() != null) { + HomeToast.show(getActivity(), R.string.home_trash_restored_toast); + } + } else if (archived) { mBinding.getData().unarchiveConversation(mBinding); } else { mBinding.getData().archiveConversation(mBinding); closeConversation(mConversationId); } }); + final View spamRow = content.findViewById(R.id.popover_overflow_spam); + if (spamRow != null) { + final boolean canReportSpam = !trashed && participant != null; + spamRow.setVisibility(canReportSpam ? View.VISIBLE : View.GONE); + spamRow.setOnClickListener(v -> { + dismissConversationOverflowPopover(); + confirmReportConversationSpam(); + }); + } content.findViewById(R.id.popover_overflow_delete).setOnClickListener(v -> { dismissConversationOverflowPopover(); confirmDeleteConversation(); @@ -2371,8 +2472,9 @@ public class ConversationFragment extends Fragment implements ConversationDataLi content.measure( View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED), View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)); + final int width = content.getMeasuredWidth(); final PopupWindow popup = new PopupWindow(content, - content.getMeasuredWidth(), + width, ViewGroup.LayoutParams.WRAP_CONTENT, true); popup.setOutsideTouchable(true); @@ -2396,7 +2498,7 @@ public class ConversationFragment extends Fragment implements ConversationDataLi } else { windowRight = getResources().getDisplayMetrics().widthPixels; } - final int desiredLeft = windowRight - endInset - content.getMeasuredWidth(); + final int desiredLeft = windowRight - endInset - width; final int xOff = desiredLeft - anchorLoc[0]; popup.showAsDropDown(anchor, xOff, 8); HomeUiMotion.popoverEnter(content, true); @@ -2428,16 +2530,22 @@ public class ConversationFragment extends Fragment implements ConversationDataLi } private void confirmDeleteConversation() { - if (getActivity() == null) { + if (getActivity() == null || !mBinding.isBound()) { return; } if (isReadyForDeleteAction()) { - new AlertDialog.Builder(getActivity(), R.style.Home_Dialog) - .setTitle(getResources().getQuantityString( - R.plurals.delete_conversations_confirmation_dialog_title, 1)) - .setPositiveButton(R.string.delete_conversation_confirmation_button, - (dialog, button) -> deleteConversation()) - .setNegativeButton(R.string.delete_conversation_decline_button, null) + final boolean trashed = mBinding.getData().getIsTrashed(); + HomeModal.builder(getActivity()) + .icon(R.drawable.ic_home_menu_trash) + .title(trashed + ? getString(R.string.home_trash_permanent_title) + : getResources().getQuantityString( + R.plurals.delete_conversations_confirmation_dialog_title, 1)) + .message(trashed + ? getString(R.string.home_trash_permanent_message_one) + : getString(R.string.home_delete_alert_message_one)) + .negative(R.string.delete_conversation_decline_button, null) + .positive(R.string.home_delete_confirm, this::deleteConversation) .show(); } else { warnOfMissingActionConditions(false /*sending*/, diff --git a/app/src/main/java/com/android/messaging/ui/conversationlist/AbstractConversationListActivity.java b/app/src/main/java/com/android/messaging/ui/conversationlist/AbstractConversationListActivity.java index 17d315a..ae8a2ce 100644 --- a/app/src/main/java/com/android/messaging/ui/conversationlist/AbstractConversationListActivity.java +++ b/app/src/main/java/com/android/messaging/ui/conversationlist/AbstractConversationListActivity.java @@ -25,6 +25,7 @@ import android.content.Intent; import android.content.res.Resources; import android.net.Uri; import android.os.Bundle; +import android.view.ActionMode; import android.view.View; import androidx.activity.result.ActivityResultLauncher; import androidx.activity.result.contract.ActivityResultContracts; @@ -35,6 +36,7 @@ import androidx.appcompat.app.ActionBar; 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.UpdateConversationTrashStatusAction; import com.android.messaging.datamodel.action.UpdateDestinationBlockedAction; import com.android.messaging.datamodel.data.ConversationListData; import com.android.messaging.datamodel.data.ConversationListItemData; @@ -45,6 +47,8 @@ import com.android.messaging.datamodel.action.MarkAsUnreadAction; import com.android.messaging.ui.UIIntents; import com.android.messaging.ui.contact.AddContactsConfirmationDialog; import com.android.messaging.ui.home.HomeDeleteAlertDialog; +import com.android.messaging.ui.home.HomeInboxMenu; +import com.android.messaging.ui.home.HomeModal; import com.android.messaging.ui.home.HomeToast; import com.android.messaging.ui.conversationlist.ConversationListFragment.ConversationListFragmentHost; import com.android.messaging.ui.conversationlist.MultiSelectActionModeCallback.SelectedConversation; @@ -96,7 +100,123 @@ public abstract class AbstractConversationListActivity extends BugleActionBarAct return false; } + protected boolean isTrashList() { + return false; + } + + @Override + protected boolean shouldShowContextualActionBar(final ActionMode.Callback callback) { + if (useHomeSelectionChrome() && callback instanceof MultiSelectActionModeCallback) { + return !((MultiSelectActionModeCallback) callback).useHomeSelectionChrome(); + } + return super.shouldShowContextualActionBar(callback); + } + + public void enterHomeEditMode() { + if (!hasVisibleConversations()) { + HomeToast.show(this, R.string.home_toast_nothing_to_select); + return; + } + if (!isInConversationListSelectMode()) { + startMultiSelectActionMode(); + } + } + + private boolean hasVisibleConversations() { + return mConversationListFragment != null + && mConversationListFragment.getVisibleConversationCount() > 0; + } + + public void onSubListSelectionMode(final boolean enabled) { + onSubListSelectionCount(0); + HomeInboxMenu.setSelecting(this, enabled); + } + + public void onSubListSelectionCount(final int count) { + final int visible = mConversationListFragment == null + ? 0 : mConversationListFragment.getVisibleConversationCount(); + final boolean hasWork = count > 0 || visible > 0; + if (isTrashList()) { + HomeInboxMenu.setSelectAction(this, + count > 0 ? R.drawable.ic_delete_small_dark : R.drawable.ic_home_menu_trash, + count > 0 ? R.string.home_delete_confirm : R.string.home_empty_trash_action, + hasWork, true); + } else { + HomeInboxMenu.setSelectAction(this, R.drawable.ic_home_menu_archive, + count > 0 ? R.string.home_sub_unarchive : R.string.home_sub_unarchive_all, + hasWork, false); + } + } + + public void onSubListPrimaryAction() { + if (isTrashList()) { + emptyTrashOrDeleteSelected(); + } else { + unarchiveAllOrSelected(); + } + } + + private void unarchiveAllOrSelected() { + final Collection selected = getSelectedConversationsIfAny(); + if (selected != null && !selected.isEmpty()) { + restoreSelectedConversations(); + return; + } + if (mConversationListFragment == null) { + return; + } + final List items = + mConversationListFragment.snapshotVisibleConversations(); + if (items.isEmpty()) { + HomeToast.show(this, R.string.home_toast_nothing_to_unarchive); + return; + } + for (final ConversationListItemData item : items) { + UpdateConversationArchiveStatusAction.unarchiveConversation(item.getConversationId()); + } + if (items.size() == 1) { + HomeToast.show(this, R.string.home_profile_unarchived_toast); + } else { + HomeToast.show(this, getString(R.string.home_sub_unarchived_toast_many, items.size())); + } + exitMultiSelectState(); + } + + private void emptyTrashOrDeleteSelected() { + final Collection selected = getSelectedConversationsIfAny(); + if (selected != null && !selected.isEmpty()) { + confirmPermanentDelete(selected); + return; + } + if (mConversationListFragment == null) { + return; + } + final List items = + mConversationListFragment.snapshotVisibleConversations(); + if (items.isEmpty()) { + HomeToast.show(this, R.string.home_toast_nothing_to_empty); + return; + } + final ArrayList all = new ArrayList<>(items.size()); + for (final ConversationListItemData item : items) { + all.add(new SelectedConversation(item)); + } + confirmEmptyAll(all); + } + + private Collection getSelectedConversationsIfAny() { + if (!isInConversationListSelectMode()) { + return null; + } + final MultiSelectActionModeCallback callback = + (MultiSelectActionModeCallback) getActionModeCallback(); + return callback.getSelectedConversations(); + } + protected void startMultiSelectActionMode() { + if (mConversationListFragment == null) { + return; + } final boolean homeChrome = useHomeSelectionChrome(); if (homeChrome) { hideHomeContextualActionBar(); @@ -112,6 +232,9 @@ public abstract class AbstractConversationListActivity extends BugleActionBarAct public void exitMultiSelectState() { dismissActionMode(); + if (mConversationListFragment == null) { + return; + } if (useHomeSelectionChrome()) { mConversationListFragment.setHomeSelectionMode(false); } else { @@ -138,6 +261,10 @@ public abstract class AbstractConversationListActivity extends BugleActionBarAct if (conversations.isEmpty()) { return; } + if (isTrashList()) { + confirmPermanentDelete(conversations); + return; + } if (useHomeSelectionChrome()) { showHomeDeleteAlert(conversations); } else { @@ -149,8 +276,13 @@ public abstract class AbstractConversationListActivity extends BugleActionBarAct if (data == null) { return; } - showHomeDeleteAlert(java.util.Collections.singletonList( - new MultiSelectActionModeCallback.SelectedConversation(data))); + final java.util.List one = java.util.Collections.singletonList( + new MultiSelectActionModeCallback.SelectedConversation(data)); + if (isTrashList()) { + confirmPermanentDelete(one); + return; + } + showHomeDeleteAlert(one); } public void markSelectedConversationsUnread() { @@ -166,6 +298,109 @@ public abstract class AbstractConversationListActivity extends BugleActionBarAct exitMultiSelectState(); } + public void restoreSelectedConversations() { + if (!isInConversationListSelectMode()) { + return; + } + final MultiSelectActionModeCallback callback = + (MultiSelectActionModeCallback) getActionModeCallback(); + final Collection conversations = + callback.getSelectedConversations(); + if (conversations.isEmpty()) { + return; + } + for (final SelectedConversation conversation : conversations) { + if (isTrashList()) { + UpdateConversationTrashStatusAction.restoreFromTrash(conversation.conversationId); + } else { + UpdateConversationArchiveStatusAction.unarchiveConversation( + conversation.conversationId); + } + } + HomeToast.show(this, isTrashList() + ? R.string.home_trash_restored_toast + : R.string.home_profile_unarchived_toast); + exitMultiSelectState(); + } + + public void archiveSelectedConversations() { + if (!isInConversationListSelectMode()) { + return; + } + final MultiSelectActionModeCallback callback = + (MultiSelectActionModeCallback) getActionModeCallback(); + final Collection conversations = + callback.getSelectedConversations(); + if (conversations.isEmpty()) { + return; + } + int count = 0; + for (final SelectedConversation conversation : conversations) { + UpdateConversationArchiveStatusAction.archiveConversation( + conversation.conversationId); + count++; + } + if (count == 1) { + HomeToast.show(this, R.string.home_profile_archived_toast); + } else { + HomeToast.show(this, getString(R.string.home_selection_archived_toast_many, count)); + } + exitMultiSelectState(); + } + + public void reportSelectedConversationsSpam() { + if (!isInConversationListSelectMode()) { + return; + } + final MultiSelectActionModeCallback callback = + (MultiSelectActionModeCallback) getActionModeCallback(); + final Collection conversations = new ArrayList<>( + callback.getSelectedConversations()); + int reportableCount = 0; + for (final SelectedConversation conversation : conversations) { + if (!conversation.isGroup + && conversation.otherParticipantNormalizedDestination != null) { + reportableCount++; + } + } + if (reportableCount == 0) { + HomeToast.show(this, R.string.home_report_spam_groups_only); + return; + } + final int reportable = reportableCount; + final String message = reportable == 1 + ? getString(R.string.home_report_spam_message_one) + : getString(R.string.home_report_spam_message_many, reportable); + HomeModal.builder(this) + .icon(R.drawable.ic_home_menu_spam) + .title(R.string.home_report_spam_title) + .message(message) + .negative(android.R.string.cancel, null) + .positive(R.string.home_unknown_sender_report_spam, () -> + performReportSelectedSpam(conversations, reportable)) + .show(); + } + + private void performReportSelectedSpam( + final Collection conversations, final int reportable) { + for (final SelectedConversation conversation : conversations) { + if (conversation.isGroup + || conversation.otherParticipantNormalizedDestination == null) { + continue; + } + UpdateDestinationBlockedAction.updateDestinationBlocked( + conversation.otherParticipantNormalizedDestination, true, + conversation.conversationId, + NOOP_BLOCK_LISTENER); + } + if (reportable == 1) { + HomeToast.show(this, R.string.home_report_spam_toast_one); + } else { + HomeToast.show(this, getString(R.string.home_report_spam_toast_many, reportable)); + } + exitMultiSelectState(); + } + protected boolean isInConversationListSelectMode() { return getActionModeCallback() instanceof MultiSelectActionModeCallback; } @@ -181,17 +416,7 @@ public abstract class AbstractConversationListActivity extends BugleActionBarAct return; } HomeDeleteAlertDialog.show(this, conversations.size(), - new HomeDeleteAlertDialog.Listener() { - @Override - public void onDelete() { - performDeleteConversations(conversations); - } - - @Override - public void onDeleteAndRegisterSpam() { - performDeleteAndRegisterSpam(conversations); - } - }); + () -> performDeleteConversations(conversations)); } private boolean ensureDefaultSmsAppForDelete() { @@ -217,50 +442,67 @@ public abstract class AbstractConversationListActivity extends BugleActionBarAct private void performDeleteConversations( final Collection conversations) { - performDeleteConversations(conversations, false /* reportSpam */); - } - - private void performDeleteConversations( - final Collection conversations, final boolean reportSpam) { final int count = conversations.size(); - for (final SelectedConversation conversation : conversations) { - DeleteConversationAction.deleteConversation( - conversation.conversationId, - conversation.timestamp); + if (isTrashList()) { + for (final SelectedConversation conversation : conversations) { + DeleteConversationAction.deleteConversation( + conversation.conversationId, + conversation.timestamp); + } + showDeleteCompletedToast(count); + } else { + for (final SelectedConversation conversation : conversations) { + UpdateConversationTrashStatusAction.moveToTrash(conversation.conversationId); + } + if (useHomeSelectionChrome() && count > 0) { + HomeToast.show(this, count == 1 + ? getString(R.string.home_trash_moved_toast_one) + : getString(R.string.home_trash_moved_toast_many, count)); + } + } + if (isInConversationListSelectMode()) { + exitMultiSelectState(); } - showDeleteCompletedToast(count, reportSpam); - exitMultiSelectState(); } - private void showDeleteCompletedToast(final int count, final boolean reportSpam) { + private void confirmPermanentDelete(final Collection conversations) { + if (!ensureDefaultSmsAppForDelete()) { + return; + } + final int count = conversations.size(); + HomeModal.builder(this) + .icon(R.drawable.ic_home_menu_trash) + .title(R.string.home_trash_permanent_title) + .message(count == 1 + ? getString(R.string.home_trash_permanent_message_one) + : getString(R.string.home_trash_permanent_message_many, count)) + .negative(android.R.string.cancel, null) + .positive(R.string.home_delete_confirm, () -> + performDeleteConversations(conversations)) + .show(); + } + + private void confirmEmptyAll(final Collection conversations) { + if (!ensureDefaultSmsAppForDelete()) { + return; + } + HomeModal.builder(this) + .icon(R.drawable.ic_home_menu_trash) + .title(R.string.home_empty_all_title) + .message(R.string.home_empty_all_message) + .negative(android.R.string.cancel, null) + .positive(R.string.home_empty_all_confirm, () -> + performDeleteConversations(conversations)) + .show(); + } + + private void showDeleteCompletedToast(final int count) { if (!useHomeSelectionChrome() || count <= 0) { return; } - final String message; - if (reportSpam) { - message = count == 1 - ? getString(R.string.home_delete_spam_toast_one) - : getString(R.string.home_delete_spam_toast_many, count); - } else { - message = count == 1 - ? getString(R.string.home_delete_toast_one) - : getString(R.string.home_delete_toast_many, count); - } - HomeToast.show(this, message); - } - - private void performDeleteAndRegisterSpam( - final Collection conversations) { - for (final SelectedConversation conversation : conversations) { - if (!conversation.isGroup - && conversation.otherParticipantNormalizedDestination != null) { - UpdateDestinationBlockedAction.updateDestinationBlocked( - conversation.otherParticipantNormalizedDestination, true, - conversation.conversationId, - NOOP_BLOCK_LISTENER); - } - } - performDeleteConversations(conversations, true /* registerSpam */); + HomeToast.show(this, count == 1 + ? getString(R.string.home_delete_toast_one) + : getString(R.string.home_delete_toast_many, count)); } @Override diff --git a/app/src/main/java/com/android/messaging/ui/conversationlist/ArchivedConversationListActivity.java b/app/src/main/java/com/android/messaging/ui/conversationlist/ArchivedConversationListActivity.java index 77e0a76..2abdcbe 100644 --- a/app/src/main/java/com/android/messaging/ui/conversationlist/ArchivedConversationListActivity.java +++ b/app/src/main/java/com/android/messaging/ui/conversationlist/ArchivedConversationListActivity.java @@ -22,6 +22,7 @@ import android.view.MenuItem; import androidx.appcompat.app.ActionBar; import com.android.messaging.R; +import com.android.messaging.ui.home.HomeInboxMenu; import com.android.messaging.ui.home.HomeSubHeader; public class ArchivedConversationListActivity extends AbstractConversationListActivity { @@ -33,7 +34,8 @@ public class ArchivedConversationListActivity extends AbstractConversationListAc if (getSupportActionBar() != null) { getSupportActionBar().hide(); } - HomeSubHeader.bind(this, R.string.home_recently_deleted_title, v -> onActionBarHome()); + HomeSubHeader.bind(this, R.string.home_archived_title, v -> onActionBarHome()); + HomeInboxMenu.bind(this, HomeInboxMenu.Screen.ARCHIVE); if (savedInstanceState == null) { mConversationListFragment = @@ -87,7 +89,7 @@ public class ArchivedConversationListActivity extends AbstractConversationListAc @Override public boolean isSwipeAnimatable() { - return false; + return !isInConversationListSelectMode(); } @Override diff --git a/app/src/main/java/com/android/messaging/ui/conversationlist/ConversationListActivity.java b/app/src/main/java/com/android/messaging/ui/conversationlist/ConversationListActivity.java index a9efd65..c26d3a2 100644 --- a/app/src/main/java/com/android/messaging/ui/conversationlist/ConversationListActivity.java +++ b/app/src/main/java/com/android/messaging/ui/conversationlist/ConversationListActivity.java @@ -18,7 +18,6 @@ package com.android.messaging.ui.conversationlist; import android.os.Bundle; -import android.view.ActionMode; import android.window.SplashScreenView; import android.view.Menu; import android.view.MenuItem; @@ -52,14 +51,6 @@ public class ConversationListActivity extends AbstractConversationListActivity { return true; } - @Override - protected boolean shouldShowContextualActionBar(final ActionMode.Callback callback) { - if (callback instanceof MultiSelectActionModeCallback) { - return !((MultiSelectActionModeCallback) callback).useHomeSelectionChrome(); - } - return true; - } - @Override protected void updateActionBar(final ActionBar actionBar) { if (actionBar == null) { @@ -152,12 +143,6 @@ public class ConversationListActivity extends AbstractConversationListActivity { UIIntents.get().launchHomeAutomationActivity(this); } - public void enterHomeEditMode() { - if (!isInConversationListSelectMode()) { - startMultiSelectActionMode(); - } - } - @Override public boolean isSwipeAnimatable() { return !isInConversationListSelectMode(); diff --git a/app/src/main/java/com/android/messaging/ui/conversationlist/ConversationListAdapter.java b/app/src/main/java/com/android/messaging/ui/conversationlist/ConversationListAdapter.java index f09591e..9331119 100644 --- a/app/src/main/java/com/android/messaging/ui/conversationlist/ConversationListAdapter.java +++ b/app/src/main/java/com/android/messaging/ui/conversationlist/ConversationListAdapter.java @@ -34,6 +34,8 @@ import com.android.messaging.datamodel.data.ConversationListItemData; import com.android.messaging.ui.CursorRecyclerAdapter; import com.android.messaging.ui.home.HomeCategoryClassifier; import com.android.messaging.ui.home.HomeCategoryStore; +import com.android.messaging.ui.home.HomeInboxFilter; +import com.android.messaging.ui.home.HomeSubListSearch; import com.android.messaging.ui.home.PinnedConversationStore; import java.util.ArrayList; @@ -51,6 +53,8 @@ public class ConversationListAdapter public static final int FILTER_ALL = 0; public static final int FILTER_UNREAD = 1; public static final int FILTER_GROUPS = 2; + public static final int FILTER_WORK = 3; + public static final int FILTER_PERSONAL = 4; private static final int VIEW_TYPE_PINNED_HEADER = 1; private static final int VIEW_TYPE_CONVERSATION = 2; @@ -61,6 +65,7 @@ public class ConversationListAdapter private final ConversationListItemData mFilterProbe = new ConversationListItemData(); private int mFilterMode = FILTER_ALL; private String mCategoryId = HomeCategoryStore.ID_ALL; + private String mSearchQuery = ""; private int[] mDisplayPositions = new int[0]; private View mPinnedStripRoot; private boolean mPinnedHeaderVisible; @@ -116,6 +121,24 @@ public class ConversationListAdapter return mFilterMode; } + public void setSearchQuery(final String query) { + final String next = query == null ? "" : query.trim(); + if (next.equals(mSearchQuery)) { + return; + } + mSearchQuery = next; + rebuildFilterIndex(); + notifyDataSetChanged(); + } + + public String getSearchQuery() { + return mSearchQuery; + } + + public boolean hasSearchQuery() { + return !mSearchQuery.isEmpty(); + } + public void setCategoryId(final String categoryId) { final String next = categoryId == null ? HomeCategoryStore.ID_ALL : categoryId; if (next.equals(mCategoryId)) { @@ -156,6 +179,28 @@ public class ConversationListAdapter return items; } + /** Visible (filtered) conversation rows, not the raw cursor. */ + public List snapshotVisibleConversations() { + final Cursor cursor = getCursor(); + if (cursor == null || mDisplayPositions.length == 0) { + return Collections.emptyList(); + } + final List items = new ArrayList<>(mDisplayPositions.length); + final int saved = cursor.getPosition(); + for (final int position : mDisplayPositions) { + if (!cursor.moveToPosition(position)) { + continue; + } + final ConversationListItemData item = new ConversationListItemData(); + item.bind(cursor); + items.add(item); + } + if (saved >= 0 && saved < cursor.getCount()) { + cursor.moveToPosition(saved); + } + return items; + } + public void notifySelectionChromeChanged() { final int count = getItemCount(); if (count > 0) { @@ -294,6 +339,18 @@ public class ConversationListAdapter if (mFilterMode == FILTER_GROUPS && mFilterProbe.getParticipantCount() <= 1) { return false; } + if (mFilterMode == FILTER_WORK && !HomeInboxFilter.matchesWork(mContext, mFilterProbe)) { + return false; + } + if (mFilterMode == FILTER_PERSONAL + && !HomeInboxFilter.matchesPersonal(mContext, mFilterProbe)) { + return false; + } + if (!HomeSubListSearch.matches(mSearchQuery, + mFilterProbe.getName(), mFilterProbe.getSnippetText(), + mFilterProbe.getSnippetSenderName())) { + return false; + } return HomeCategoryClassifier.matches(mContext, mCategoryId, mFilterProbe); } diff --git a/app/src/main/java/com/android/messaging/ui/conversationlist/ConversationListFragment.java b/app/src/main/java/com/android/messaging/ui/conversationlist/ConversationListFragment.java index 40e2609..ea551d3 100644 --- a/app/src/main/java/com/android/messaging/ui/conversationlist/ConversationListFragment.java +++ b/app/src/main/java/com/android/messaging/ui/conversationlist/ConversationListFragment.java @@ -28,6 +28,7 @@ import android.graphics.Rect; import android.graphics.RenderEffect; import android.graphics.Shader; import android.telephony.PhoneNumberUtils; +import android.telephony.SubscriptionInfo; import android.net.Uri; import android.os.Bundle; import android.os.Parcelable; @@ -37,6 +38,7 @@ import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuInflater; import android.view.MenuItem; +import android.view.MotionEvent; import android.view.Gravity; import android.view.HapticFeedbackConstants; import android.view.View; @@ -66,6 +68,10 @@ import com.android.messaging.datamodel.MessagingContentProvider; import com.android.messaging.datamodel.data.ConversationMessageData; import com.android.messaging.datamodel.action.MarkAsReadAction; import com.android.messaging.datamodel.action.MarkAsUnreadAction; +import com.android.messaging.datamodel.action.UpdateConversationArchiveStatusAction; +import com.android.messaging.datamodel.action.UpdateConversationTrashStatusAction; +import com.android.messaging.datamodel.action.UpdateDestinationBlockedAction; +import com.android.messaging.datamodel.action.UpdateMessageNotificationAction; import com.android.messaging.datamodel.binding.Binding; import com.android.messaging.datamodel.binding.BindingBase; import com.android.messaging.datamodel.data.ConversationListData; @@ -76,6 +82,7 @@ import com.android.messaging.ui.home.HomeCategoryAddModal; import com.android.messaging.ui.home.HomeCategoryAssignSheet; import com.android.messaging.ui.home.HomeCategoryClassifier; import com.android.messaging.ui.home.HomeCategoryStore; +import com.android.messaging.ui.home.HomeInboxFilter; import com.android.messaging.ui.home.HomeModal; import com.android.messaging.ui.home.HomeEmptyState; import com.android.messaging.ui.SnackBarInteraction; @@ -86,6 +93,7 @@ import com.android.messaging.util.ContactUtil; import com.android.messaging.util.Dates; import com.android.messaging.util.PhoneUtils; import com.android.messaging.ui.home.HomePinnedStrip; +import com.android.messaging.ui.home.HomeSubListSearch; import com.android.messaging.ui.home.MutedConversationStore; import com.android.messaging.ui.home.HomeToast; import com.android.messaging.ui.home.HomeUiMotion; @@ -108,10 +116,12 @@ import java.util.Set; public class ConversationListFragment extends Fragment implements ConversationListDataListener, ConversationListItemView.HostInterface { private static final String BUNDLE_ARCHIVED_MODE = "archived_mode"; + private static final String BUNDLE_TRASH_MODE = "trash_mode"; private static final String BUNDLE_FORWARD_MESSAGE_MODE = "forward_message_mode"; private MenuItem mShowBlockedMenuItem; private boolean mArchiveMode; + private boolean mTrashMode; private boolean mBlockedAvailable; private boolean mForwardMessageMode; @@ -134,7 +144,16 @@ public class ConversationListFragment extends Fragment implements ConversationLi private View mHomeBottomNav; private View mHomeSelectionBar; private ImageButton mHomeSelectionDelete; - private TextView mHomeReadAllButton; + private View mHomeSelectionUnread; + private TextView mHomeSelectionUnreadLabel; + private View mHomeSelectionSpam; + private View mHomeSelectionArchive; + private View mHomeSelectionDock; + private View mHomeSelectionTabs; + private View mHomeSelectionIndicator; + private int mHomeSelectionUnreadNaturalWidth; + private int mHomeSelectionSpamNaturalWidth; + private int mHomeSelectionArchiveNaturalWidth; private int mHomeSelectionCount; private View mHomeMessagesTop; private View mHomeCategoryCollapse; @@ -149,6 +168,7 @@ public class ConversationListFragment extends Fragment implements ConversationLi private View mHomePinnedStrip; private TextView mHomeEditButton; private ImageButton mHomePinnedDoneButton; + private View mHomeSelectionDone; private boolean mHomePinnedEditMode; private View mHomeNavDock; private View mHomeNavMessages; @@ -189,6 +209,7 @@ public class ConversationListFragment extends Fragment implements ConversationLi "homeSearchBarExpanded"; private static final String SAVED_INSTANCE_STATE_HOME_TAB = "homeTab"; private static final String SAVED_INSTANCE_STATE_PINNED_EDIT = "homePinnedEdit"; + private static final String SAVED_INSTANCE_STATE_FILTER = "homeFilter"; private static final int HOME_TAB_MESSAGES = 0; private static final int HOME_TAB_AUTOMATE = 1; private static final int SEARCH_NEWEST_EDGE_SLOP_DP = 48; @@ -236,10 +257,18 @@ public class ConversationListFragment extends Fragment implements ConversationLi return createConversationListFragment(BUNDLE_ARCHIVED_MODE); } + public static ConversationListFragment createTrashConversationListFragment() { + return createConversationListFragment(BUNDLE_TRASH_MODE); + } + public static ConversationListFragment createForwardMessageConversationListFragment() { return createConversationListFragment(BUNDLE_FORWARD_MESSAGE_MODE); } + private boolean isSubList() { + return mArchiveMode || mTrashMode || mForwardMessageMode; + } + public static ConversationListFragment createConversationListFragment(String modeKeyName) { final ConversationListFragment fragment = new ConversationListFragment(); if (modeKeyName != null) { @@ -258,6 +287,10 @@ public class ConversationListFragment extends Fragment implements ConversationLi super.onCreate(bundle); mListBinding.getData().init(LoaderManager.getInstance(this), mListBinding); mAdapter = new ConversationListAdapter(getActivity(), null, this); + if (bundle != null) { + mAdapter.setFilterMode(bundle.getInt(SAVED_INSTANCE_STATE_FILTER, + ConversationListAdapter.FILTER_ALL)); + } } @Override @@ -275,8 +308,7 @@ public class ConversationListFragment extends Fragment implements ConversationLi } public void setScrolledToNewestConversationIfNeeded() { - if (!mArchiveMode - && !mForwardMessageMode + if (!isSubList() && isScrolledToFirstConversation() && mHost.hasWindowFocus()) { mListBinding.getData().setScrolledToNewestConversation(true); @@ -320,9 +352,7 @@ public class ConversationListFragment extends Fragment implements ConversationLi } private void updateListTopFade() { - final boolean listActive = mHomeTab == HOME_TAB_MESSAGES - && !mArchiveMode - && !mForwardMessageMode; + final boolean listActive = isSubList() || mHomeTab == HOME_TAB_MESSAGES; setFadeVisible(mListTopFade, listActive && !isListFlushAtTop()); setFadeVisible(mListBottomFade, listActive && !isListFlushAtBottom()); } @@ -397,7 +427,7 @@ public class ConversationListFragment extends Fragment implements ConversationLi mListBinding.getData().setScrolledToNewestConversation(false); } - if (!mArchiveMode && !mForwardMessageMode) { + if (!isSubList()) { handleSearchBarByScrollOffset(dy); } updateListTopFade(); @@ -424,12 +454,33 @@ public class ConversationListFragment extends Fragment implements ConversationLi mHomeBottomNav = rootView.findViewById(R.id.home_bottom_nav); mHomeSelectionBar = rootView.findViewById(R.id.home_selection_bar); mHomeSelectionDelete = rootView.findViewById(R.id.home_selection_delete); - mHomeReadAllButton = rootView.findViewById(R.id.home_read_all_button); + mHomeSelectionUnread = rootView.findViewById(R.id.home_selection_unread); + mHomeSelectionSpam = rootView.findViewById(R.id.home_selection_spam); + mHomeSelectionArchive = rootView.findViewById(R.id.home_selection_archive); + mHomeSelectionDock = rootView.findViewById(R.id.home_selection_actions); + mHomeSelectionTabs = rootView.findViewById(R.id.home_selection_tabs); + mHomeSelectionIndicator = rootView.findViewById(R.id.home_selection_indicator); + if (mHomeSelectionUnread != null) { + mHomeSelectionUnreadLabel = mHomeSelectionUnread.findViewById(R.id.home_nav_tab_label); + bindHomeSelectionTab(mHomeSelectionUnread, R.drawable.ic_home_menu_unread, + R.string.home_read_all, true); + } + if (mHomeSelectionSpam != null) { + bindHomeSelectionTab(mHomeSelectionSpam, R.drawable.ic_home_menu_spam, + R.string.home_selection_spam, false); + } + if (mHomeSelectionArchive != null) { + bindHomeSelectionTab(mHomeSelectionArchive, R.drawable.ic_home_menu_archive, + R.string.home_selection_archive, false); + } + applyHomeSelectionTabEnabled(mHomeSelectionSpam, false); + applyHomeSelectionTabEnabled(mHomeSelectionArchive, false); + bindHomeSelectionDockMetrics(); mHomeComposeContainer = rootView.findViewById(R.id.home_compose_container); mStartNewConversationButton = rootView.findViewById(R.id.home_nav_search); mHomeAutomationCreateButton = rootView.findViewById(R.id.home_nav_automation_create); - if (mArchiveMode || mForwardMessageMode) { + if (isSubList()) { rootView.setFitsSystemWindows(false); if (mHomeComposeContainer != null) { mHomeComposeContainer.setVisibility(View.GONE); @@ -496,6 +547,8 @@ public class ConversationListFragment extends Fragment implements ConversationLi updateHomeEditButtonForPinnedMode(); } + bindSubListSearch(); + // 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 @@ -506,6 +559,45 @@ public class ConversationListFragment extends Fragment implements ConversationLi return rootView; } + private void bindHomeSelectionDockMetrics() { + if (mHomeSelectionUnread == null) { + return; + } + mHomeSelectionUnread.addOnLayoutChangeListener((v, l, t, r, b, ol, ot, or, ob) -> + applyHomeSelectionDockMetrics()); + mHomeSelectionUnread.post(this::applyHomeSelectionDockMetrics); + } + + private void applyHomeSelectionDockMetrics() { + if (mHomeSelectionUnreadNaturalWidth <= 0 && mHomeSelectionUnread != null) { + mHomeSelectionUnreadNaturalWidth = measureNavTabNaturalWidth(mHomeSelectionUnread); + } + if (mHomeSelectionSpamNaturalWidth <= 0 && mHomeSelectionSpam != null) { + mHomeSelectionSpamNaturalWidth = measureNavTabNaturalWidth(mHomeSelectionSpam); + } + if (mHomeSelectionArchiveNaturalWidth <= 0 && mHomeSelectionArchive != null) { + mHomeSelectionArchiveNaturalWidth = measureNavTabNaturalWidth(mHomeSelectionArchive); + } + applyHomeNavTabActiveSize(mHomeSelectionUnread, mHomeSelectionUnreadNaturalWidth); + applyHomeNavTabActiveSize(mHomeSelectionSpam, mHomeSelectionSpamNaturalWidth); + applyHomeNavTabActiveSize(mHomeSelectionArchive, mHomeSelectionArchiveNaturalWidth); + slideHomeSelectionIndicator(); + if (mHomeSelectionDock != null) { + mHomeSelectionDock.post(() -> alignDockToGuideline(mHomeSelectionDock)); + } + } + + private void slideHomeSelectionIndicator() { + if (mHomeSelectionUnread == null || mHomeSelectionIndicator == null + || mHomeSelectionTabs == null || mHomeSelectionUnread.getWidth() <= 0) { + return; + } + final float x = mHomeSelectionTabs.getLeft() + mHomeSelectionUnread.getLeft(); + final float y = mHomeSelectionTabs.getTop() + mHomeSelectionUnread.getTop(); + HomeUiMotion.slideNavIndicator(mHomeSelectionIndicator, x, y, + mHomeSelectionUnread.getWidth(), mHomeSelectionUnread.getHeight(), false); + } + private void bindHomeNavMetrics(final View gap) { mHomeNavTabGap = gap; if (mHomeNavMessages == null) { @@ -536,22 +628,26 @@ public class ConversationListFragment extends Fragment implements ConversationLi applyHomeNavTabActiveSize(mHomeNavMessages, mHomeNavMessagesNaturalWidth); applyHomeNavTabActiveSize(mHomeNavAutomate, mHomeNavAutomateNaturalWidth); if (mHomeNavDock != null) { - mHomeNavDock.post(this::alignHomeNavDockToGuideline); + mHomeNavDock.post(() -> alignDockToGuideline(mHomeNavDock)); } } /** Slide only the Messages/Automation pill to the 20dp screen guideline; compose stays put. */ private void alignHomeNavDockToGuideline() { - if (mHomeNavDock == null || mRootView == null || mHomeNavDock.getWidth() <= 0) { + alignDockToGuideline(mHomeNavDock); + } + + private void alignDockToGuideline(final View dock) { + if (dock == null || mRootView == null || dock.getWidth() <= 0) { return; } final int guideline = getResources().getDimensionPixelSize(R.dimen.looply_screen_padding); - mHomeNavDock.setTranslationX(0f); + dock.setTranslationX(0f); final int[] dockLoc = new int[2]; final int[] rootLoc = new int[2]; - mHomeNavDock.getLocationOnScreen(dockLoc); + dock.getLocationOnScreen(dockLoc); mRootView.getLocationOnScreen(rootLoc); - mHomeNavDock.setTranslationX(guideline - (dockLoc[0] - rootLoc[0])); + dock.setTranslationX(guideline - (dockLoc[0] - rootLoc[0])); } private void applyHomeNavTabActiveSize(final View tab, final int natural) { @@ -629,9 +725,28 @@ public class ConversationListFragment extends Fragment implements ConversationLi private void hideHomeChrome(final View rootView) { setGone(rootView, R.id.home_messages_top); + setGone(rootView, R.id.home_category_collapse); setGone(rootView, R.id.home_search_bar_container); setGone(rootView, R.id.home_bottom_nav); setGone(rootView, R.id.home_selection_bar); + setGone(rootView, R.id.home_automation_pane); + setGone(rootView, R.id.home_compose_container); + if (rootView instanceof ViewGroup && ((ViewGroup) rootView).getChildCount() > 0) { + ((ViewGroup) rootView).getChildAt(0).setFitsSystemWindows(false); + } + } + + private void bindSubListSearch() { + if (!isSubList() || getActivity() == null) { + return; + } + HomeSubListSearch.bind(requireActivity(), mRecyclerView, query -> { + if (mAdapter == null) { + return; + } + mAdapter.setSearchQuery(query); + updateEmptyListUi(mAdapter.getConversationItemCount() == 0); + }); } private static void setGone(final View root, final int id) { @@ -849,16 +964,34 @@ public class ConversationListFragment extends Fragment implements ConversationLi navAutomate.setOnClickListener(v -> selectHomeTab(HOME_TAB_AUTOMATE)); } - final View selectionDone = rootView.findViewById(R.id.home_selection_done); - if (selectionDone != null) { - selectionDone.setOnClickListener(v -> { + mHomeSelectionDone = rootView.findViewById(R.id.home_selection_done); + if (mHomeSelectionDone != null) { + mHomeSelectionDone.setOnClickListener(v -> { if (getActivity() instanceof AbstractConversationListActivity) { ((AbstractConversationListActivity) getActivity()).exitMultiSelectState(); } }); } - if (mHomeReadAllButton != null) { - mHomeReadAllButton.setOnClickListener(v -> onHomeReadAllOrUnreadClick()); + if (mHomeSelectionUnread != null) { + mHomeSelectionUnread.setOnClickListener(v -> onHomeReadAllOrUnreadClick()); + } + if (mHomeSelectionSpam != null) { + mHomeSelectionSpam.setOnClickListener(v -> { + if (mHomeSelectionCount > 0 + && getActivity() instanceof AbstractConversationListActivity) { + ((AbstractConversationListActivity) getActivity()) + .reportSelectedConversationsSpam(); + } + }); + } + if (mHomeSelectionArchive != null) { + mHomeSelectionArchive.setOnClickListener(v -> { + if (mHomeSelectionCount > 0 + && getActivity() instanceof AbstractConversationListActivity) { + ((AbstractConversationListActivity) getActivity()) + .archiveSelectedConversations(); + } + }); } if (mHomeSelectionDelete != null) { mHomeSelectionDelete.setOnClickListener(v -> { @@ -915,7 +1048,7 @@ public class ConversationListFragment extends Fragment implements ConversationLi private void showHomeListProfilePopup(final ConversationListItemData data, final View anchorView) { - if (mHomeListProfilePopup == null || data == null || mArchiveMode || mForwardMessageMode + if (mHomeListProfilePopup == null || data == null || isSubList() || isSelectionMode()) { return; } @@ -1230,7 +1363,20 @@ public class ConversationListFragment extends Fragment implements ConversationLi } public void setHomeSelectionMode(final boolean enabled) { - if (mArchiveMode || mForwardMessageMode || mRootView == null) { + if (mRootView == null) { + return; + } + if (isSubList()) { + if (getActivity() instanceof AbstractConversationListActivity) { + ((AbstractConversationListActivity) getActivity()).onSubListSelectionMode(enabled); + } + mRootView.post(() -> { + if (!isAdded()) { + return; + } + applySubListSelectPadding(enabled); + refreshListSelectionChrome(); + }); return; } if (enabled) { @@ -1244,9 +1390,20 @@ public class ConversationListFragment extends Fragment implements ConversationLi HomeUiMotion.replaceHeaderInstant( enabled ? mHomeHeader : mHomeSelectionHeader, enabled ? mHomeSelectionHeader : mHomeHeader); + if (mHomeSelectionDone != null) { + if (enabled) { + HomeUiMotion.swapOverlayChip(null, mHomeSelectionDone); + } else { + mHomeSelectionDone.setScaleX(1f); + mHomeSelectionDone.setScaleY(1f); + mHomeSelectionDone.setAlpha(1f); + } + } refreshListSelectionChrome(); HomeUiMotion.swapBottomDock(mHomeBottomNav, mHomeSelectionBar, enabled, () -> { - if (!enabled) { + if (enabled) { + applyHomeSelectionDockMetrics(); + } else { updateHomeMainNavVisibility(false); } }); @@ -1261,7 +1418,7 @@ public class ConversationListFragment extends Fragment implements ConversationLi } private void selectHomeTab(final int tab) { - if (mArchiveMode || mForwardMessageMode || mRootView == null) { + if (isSubList() || mRootView == null) { return; } if (isSelectionMode() && tab != HOME_TAB_MESSAGES) { @@ -1283,8 +1440,8 @@ public class ConversationListFragment extends Fragment implements ConversationLi } else { setCategoryStripShown(false, false); } - final CharSequence title = getString(showMessages - ? R.string.home_title : R.string.home_automation_title); + final CharSequence title = showMessages + ? homeMessagesTitle() : getString(R.string.home_automation_title); if (mHomeTitle != null) { if (animate) { HomeUiMotion.fadeSwapText(mHomeTitle, title); @@ -1343,7 +1500,7 @@ public class ConversationListFragment extends Fragment implements ConversationLi * action mode alone */ private void updateHomeMainNavVisibility(final boolean homeSelectionChromeActive) { - if (mHomeBottomNav == null || mArchiveMode || mForwardMessageMode) { + if (mHomeBottomNav == null || isSubList()) { return; } final boolean hideForSelection = homeSelectionChromeActive || isSelectionMode(); @@ -1360,7 +1517,7 @@ public class ConversationListFragment extends Fragment implements ConversationLi } private void updateHomeComposeButtonVisibility(final boolean animate) { - if (mArchiveMode || mForwardMessageMode) { + if (isSubList()) { return; } final boolean showDock = isResumed() && !isSelectionMode(); @@ -1447,16 +1604,29 @@ public class ConversationListFragment extends Fragment implements ConversationLi } public void updateHomeSelectionBar(final int selectedCount) { + if (isSubList()) { + if (getActivity() instanceof AbstractConversationListActivity) { + ((AbstractConversationListActivity) getActivity()) + .onSubListSelectionCount(selectedCount); + } + return; + } mHomeSelectionCount = selectedCount; - if (mHomeReadAllButton != null) { - mHomeReadAllButton.setText(selectedCount > 0 + if (mHomeSelectionUnreadLabel != null) { + mHomeSelectionUnreadLabel.setText(selectedCount > 0 ? R.string.home_mark_unread : R.string.home_read_all); + mHomeSelectionUnreadNaturalWidth = 0; + if (mHomeSelectionUnread != null) { + mHomeSelectionUnread.post(this::applyHomeSelectionDockMetrics); + } } + final boolean enabled = selectedCount > 0; + applyHomeSelectionTabEnabled(mHomeSelectionSpam, enabled); + applyHomeSelectionTabEnabled(mHomeSelectionArchive, enabled); if (mHomeSelectionDelete == null) { return; } - final boolean enabled = selectedCount > 0; mHomeSelectionDelete.setEnabled(enabled); mHomeSelectionDelete.setAlpha(1f); mHomeSelectionDelete.setImageAlpha(enabled ? 255 : 102); @@ -1466,6 +1636,49 @@ public class ConversationListFragment extends Fragment implements ConversationLi android.content.res.ColorStateList.valueOf(tintColor)); } + private void bindHomeSelectionTab(final View tab, final int iconRes, final int labelRes, + final boolean selected) { + if (tab == null) { + return; + } + final ImageView icon = tab.findViewById(R.id.home_nav_tab_icon); + final TextView label = tab.findViewById(R.id.home_nav_tab_label); + if (icon != null) { + icon.setImageResource(iconRes); + } + if (label != null) { + label.setText(labelRes); + } + applyHomeSelectionTabStyle(tab, selected); + } + + private void applyHomeSelectionTabStyle(final View tab, final boolean selected) { + if (tab == null) { + return; + } + tab.setBackgroundResource(android.R.color.transparent); + final int color = ContextCompat.getColor(requireContext(), + selected ? R.color.home_nav_active_content : R.color.home_nav_inactive); + final ImageView icon = tab.findViewById(R.id.home_nav_tab_icon); + final TextView label = tab.findViewById(R.id.home_nav_tab_label); + if (icon != null) { + icon.setImageTintList(android.content.res.ColorStateList.valueOf(color)); + } + if (label != null) { + label.setTextColor(color); + label.setTypeface(null, selected ? android.graphics.Typeface.BOLD + : android.graphics.Typeface.NORMAL); + } + } + + private void applyHomeSelectionTabEnabled(final View tab, final boolean enabled) { + if (tab == null) { + return; + } + tab.setEnabled(enabled); + tab.setAlpha(enabled ? 1f : 0.4f); + } + private void onHomeReadAllOrUnreadClick() { if (mHomeSelectionCount > 0 && getActivity() instanceof AbstractConversationListActivity) { @@ -1475,6 +1688,30 @@ public class ConversationListFragment extends Fragment implements ConversationLi markAllVisibleConversationsRead(); } + private void applySubListSelectPadding(final boolean enabled) { + if (mRecyclerView == null || !isSubList()) { + return; + } + mRecyclerView.setPadding( + mRecyclerView.getPaddingLeft(), + mRecyclerView.getPaddingTop(), + mRecyclerView.getPaddingRight(), + getResources().getDimensionPixelSize(enabled + ? R.dimen.home_sub_select_list_padding + : R.dimen.looply_space_lg)); + } + + public List snapshotVisibleConversations() { + if (mAdapter == null) { + return java.util.Collections.emptyList(); + } + return mAdapter.snapshotVisibleConversations(); + } + + public int getVisibleConversationCount() { + return mAdapter == null ? 0 : mAdapter.getConversationItemCount(); + } + private void markAllVisibleConversationsRead() { mAdapter.forEachVisibleConversationId(MarkAsReadAction::markAsRead); } @@ -1596,6 +1833,39 @@ public class ConversationListFragment extends Fragment implements ConversationLi final boolean muted = MutedConversationStore.isMuted(requireContext(), data.getConversationId()); final boolean read = data.getIsRead(); + final View restoreRow = content.findViewById(R.id.popover_conversation_restore); + if (restoreRow != null) { + restoreRow.setVisibility((mArchiveMode || mTrashMode) ? View.VISIBLE : View.GONE); + restoreRow.setOnClickListener(v -> { + dismissHomePopover(); + if (mTrashMode) { + UpdateConversationTrashStatusAction.restoreFromTrash(data.getConversationId()); + HomeToast.show(requireContext(), R.string.home_trash_restored_toast); + } else { + UpdateConversationArchiveStatusAction.unarchiveConversation( + data.getConversationId()); + HomeToast.show(requireContext(), R.string.home_profile_unarchived_toast); + } + }); + } + final View pinRow = content.findViewById(R.id.popover_conversation_pin); + if (pinRow != null) { + pinRow.setVisibility(isSubList() ? View.GONE : View.VISIBLE); + } + final View categoryRow = content.findViewById(R.id.popover_conversation_category); + if (categoryRow != null) { + categoryRow.setVisibility(isSubList() ? View.GONE : View.VISIBLE); + } + final View archiveRow = content.findViewById(R.id.popover_conversation_archive); + if (archiveRow != null) { + archiveRow.setVisibility((mArchiveMode || mTrashMode) ? View.GONE : View.VISIBLE); + } + final View spamRow = content.findViewById(R.id.popover_conversation_spam); + if (spamRow != null) { + final boolean canReportSpam = !mTrashMode && !data.getIsGroup() + && !TextUtils.isEmpty(data.getOtherParticipantNormalizedDestination()); + spamRow.setVisibility(canReportSpam ? View.VISIBLE : View.GONE); + } final TextView pinLabel = content.findViewById(R.id.popover_conversation_pin_label); if (pinLabel != null) { @@ -1621,6 +1891,10 @@ public class ConversationListFragment extends Fragment implements ConversationLi ? R.drawable.ic_home_bell : R.drawable.ic_home_bell_off); } + final View muteRow = content.findViewById(R.id.popover_conversation_mute); + if (muteRow != null) { + muteRow.setVisibility(mTrashMode ? View.GONE : View.VISIBLE); + } content.findViewById(R.id.popover_conversation_pin).setOnClickListener(v -> { dismissHomePopover(); @@ -1650,6 +1924,14 @@ public class ConversationListFragment extends Fragment implements ConversationLi dismissHomePopover(); showAssignHomeCategory(data); }); + content.findViewById(R.id.popover_conversation_archive).setOnClickListener(v -> { + dismissHomePopover(); + archiveConversationFromPopover(data); + }); + content.findViewById(R.id.popover_conversation_spam).setOnClickListener(v -> { + dismissHomePopover(); + confirmReportConversationSpam(data); + }); content.findViewById(R.id.popover_conversation_delete).setOnClickListener(v -> { dismissHomePopover(); confirmDeleteConversation(data); @@ -1664,6 +1946,14 @@ public class ConversationListFragment extends Fragment implements ConversationLi HomeToast.show(requireContext(), muted ? R.string.home_conversation_muted_toast : R.string.home_conversation_unmuted_toast); + if (mAdapter != null) { + final int adapterPosition = + mAdapter.getAdapterPositionForConversationId(data.getConversationId()); + if (adapterPosition >= 0) { + mAdapter.notifyItemChanged(adapterPosition); + } + } + UpdateMessageNotificationAction.updateMessageNotification(); } private void confirmDeleteConversation(final ConversationListItemData data) { @@ -1672,6 +1962,37 @@ public class ConversationListFragment extends Fragment implements ConversationLi } } + private void archiveConversationFromPopover(final ConversationListItemData data) { + if (data.getIsArchived()) { + UpdateConversationArchiveStatusAction.unarchiveConversation(data.getConversationId()); + HomeToast.show(requireContext(), R.string.home_profile_unarchived_toast); + return; + } + UpdateConversationArchiveStatusAction.archiveConversation(data.getConversationId()); + HomeToast.show(requireContext(), R.string.home_profile_archived_toast); + } + + private void confirmReportConversationSpam(final ConversationListItemData data) { + if (data.getIsGroup() + || TextUtils.isEmpty(data.getOtherParticipantNormalizedDestination())) { + HomeToast.show(requireContext(), R.string.home_report_spam_groups_only); + return; + } + HomeModal.builder(requireContext()) + .icon(R.drawable.ic_home_menu_spam) + .title(R.string.home_report_spam_title) + .message(R.string.home_report_spam_message_one) + .negative(android.R.string.cancel, null) + .positive(R.string.home_unknown_sender_report_spam, () -> { + UpdateDestinationBlockedAction.updateDestinationBlocked( + data.getOtherParticipantNormalizedDestination(), true, + data.getConversationId(), + (action, success, block, destination) -> { }); + HomeToast.show(requireContext(), R.string.home_report_spam_toast_one); + }) + .show(); + } + private static final int PREVIEW_MESSAGE_LIMIT = 3; private static final class PreviewMessage { @@ -1813,6 +2134,16 @@ public class ConversationListFragment extends Fragment implements ConversationLi } host.addView(content, new FrameLayout.LayoutParams( ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)); + content.setOnTouchListener((v, event) -> { + if (event.getActionMasked() != MotionEvent.ACTION_DOWN) { + return false; + } + if (isPeekTouchOnCard(content, event)) { + return false; + } + dismissHomePopover(); + return true; + }); final PopupWindow popup = new PopupWindow(host, ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT, @@ -1918,16 +2249,38 @@ public class ConversationListFragment extends Fragment implements ConversationLi content.setY(y); } + private static boolean isPeekTouchOnCard(final View content, final MotionEvent event) { + final View preview = content.findViewById(R.id.popover_preview_card); + final View menu = content.findViewById(R.id.popover_menu_card); + if (preview != null || menu != null) { + return isEventInside(preview, event) || isEventInside(menu, event); + } + return isEventInside(content, event); + } + + private static boolean isEventInside(final View view, final MotionEvent event) { + if (view == null || view.getVisibility() != View.VISIBLE) { + return false; + } + final int[] loc = new int[2]; + view.getLocationOnScreen(loc); + final float x = event.getRawX(); + final float y = event.getRawY(); + return x >= loc[0] && x < loc[0] + view.getWidth() + && y >= loc[1] && y < loc[1] + view.getHeight(); + } + private PopupWindow showHomePopover(final View anchor, final int layoutResId, final boolean alignEnd) { dismissHomePopover(); final View content = LayoutInflater.from(requireContext()) .inflate(layoutResId, null, false); + final int width = getResources().getDimensionPixelSize(R.dimen.home_popover_width); content.measure( - View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED), + View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY), View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)); final PopupWindow popup = new PopupWindow(content, - content.getMeasuredWidth(), + width, ViewGroup.LayoutParams.WRAP_CONTENT, true); popup.setOutsideTouchable(true); @@ -1954,9 +2307,9 @@ public class ConversationListFragment extends Fragment implements ConversationLi windowRight = getResources().getDisplayMetrics().widthPixels; } int xOff = alignEnd - ? windowRight - endInset - content.getMeasuredWidth() - anchorLoc[0] + ? windowRight - endInset - width - anchorLoc[0] : 0; - final int overflow = (anchorLoc[0] + xOff + content.getMeasuredWidth()) + final int overflow = (anchorLoc[0] + xOff + width) - (windowRight - endInset); if (overflow > 0) { xOff -= overflow; @@ -1970,6 +2323,8 @@ public class ConversationListFragment extends Fragment implements ConversationLi private void showHomeFilterPopover(final View anchor) { final PopupWindow popup = showHomePopover(anchor, R.layout.home_popover_filter, true); final View content = popup.getContentView(); + bindHomeFilterPopoverSims(content); + bindHomeFilterPopoverChecks(content); content.findViewById(R.id.popover_messages).setOnClickListener(v -> { selectHomeChip(ConversationListAdapter.FILTER_ALL); dismissHomePopover(); @@ -1978,21 +2333,26 @@ public class ConversationListFragment extends Fragment implements ConversationLi dismissHomePopover(); launchHomeSubScreen(() -> UIIntents.get().launchHomeSpamActivity(requireActivity())); }); - content.findViewById(R.id.popover_recently_deleted).setOnClickListener(v -> { + content.findViewById(R.id.popover_archive).setOnClickListener(v -> { dismissHomePopover(); launchHomeSubScreen(() -> UIIntents.get().launchArchivedConversationsActivity( requireActivity())); }); + content.findViewById(R.id.popover_recently_deleted).setOnClickListener(v -> { + dismissHomePopover(); + launchHomeSubScreen(() -> UIIntents.get().launchRecentlyDeletedConversationsActivity( + requireActivity())); + }); content.findViewById(R.id.popover_unread).setOnClickListener(v -> { selectHomeChip(ConversationListAdapter.FILTER_UNREAD); dismissHomePopover(); }); content.findViewById(R.id.popover_work).setOnClickListener(v -> { + selectHomeChip(ConversationListAdapter.FILTER_WORK); dismissHomePopover(); - HomeToast.show(requireContext(), R.string.home_popover_coming_soon); }); content.findViewById(R.id.popover_personal).setOnClickListener(v -> { - selectHomeChip(ConversationListAdapter.FILTER_ALL); + selectHomeChip(ConversationListAdapter.FILTER_PERSONAL); dismissHomePopover(); }); content.findViewById(R.id.popover_share_app).setOnClickListener(v -> { @@ -2009,6 +2369,54 @@ public class ConversationListFragment extends Fragment implements ConversationLi }); } + private void bindHomeFilterPopoverSims(final View content) { + final Context context = content.getContext(); + final SubscriptionInfo work = HomeInboxFilter.workSubscription(context); + final SubscriptionInfo personal = HomeInboxFilter.personalSubscription(context); + final boolean distinctWork = work != null && personal != null + && work.getSubscriptionId() != personal.getSubscriptionId(); + final View workRow = content.findViewById(R.id.popover_work); + if (workRow != null) { + workRow.setVisibility(distinctWork ? View.VISIBLE : View.GONE); + } + setHomeFilterText(content, R.id.popover_work_badge, HomeInboxFilter.workSimName(context)); + setHomeFilterText(content, R.id.popover_work_label, HomeInboxFilter.workSimName(context)); + setHomeFilterText(content, R.id.popover_personal_badge, + HomeInboxFilter.personalSimName(context)); + setHomeFilterText(content, R.id.popover_personal_label, + HomeInboxFilter.personalSimName(context)); + } + + private static void setHomeFilterText(final View content, final int id, final String text) { + final View view = content.findViewById(id); + if (view instanceof TextView) { + ((TextView) view).setText(text); + } + } + + private void bindHomeFilterPopoverChecks(final View content) { + final int mode = mAdapter == null + ? ConversationListAdapter.FILTER_ALL : mAdapter.getFilterMode(); + setHomeFilterCheck(content, R.id.popover_messages_check, + mode == ConversationListAdapter.FILTER_ALL); + setHomeFilterCheck(content, R.id.popover_unread_check, + mode == ConversationListAdapter.FILTER_UNREAD); + setHomeFilterCheck(content, R.id.popover_work_check, + mode == ConversationListAdapter.FILTER_WORK); + setHomeFilterCheck(content, R.id.popover_personal_check, + mode == ConversationListAdapter.FILTER_PERSONAL); + setHomeFilterCheck(content, R.id.popover_spam_check, false); + setHomeFilterCheck(content, R.id.popover_archive_check, false); + setHomeFilterCheck(content, R.id.popover_recently_deleted_check, false); + } + + private static void setHomeFilterCheck(final View content, final int id, final boolean on) { + final View check = content.findViewById(id); + if (check != null) { + check.setVisibility(on ? View.VISIBLE : View.INVISIBLE); + } + } + private void shareThisApp() { final Context context = getContext(); if (context == null) { @@ -2023,12 +2431,16 @@ public class ConversationListFragment extends Fragment implements ConversationLi } private void showHomeEditPopover(final View anchor) { + if (getVisibleConversationCount() == 0) { + HomeToast.show(requireContext(), R.string.home_toast_nothing_to_edit); + return; + } final PopupWindow popup = showHomePopover(anchor, R.layout.home_popover_edit, true); final View content = popup.getContentView(); content.findViewById(R.id.popover_select_messages).setOnClickListener(v -> { dismissHomePopoverImmediate(); - if (getActivity() instanceof ConversationListActivity) { - ((ConversationListActivity) getActivity()).enterHomeEditMode(); + if (getActivity() instanceof AbstractConversationListActivity) { + ((AbstractConversationListActivity) getActivity()).enterHomeEditMode(); } }); content.findViewById(R.id.popover_edit_pinned).setOnClickListener(v -> { @@ -2037,13 +2449,42 @@ public class ConversationListFragment extends Fragment implements ConversationLi } private void selectHomeChip(final int filterMode) { - mAdapter.setFilterMode(filterMode); + if (mHomeTab != HOME_TAB_MESSAGES) { + selectHomeTab(HOME_TAB_MESSAGES); + } + if (mAdapter != null) { + mAdapter.setFilterMode(filterMode); + } + updateHomeFilterTitle(); bindHomeCategoryStrip(); - updateEmptyListUi(mAdapter.getConversationItemCount() == 0); + updateEmptyListUi(mAdapter != null && mAdapter.getConversationItemCount() == 0); + } + + private CharSequence homeMessagesTitle() { + if (mAdapter == null) { + return getString(R.string.home_title); + } + switch (mAdapter.getFilterMode()) { + case ConversationListAdapter.FILTER_UNREAD: + return getString(R.string.home_popover_unread); + case ConversationListAdapter.FILTER_WORK: + return HomeInboxFilter.workSimName(requireContext()); + case ConversationListAdapter.FILTER_PERSONAL: + return HomeInboxFilter.personalSimName(requireContext()); + default: + return getString(R.string.home_title); + } + } + + private void updateHomeFilterTitle() { + if (mHomeTitle == null || mHomeTab != HOME_TAB_MESSAGES || isSubList()) { + return; + } + mHomeTitle.setText(homeMessagesTitle()); } private void bindHomeCategoryStrip() { - if (mHomeCategoryRow == null || mArchiveMode || mForwardMessageMode) { + if (mHomeCategoryRow == null || isSubList()) { return; } final Context context = getContext(); @@ -2259,9 +2700,11 @@ public class ConversationListFragment extends Fragment implements ConversationLi final Bundle arguments = getArguments(); if (arguments != null) { mArchiveMode = arguments.getBoolean(BUNDLE_ARCHIVED_MODE, false); + mTrashMode = arguments.getBoolean(BUNDLE_TRASH_MODE, false); mForwardMessageMode = arguments.getBoolean(BUNDLE_FORWARD_MESSAGE_MODE, false); } - mListBinding.bind(DataModel.get().createConversationListData(context, this, mArchiveMode)); + mListBinding.bind(DataModel.get().createConversationListData( + context, this, mArchiveMode, mTrashMode)); } @@ -2274,6 +2717,9 @@ public class ConversationListFragment extends Fragment implements ConversationLi outState.putBoolean(SAVED_INSTANCE_STATE_SEARCH_BAR_EXPANDED, mSearchBarShown); outState.putInt(SAVED_INSTANCE_STATE_HOME_TAB, mHomeTab); outState.putBoolean(SAVED_INSTANCE_STATE_PINNED_EDIT, mHomePinnedEditMode); + if (mAdapter != null) { + outState.putInt(SAVED_INSTANCE_STATE_FILTER, mAdapter.getFilterMode()); + } } @Override @@ -2281,7 +2727,7 @@ public class ConversationListFragment extends Fragment implements ConversationLi super.onPause(); dismissHomeListProfilePopup(false); dismissHomePopover(); - if (mHomeBottomNav != null && !mArchiveMode && !mForwardMessageMode) { + if (mHomeBottomNav != null && !isSubList()) { mHomeBottomNav.setVisibility(View.GONE); } mListState = mRecyclerView.getLayoutManager().onSaveInstanceState(); @@ -2392,7 +2838,7 @@ public class ConversationListFragment extends Fragment implements ConversationLi @Override public boolean shouldShowConversationPinButton() { - return mHomePinnedEditMode && !mArchiveMode && !mForwardMessageMode + return mHomePinnedEditMode && !isSubList() && mHomeTab == HOME_TAB_MESSAGES; } @@ -2434,10 +2880,30 @@ public class ConversationListFragment extends Fragment implements ConversationLi mEmptyListMessageView.setContent(R.drawable.ic_home_nav_messages, R.string.home_empty_messages_sync_title, R.string.home_empty_messages_sync_body); + } else if (mAdapter.hasSearchQuery()) { + mEmptyListMessageView.setContent(R.drawable.ic_home_search_line, + getString(R.string.home_empty_sub_search_title), + getString(R.string.home_empty_sub_search_body, mAdapter.getSearchQuery())); } else if (mArchiveMode) { mEmptyListMessageView.setContent(R.drawable.ic_home_menu_archive, R.string.home_empty_archive_title, R.string.home_empty_archive_body); + } else if (mTrashMode) { + mEmptyListMessageView.setContent(R.drawable.ic_home_menu_trash, + R.string.home_empty_trash_title, + R.string.home_empty_trash_body); + } else if (mAdapter.getFilterMode() == ConversationListAdapter.FILTER_UNREAD) { + mEmptyListMessageView.setContent(R.drawable.ic_home_menu_unread, + R.string.home_empty_unread_title, + R.string.home_empty_unread_body); + } else if (mAdapter.getFilterMode() == ConversationListAdapter.FILTER_WORK) { + mEmptyListMessageView.setContent(R.drawable.ic_home_nav_messages, + R.string.home_empty_work_title, + R.string.home_empty_work_body); + } else if (mAdapter.getFilterMode() == ConversationListAdapter.FILTER_PERSONAL) { + mEmptyListMessageView.setContent(R.drawable.ic_home_nav_messages, + R.string.home_empty_personal_title, + R.string.home_empty_personal_body); } else if (!HomeCategoryStore.ID_ALL.equals(mAdapter.getCategoryId()) || mAdapter.getFilterMode() != ConversationListAdapter.FILTER_ALL) { mEmptyListMessageView.setContent(R.drawable.ic_home_nav_messages, @@ -2448,7 +2914,7 @@ public class ConversationListFragment extends Fragment implements ConversationLi R.string.home_empty_messages_title, R.string.home_empty_messages_body); } - mEmptyListMessageView.setReserveDock(!mArchiveMode && !mForwardMessageMode); + mEmptyListMessageView.setReserveDock(!isSubList()); mEmptyListMessageView.setVisibility(View.VISIBLE); } else { mEmptyListMessageView.setVisibility(View.GONE); @@ -2527,7 +2993,7 @@ public class ConversationListFragment extends Fragment implements ConversationLi } private void setPinnedEditMode(final boolean enabled, final boolean refreshList) { - if (mArchiveMode || mForwardMessageMode) { + if (isSubList()) { return; } if (enabled) { @@ -2570,23 +3036,21 @@ public class ConversationListFragment extends Fragment implements ConversationLi return; } if (mHomePinnedEditMode) { - mHomeEditButton.setVisibility(View.GONE); + HomeUiMotion.swapOverlayChip(mHomeEditButton, mHomePinnedDoneButton); if (mHomePinnedDoneButton != null) { - mHomePinnedDoneButton.setVisibility(View.VISIBLE); mHomePinnedDoneButton.setOnClickListener(v -> setPinnedEditMode(false)); } } else { - mHomeEditButton.setVisibility(View.VISIBLE); - mHomeEditButton.setText(R.string.home_edit); - mHomeEditButton.setOnClickListener(this::showHomeEditPopover); - if (mHomePinnedDoneButton != null) { - mHomePinnedDoneButton.setVisibility(View.GONE); + HomeUiMotion.swapOverlayChip(mHomePinnedDoneButton, mHomeEditButton); + if (mHomeEditButton != null) { + mHomeEditButton.setText(R.string.home_edit); + mHomeEditButton.setOnClickListener(this::showHomeEditPopover); } } } private void refreshPinnedStrip() { - if (mHomePinnedStrip == null || mArchiveMode || mForwardMessageMode + if (mHomePinnedStrip == null || isSubList() || mHomeTab != HOME_TAB_MESSAGES) { mAdapter.setPinnedHeaderVisible(false); return; diff --git a/app/src/main/java/com/android/messaging/ui/conversationlist/ConversationListItemView.java b/app/src/main/java/com/android/messaging/ui/conversationlist/ConversationListItemView.java index 4f92458..7fdbcf0 100644 --- a/app/src/main/java/com/android/messaging/ui/conversationlist/ConversationListItemView.java +++ b/app/src/main/java/com/android/messaging/ui/conversationlist/ConversationListItemView.java @@ -53,6 +53,7 @@ import com.android.messaging.Factory; import com.android.messaging.R; import com.android.messaging.datamodel.MessagingContentProvider; import com.android.messaging.datamodel.action.UpdateConversationArchiveStatusAction; +import com.android.messaging.datamodel.action.UpdateConversationTrashStatusAction; import com.android.messaging.datamodel.data.ConversationListItemData; import com.android.messaging.datamodel.data.ConversationMessageData; import com.android.messaging.datamodel.data.MessageData; @@ -63,6 +64,8 @@ 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.ui.home.HomeToast; +import com.android.messaging.ui.home.MutedConversationStore; import com.android.messaging.util.Assert; import com.android.messaging.util.ImageUtils; import com.android.messaging.util.PhoneUtils; @@ -155,6 +158,7 @@ public class ConversationListItemView extends FrameLayout implements OnClickList private ContactIconView mContactIconView; private ImageView mContactCheckmarkView; private ImageView mFailedStatusIconView; + private ImageView mMuteBadgeView; private ImageView mCrossSwipeArchiveLeftImageView; private View mCrossSwipeRightActions; private ImageView mCrossSwipeMuteView; @@ -196,6 +200,7 @@ public class ConversationListItemView extends FrameLayout implements OnClickList mContactIconView = findViewById(R.id.conversation_icon); mContactCheckmarkView = findViewById(R.id.conversation_checkmark); mFailedStatusIconView = findViewById(R.id.conversation_failed_status_icon); + mMuteBadgeView = findViewById(R.id.conversation_mute_badge); mCrossSwipeArchiveLeftImageView = findViewById(R.id.crossSwipeArchiveIconLeft); mCrossSwipeRightActions = findViewById(R.id.cross_swipe_right_actions); mCrossSwipeMuteView = findViewById(R.id.cross_swipe_mute); @@ -813,6 +818,15 @@ public class ConversationListItemView extends FrameLayout implements OnClickList mContactCheckmarkView.setVisibility(checkmarkVisiblity); mFailedStatusIconView.setVisibility(failStatusVisiblity); + final boolean muted = MutedConversationStore.isMuted(getContext(), + mData.getConversationId()); + final boolean trashed = mData.getIsTrashed(); + if (mMuteBadgeView != null) { + mMuteBadgeView.setVisibility( + muted && !trashed && failStatusVisiblity != VISIBLE && !isSelected + ? VISIBLE : GONE); + } + updateSwipeMuteButton(muted, trashed); updatePinButton(selectionMode, resources); updateListDividerInset(selectionMode); @@ -931,6 +945,31 @@ public class ConversationListItemView extends FrameLayout implements OnClickList && screenY <= actionLoc[1] + mCrossSwipeRightActions.getHeight(); } + private void updateSwipeMuteButton(final boolean muted, final boolean trashed) { + if (mCrossSwipeMuteView == null) { + return; + } + mCrossSwipeMuteView.setVisibility(trashed ? GONE : VISIBLE); + if (mCrossSwipeDeleteView != null) { + final ViewGroup.MarginLayoutParams lp = + (ViewGroup.MarginLayoutParams) mCrossSwipeDeleteView.getLayoutParams(); + if (lp != null) { + lp.setMarginStart(trashed ? 0 + : getResources().getDimensionPixelSize(R.dimen.looply_space_sm)); + mCrossSwipeDeleteView.setLayoutParams(lp); + } + } + if (trashed) { + return; + } + mCrossSwipeMuteView.setImageResource(muted + ? R.drawable.ic_home_bell + : R.drawable.ic_home_bell_off); + mCrossSwipeMuteView.setContentDescription(getResources().getString(muted + ? R.string.home_swipe_unmute + : R.string.home_swipe_mute)); + } + public void setSwipeTranslationX(final float translationX) { mSwipeableContainer.setTranslationX(translationX); if (translationX == 0) { @@ -967,9 +1006,24 @@ public class ConversationListItemView extends FrameLayout implements OnClickList final String conversationId = mData.getConversationId(); if (PrefsUtils.isSwipeRightToDeleteEnabled() && swipeDirection == ConversationListSwipeHelper.SWIPE_DIRECTION_RIGHT) { + final boolean alreadyTrashed = mData.getIsTrashed(); mData.deleteConversation(); - UiUtils.showSnackBar(getContext(), getRootView(), - getResources().getString(R.string.conversation_deleted)); + if (alreadyTrashed) { + UiUtils.showSnackBar(getContext(), getRootView(), + getResources().getString(R.string.conversation_deleted)); + } else { + HomeToast.show(getContext(), R.string.home_trash_moved_toast_one); + } + return; + } + if (mData.getIsTrashed()) { + UpdateConversationTrashStatusAction.restoreFromTrash(conversationId); + HomeToast.show(getContext(), R.string.home_trash_restored_toast); + return; + } + if (mData.getIsArchived()) { + UpdateConversationArchiveStatusAction.unarchiveConversation(conversationId); + HomeToast.show(getContext(), R.string.home_profile_unarchived_toast); return; } UpdateConversationArchiveStatusAction.archiveConversation(conversationId); diff --git a/app/src/main/java/com/android/messaging/ui/conversationlist/RecentlyDeletedConversationListActivity.java b/app/src/main/java/com/android/messaging/ui/conversationlist/RecentlyDeletedConversationListActivity.java new file mode 100644 index 0000000..921ec48 --- /dev/null +++ b/app/src/main/java/com/android/messaging/ui/conversationlist/RecentlyDeletedConversationListActivity.java @@ -0,0 +1,105 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * Copyright (C) 2024-2025 The LineageOS 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.os.Bundle; +import android.view.MenuItem; + +import androidx.appcompat.app.ActionBar; + +import com.android.messaging.R; +import com.android.messaging.ui.home.HomeInboxMenu; +import com.android.messaging.ui.home.HomeSubHeader; + +public class RecentlyDeletedConversationListActivity extends AbstractConversationListActivity { + + @Override + protected void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.home_archived_activity); + if (getSupportActionBar() != null) { + getSupportActionBar().hide(); + } + HomeSubHeader.bind(this, R.string.home_recently_deleted_title, v -> onActionBarHome()); + HomeInboxMenu.bind(this, HomeInboxMenu.Screen.TRASH); + + if (savedInstanceState == null) { + mConversationListFragment = + ConversationListFragment.createTrashConversationListFragment(); + getSupportFragmentManager() + .beginTransaction() + .replace(R.id.home_archived_list_host, mConversationListFragment) + .commit(); + } else { + mConversationListFragment = (ConversationListFragment) getSupportFragmentManager() + .findFragmentById(R.id.home_archived_list_host); + } + } + + @Override + protected boolean useHomeSelectionChrome() { + return true; + } + + @Override + protected boolean isTrashList() { + return true; + } + + @Override + protected void updateActionBar(final ActionBar actionBar) { + if (actionBar == null) { + return; + } + actionBar.hide(); + super.updateActionBar(actionBar); + } + + @Override + public void onBackPressed() { + if (isInConversationListSelectMode()) { + exitMultiSelectState(); + } else { + super.onBackPressed(); + } + } + + @Override + public boolean onOptionsItemSelected(final MenuItem menuItem) { + if (menuItem.getItemId() == android.R.id.home) { + onActionBarHome(); + return true; + } + return super.onOptionsItemSelected(menuItem); + } + + @Override + public void onActionBarHome() { + onBackPressed(); + } + + @Override + public boolean isSwipeAnimatable() { + return !isInConversationListSelectMode(); + } + + @Override + public void finish() { + super.finish(); + overridePendingTransition(R.anim.home_activity_hold, R.anim.home_activity_close); + } +} diff --git a/app/src/main/java/com/android/messaging/ui/home/HomeAppLockActivity.java b/app/src/main/java/com/android/messaging/ui/home/HomeAppLockActivity.java index 31cf14c..216c92a 100644 --- a/app/src/main/java/com/android/messaging/ui/home/HomeAppLockActivity.java +++ b/app/src/main/java/com/android/messaging/ui/home/HomeAppLockActivity.java @@ -5,6 +5,7 @@ import android.text.Editable; import android.text.TextUtils; import android.text.TextWatcher; import android.text.method.PasswordTransformationMethod; +import android.view.Gravity; import android.view.KeyEvent; import android.view.LayoutInflater; import android.view.View; @@ -13,6 +14,7 @@ import android.view.inputmethod.EditorInfo; import android.widget.EditText; import android.widget.FrameLayout; import android.widget.ImageView; +import android.widget.LinearLayout; import android.widget.TextView; import androidx.activity.OnBackPressedCallback; @@ -70,9 +72,15 @@ public class HomeAppLockActivity extends BugleActionBarActivity { if (isPinMode()) { inflater.inflate(R.layout.home_lock_pin_body, body, true); bindPinPad(body); + if (mMode != HomeAppLock.MODE_UNLOCK) { + relocateLockPrompt(body); + } } else if (isPatternMode()) { inflater.inflate(R.layout.home_lock_pattern_body, body, true); bindPattern(body); + if (mMode != HomeAppLock.MODE_UNLOCK) { + relocateLockPrompt(body); + } } else { inflater.inflate(R.layout.home_lock_password_body, body, true); bindPassword(body); @@ -108,6 +116,35 @@ public class HomeAppLockActivity extends BugleActionBarActivity { } } + private void relocateLockPrompt(final View host) { + final ViewGroup prompt = host.findViewById(R.id.home_lock_prompt); + if (prompt == null) { + return; + } + if (mSubtitle != null) { + mSubtitle.setGravity(Gravity.CENTER); + mSubtitle.setTextAlignment(View.TEXT_ALIGNMENT_CENTER); + final LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); + lp.bottomMargin = getResources().getDimensionPixelSize(R.dimen.looply_space_sm); + mSubtitle.setLayoutParams(lp); + moveChild(prompt, mSubtitle, 0); + } + if (mError != null) { + moveChild(prompt, mError, mSubtitle == null ? 0 : 1); + } + } + + private static void moveChild(final ViewGroup parent, final View child, final int index) { + if (parent == null || child == null) { + return; + } + if (child.getParent() instanceof ViewGroup) { + ((ViewGroup) child.getParent()).removeView(child); + } + parent.addView(child, Math.min(index, parent.getChildCount())); + } + private void bindPinPad(final View host) { mPinDots = host.findViewById(R.id.home_lock_pin_dots); final ViewGroup keypad = host.findViewById(R.id.home_lock_pin_keypad); diff --git a/app/src/main/java/com/android/messaging/ui/home/HomeDeleteAlertDialog.java b/app/src/main/java/com/android/messaging/ui/home/HomeDeleteAlertDialog.java index 8cef22c..ad53a0f 100644 --- a/app/src/main/java/com/android/messaging/ui/home/HomeDeleteAlertDialog.java +++ b/app/src/main/java/com/android/messaging/ui/home/HomeDeleteAlertDialog.java @@ -17,25 +17,16 @@ package com.android.messaging.ui.home; import android.content.Context; -import android.graphics.drawable.ColorDrawable; -import android.view.LayoutInflater; -import android.view.View; -import android.view.Window; -import android.view.WindowManager; -import android.widget.TextView; import androidx.annotation.NonNull; -import androidx.appcompat.app.AlertDialog; import com.android.messaging.R; -/** Centered iOS-style alert for delete / delete-and-register-spam from home selection mode. */ +/** Home modal for moving conversations to Trash from the conversation list. */ public final class HomeDeleteAlertDialog { public interface Listener { void onDelete(); - - void onDeleteAndRegisterSpam(); } private HomeDeleteAlertDialog() { @@ -43,51 +34,17 @@ public final class HomeDeleteAlertDialog { public static void show(@NonNull final Context context, final int conversationCount, @NonNull final Listener listener) { - final View content = LayoutInflater.from(context).inflate( - R.layout.home_delete_alert_dialog, null); - - final TextView message = content.findViewById(R.id.home_delete_alert_message); - if (message != null) { - if (conversationCount == 1) { - message.setText(R.string.home_delete_alert_message_one); - } else { - message.setText(context.getString( - R.string.home_delete_alert_message_many, conversationCount)); - } - } - - final AlertDialog dialog = new AlertDialog.Builder(context, R.style.Home_AlertDialog) - .setView(content) - .setCancelable(true) - .create(); - - final View delete = content.findViewById(R.id.home_delete_alert_delete); - if (delete != null) { - delete.setOnClickListener(v -> { - dialog.dismiss(); - listener.onDelete(); - }); - } - - final View deleteSpam = content.findViewById(R.id.home_delete_alert_delete_spam); - if (deleteSpam != null) { - deleteSpam.setOnClickListener(v -> { - dialog.dismiss(); - listener.onDeleteAndRegisterSpam(); - }); - } - - dialog.show(); - - final Window window = dialog.getWindow(); - if (window != null) { - window.setBackgroundDrawable(new ColorDrawable(android.graphics.Color.TRANSPARENT)); - final int margin = context.getResources().getDimensionPixelSize( - R.dimen.home_alert_dialog_horizontal_margin); - window.setLayout( - WindowManager.LayoutParams.MATCH_PARENT, - WindowManager.LayoutParams.WRAP_CONTENT); - window.getDecorView().setPadding(margin, 0, margin, 0); - } + final String title = context.getResources().getQuantityString( + R.plurals.delete_conversations_confirmation_dialog_title, conversationCount); + final String message = conversationCount == 1 + ? context.getString(R.string.home_delete_alert_message_one) + : context.getString(R.string.home_delete_alert_message_many, conversationCount); + HomeModal.builder(context) + .icon(R.drawable.ic_home_menu_trash) + .title(title) + .message(message) + .positive(R.string.home_delete_confirm, listener::onDelete) + .negative(R.string.delete_conversation_decline_button, null) + .show(); } } diff --git a/app/src/main/java/com/android/messaging/ui/home/HomeInboxFilter.java b/app/src/main/java/com/android/messaging/ui/home/HomeInboxFilter.java new file mode 100644 index 0000000..41f29a7 --- /dev/null +++ b/app/src/main/java/com/android/messaging/ui/home/HomeInboxFilter.java @@ -0,0 +1,139 @@ +package com.android.messaging.ui.home; + +import android.content.Context; +import android.telephony.SubscriptionInfo; +import android.text.TextUtils; + +import com.android.messaging.R; +import com.android.messaging.datamodel.data.ConversationListItemData; +import com.android.messaging.util.SimNameUtil; + +import java.util.List; +import java.util.Locale; + +/** Work / Personal inbox filters: dual-SIM when present, otherwise people vs business. */ +public final class HomeInboxFilter { + private HomeInboxFilter() { + } + + public static boolean matchesWork(final Context context, final ConversationListItemData item) { + if (item == null) { + return false; + } + if (item.isEnterprise()) { + return true; + } + final List infos = SimNameUtil.getActiveSubscriptions(); + if (infos.size() >= 2) { + return isWorkSubscription(context, infos, + SimNameUtil.getSubscriptionForSelfId(item.getSelfId())); + } + return HomeCategoryClassifier.isPromotion(item); + } + + public static boolean matchesPersonal(final Context context, + final ConversationListItemData item) { + if (item == null) { + return false; + } + if (item.isEnterprise()) { + return false; + } + final List infos = SimNameUtil.getActiveSubscriptions(); + if (infos.size() >= 2) { + return !isWorkSubscription(context, infos, + SimNameUtil.getSubscriptionForSelfId(item.getSelfId())); + } + return HomeCategoryClassifier.isPersonal(item); + } + + public static String workSimName(final Context context) { + return SimNameUtil.getDisplayName(context, workSubscription(context)); + } + + public static String personalSimName(final Context context) { + return SimNameUtil.getDisplayName(context, personalSubscription(context)); + } + + public static SubscriptionInfo workSubscription(final Context context) { + final List infos = SimNameUtil.getActiveSubscriptions(); + if (infos.isEmpty()) { + return null; + } + for (final SubscriptionInfo info : infos) { + if (info != null && isWorkSubscription(context, infos, info)) { + return info; + } + } + return infos.get(infos.size() - 1); + } + + public static SubscriptionInfo personalSubscription(final Context context) { + final List infos = SimNameUtil.getActiveSubscriptions(); + if (infos.isEmpty()) { + return null; + } + for (final SubscriptionInfo info : infos) { + if (info != null && !isWorkSubscription(context, infos, info)) { + return info; + } + } + return infos.get(0); + } + + private static boolean isWorkSubscription(final Context context, + final List infos, final SubscriptionInfo match) { + if (match == null || infos == null || infos.isEmpty()) { + return false; + } + final boolean namedWork = looksLike(context, match, true); + final boolean namedPersonal = looksLike(context, match, false); + if (namedWork != namedPersonal) { + return namedWork; + } + int minSlot = Integer.MAX_VALUE; + int maxSlot = Integer.MIN_VALUE; + for (final SubscriptionInfo info : infos) { + if (info == null) { + continue; + } + final int slot = info.getSimSlotIndex(); + minSlot = Math.min(minSlot, slot); + maxSlot = Math.max(maxSlot, slot); + } + if (minSlot != maxSlot) { + return match.getSimSlotIndex() == maxSlot; + } + return infos.indexOf(match) == infos.size() - 1; + } + + private static boolean looksLike(final Context context, final SubscriptionInfo info, + final boolean work) { + final CharSequence displayName = info.getDisplayName(); + if (TextUtils.isEmpty(displayName)) { + return false; + } + final String name = displayName.toString().toLowerCase(Locale.ROOT); + if (work) { + final String label = context.getString(R.string.home_chip_work) + .toLowerCase(Locale.ROOT); + return containsToken(name, "work") + || containsToken(name, "office") + || name.contains("업무") + || name.contains("회사") + || (!TextUtils.isEmpty(label) && name.contains(label)); + } + final String label = context.getString(R.string.home_chip_personal) + .toLowerCase(Locale.ROOT); + return containsToken(name, "personal") + || containsToken(name, "private") + || containsToken(name, "home") + || name.contains("개인") + || name.contains("집") + || (!TextUtils.isEmpty(label) && name.contains(label)); + } + + private static boolean containsToken(final String haystack, final String token) { + return haystack.equals(token) || haystack.contains(token); + } +} diff --git a/app/src/main/java/com/android/messaging/ui/home/HomeInboxMenu.java b/app/src/main/java/com/android/messaging/ui/home/HomeInboxMenu.java new file mode 100644 index 0000000..9d8a1a4 --- /dev/null +++ b/app/src/main/java/com/android/messaging/ui/home/HomeInboxMenu.java @@ -0,0 +1,281 @@ +package com.android.messaging.ui.home; + +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.graphics.drawable.ColorDrawable; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.PopupWindow; +import android.widget.TextView; + +import com.android.messaging.R; +import com.android.messaging.ui.UIIntents; +import com.android.messaging.ui.conversationlist.AbstractConversationListActivity; + +/** Edit + filter chrome shared by home sub-lists (Spam / Archive / Trash). */ +public final class HomeInboxMenu { + + public enum Screen { + MESSAGES, + SPAM, + ARCHIVE, + TRASH + } + + private HomeInboxMenu() { + } + + public static void bind(final Activity activity, final Screen screen) { + if (activity == null) { + return; + } + final View actions = activity.findViewById(R.id.home_sub_header_inbox_actions); + final View edit = activity.findViewById(R.id.home_sub_header_edit); + final View filter = activity.findViewById(R.id.home_sub_header_filter); + if (actions == null || filter == null) { + return; + } + actions.setVisibility(View.VISIBLE); + filter.setOnClickListener(v -> showFilter(activity, v, screen)); + bindSelectAction(activity, v -> { + if (activity instanceof AbstractConversationListActivity) { + ((AbstractConversationListActivity) activity).onSubListPrimaryAction(); + } else if (activity instanceof HomeSpamActivity) { + ((HomeSpamActivity) activity).onSelectPrimaryAction(); + } + }); + if (edit == null) { + return; + } + edit.setTag(null); + if (edit instanceof TextView) { + final TextView text = (TextView) edit; + text.setText(R.string.home_select); + text.setTextColor(activity.getColor(R.color.home_text_primary)); + text.setCompoundDrawablesRelative(null, null, null, null); + text.setCompoundDrawablePadding(0); + } + edit.setVisibility(View.VISIBLE); + final View.OnClickListener toggleSelect = v -> { + if (activity instanceof AbstractConversationListActivity) { + final AbstractConversationListActivity list = + (AbstractConversationListActivity) activity; + if (list.isSelectionMode()) { + list.exitMultiSelectState(); + } else { + list.enterHomeEditMode(); + } + return; + } + if (activity instanceof HomeSpamActivity) { + ((HomeSpamActivity) activity).toggleSelectMode(); + } + }; + edit.setOnClickListener(toggleSelect); + final View done = activity.findViewById(R.id.home_sub_header_done); + if (done != null) { + done.setOnClickListener(toggleSelect); + } + } + + public static void setSelecting(final Activity activity, final boolean selecting) { + final View actions = activity == null + ? null : activity.findViewById(R.id.home_sub_header_inbox_actions); + final View done = activity == null ? null : activity.findViewById(R.id.home_sub_header_done); + if (selecting) { + HomeUiMotion.swapOverlayChip(actions, done); + } else { + HomeUiMotion.swapOverlayChip(done, actions); + } + final View bar = activity == null ? null : activity.findViewById(R.id.home_sub_select_bar); + HomeUiMotion.swapSubListDock(bar, selecting); + } + + public static void setSelectAction(final Activity activity, final int iconRes, + final int textResId, final boolean enabled, final boolean destructive) { + final View tab = activity == null + ? null : activity.findViewById(R.id.home_sub_select_action); + if (tab == null) { + return; + } + tab.setEnabled(enabled); + tab.setAlpha(enabled ? 1f : 0.4f); + final int color = activity.getColor(destructive + ? R.color.home_destructive + : R.color.home_nav_inactive); + final ImageView icon = tab.findViewById(R.id.home_nav_tab_icon); + if (icon != null) { + if (iconRes != 0) { + icon.setImageResource(iconRes); + } + icon.setImageTintList(android.content.res.ColorStateList.valueOf(color)); + } + final TextView label = tab.findViewById(R.id.home_nav_tab_label); + if (label != null) { + label.setText(textResId); + label.setTextColor(color); + label.setTypeface(null, android.graphics.Typeface.NORMAL); + } + } + + public static void bindSelectAction(final Activity activity, + final View.OnClickListener onAction) { + final View action = activity == null + ? null : activity.findViewById(R.id.home_sub_select_action); + if (action != null) { + action.setOnClickListener(onAction); + } + } + + private static void showFilter(final Activity activity, final View anchor, + final Screen screen) { + final PopupWindow popup = showPopover(activity, anchor, R.layout.home_popover_filter); + final View content = popup.getContentView(); + bindFilterSims(content); + bindFilterChecks(content, screen); + setGone(content, R.id.popover_unread); + setGone(content, R.id.popover_work); + setGone(content, R.id.popover_personal); + setGone(content, R.id.popover_filter_by_label); + setGone(content, R.id.popover_filter_by_divider); + content.findViewById(R.id.popover_messages).setOnClickListener(v -> { + popup.dismiss(); + if (screen != Screen.MESSAGES) { + activity.finish(); + } + }); + content.findViewById(R.id.popover_spam).setOnClickListener(v -> { + popup.dismiss(); + openScreen(activity, screen, Screen.SPAM, + () -> UIIntents.get().launchHomeSpamActivity(activity)); + }); + content.findViewById(R.id.popover_archive).setOnClickListener(v -> { + popup.dismiss(); + openScreen(activity, screen, Screen.ARCHIVE, + () -> UIIntents.get().launchArchivedConversationsActivity(activity)); + }); + content.findViewById(R.id.popover_recently_deleted).setOnClickListener(v -> { + popup.dismiss(); + openScreen(activity, screen, Screen.TRASH, + () -> UIIntents.get().launchRecentlyDeletedConversationsActivity(activity)); + }); + content.findViewById(R.id.popover_share_app).setOnClickListener(v -> { + popup.dismiss(); + shareThisApp(activity); + }); + content.findViewById(R.id.popover_settings).setOnClickListener(v -> { + popup.dismiss(); + UIIntents.get().launchSettingsActivity(activity); + activity.overridePendingTransition(R.anim.home_activity_open, + R.anim.home_activity_hold); + }); + } + + private static void showEdit(final Activity activity, final View anchor) { + final PopupWindow popup = showPopover(activity, anchor, R.layout.home_popover_edit); + final View content = popup.getContentView(); + final View pinned = content.findViewById(R.id.popover_edit_pinned); + if (pinned != null) { + pinned.setVisibility(View.GONE); + } + content.findViewById(R.id.popover_select_messages).setOnClickListener(v -> { + popup.dismiss(); + if (activity instanceof AbstractConversationListActivity) { + ((AbstractConversationListActivity) activity).enterHomeEditMode(); + } + }); + } + + private static void openScreen(final Activity activity, final Screen current, + final Screen target, final Runnable launch) { + if (current == target) { + return; + } + launch.run(); + activity.finish(); + activity.overridePendingTransition(R.anim.home_activity_open, R.anim.home_activity_hold); + } + + private static void bindFilterSims(final View content) { + final Context context = content.getContext(); + setFilterText(content, R.id.popover_work_badge, HomeInboxFilter.workSimName(context)); + setFilterText(content, R.id.popover_work_label, HomeInboxFilter.workSimName(context)); + setFilterText(content, R.id.popover_personal_badge, + HomeInboxFilter.personalSimName(context)); + setFilterText(content, R.id.popover_personal_label, + HomeInboxFilter.personalSimName(context)); + } + + private static void bindFilterChecks(final View content, final Screen screen) { + setCheck(content, R.id.popover_messages_check, screen == Screen.MESSAGES); + setCheck(content, R.id.popover_spam_check, screen == Screen.SPAM); + setCheck(content, R.id.popover_archive_check, screen == Screen.ARCHIVE); + setCheck(content, R.id.popover_recently_deleted_check, screen == Screen.TRASH); + setCheck(content, R.id.popover_unread_check, false); + setCheck(content, R.id.popover_work_check, false); + setCheck(content, R.id.popover_personal_check, false); + } + + private static void setCheck(final View content, final int id, final boolean on) { + final View check = content.findViewById(id); + if (check != null) { + check.setVisibility(on ? View.VISIBLE : View.INVISIBLE); + } + } + + private static void setFilterText(final View content, final int id, final String text) { + final View view = content.findViewById(id); + if (view instanceof TextView) { + ((TextView) view).setText(text); + } + } + + private static void setGone(final View content, final int id) { + final View view = content.findViewById(id); + if (view != null) { + view.setVisibility(View.GONE); + } + } + + private static PopupWindow showPopover(final Activity activity, final View anchor, + final int layoutResId) { + final View content = LayoutInflater.from(activity).inflate(layoutResId, null, false); + final int width = activity.getResources().getDimensionPixelSize(R.dimen.home_popover_width); + content.measure( + View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)); + final PopupWindow popup = new PopupWindow(content, width, + ViewGroup.LayoutParams.WRAP_CONTENT, true); + popup.setOutsideTouchable(true); + popup.setFocusable(true); + popup.setElevation(16f); + popup.setBackgroundDrawable(new ColorDrawable(android.graphics.Color.TRANSPARENT)); + final int endInset = activity.getResources().getDimensionPixelSize( + R.dimen.looply_screen_padding); + final int[] anchorLoc = new int[2]; + anchor.getLocationOnScreen(anchorLoc); + final int windowRight = activity.getResources().getDisplayMetrics().widthPixels; + int xOff = windowRight - endInset - width - anchorLoc[0]; + final int overflow = (anchorLoc[0] + xOff + width) - (windowRight - endInset); + if (overflow > 0) { + xOff -= overflow; + } + popup.showAsDropDown(anchor, xOff, 8); + HomeUiMotion.popoverEnter(content, true); + return popup; + } + + private static void shareThisApp(final Activity activity) { + final String storeUrl = "https://play.google.com/store/apps/details?id=" + + activity.getPackageName(); + final Intent share = new Intent(Intent.ACTION_SEND); + share.setType("text/plain"); + share.putExtra(Intent.EXTRA_TEXT, + activity.getString(R.string.home_share_app_text, storeUrl)); + activity.startActivity(Intent.createChooser(share, + activity.getString(R.string.home_popover_share_app))); + } +} diff --git a/app/src/main/java/com/android/messaging/ui/home/HomeMessageDetailsActivity.java b/app/src/main/java/com/android/messaging/ui/home/HomeMessageDetailsActivity.java new file mode 100644 index 0000000..4865387 --- /dev/null +++ b/app/src/main/java/com/android/messaging/ui/home/HomeMessageDetailsActivity.java @@ -0,0 +1,214 @@ +package com.android.messaging.ui.home; + +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import android.support.v7.mms.pdu.PduHeaders; +import android.text.TextUtils; +import android.text.format.DateUtils; +import android.text.format.Formatter; +import android.view.Gravity; +import android.view.View; +import android.widget.FrameLayout; +import android.widget.TextView; + +import com.android.messaging.R; +import com.android.messaging.datamodel.data.ConversationMessageData; +import com.android.messaging.datamodel.data.MessagePartData; +import com.android.messaging.sms.MmsUtils; +import com.android.messaging.ui.BugleActionBarActivity; + +import java.util.List; + +/** Home sub-screen for a single message's details. */ +public class HomeMessageDetailsActivity extends BugleActionBarActivity { + + static final String EXTRA_PREVIEW = "preview"; + static final String EXTRA_INCOMING = "incoming"; + static final String EXTRA_SMS = "sms"; + static final String EXTRA_SENT = "sent"; + static final String EXTRA_RECEIVED = "received"; + static final String EXTRA_SEND_COMPLETE = "send_complete"; + static final String EXTRA_PRIORITY = "priority"; + static final String EXTRA_SUBJECT = "subject"; + static final String EXTRA_SIZE = "size"; + + public static void start(final Context context, final ConversationMessageData data) { + if (context == null || data == null) { + return; + } + final Intent intent = new Intent(context, HomeMessageDetailsActivity.class); + intent.putExtra(EXTRA_PREVIEW, previewText(context, data)); + intent.putExtra(EXTRA_INCOMING, data.getIsIncoming()); + intent.putExtra(EXTRA_SMS, data.getIsSms()); + intent.putExtra(EXTRA_SENT, data.getSentTimeStamp()); + intent.putExtra(EXTRA_RECEIVED, data.getReceivedTimeStamp()); + intent.putExtra(EXTRA_SEND_COMPLETE, data.getIsSendComplete()); + intent.putExtra(EXTRA_PRIORITY, data.getSmsPriority()); + intent.putExtra(EXTRA_SUBJECT, data.getMmsSubject()); + intent.putExtra(EXTRA_SIZE, data.getSmsMessageSize()); + context.startActivity(intent); + } + + @Override + protected void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.home_message_details_activity); + if (getSupportActionBar() != null) { + getSupportActionBar().hide(); + } + HomeSubHeader.bind(this, R.string.home_message_details_title, v -> finish()); + bindDetails(getIntent()); + } + + private void bindDetails(final Intent intent) { + if (intent == null) { + finish(); + return; + } + final TextView bubble = findViewById(R.id.home_message_details_bubble); + final boolean incoming = intent.getBooleanExtra(EXTRA_INCOMING, true); + bubble.setText(intent.getStringExtra(EXTRA_PREVIEW)); + final FrameLayout.LayoutParams lp = (FrameLayout.LayoutParams) bubble.getLayoutParams(); + if (incoming) { + lp.gravity = Gravity.START; + bubble.setBackgroundResource(R.drawable.home_bg_preview_bubble_in); + bubble.setTextColor(getColor(R.color.home_text_primary)); + } else { + lp.gravity = Gravity.END; + bubble.setBackgroundResource(R.drawable.home_bg_preview_bubble_out); + bubble.setTextColor(getColor(R.color.looply_bubble_outgoing_text)); + } + bubble.setLayoutParams(lp); + + final boolean sms = intent.getBooleanExtra(EXTRA_SMS, true); + final TextView type = findViewById(R.id.home_message_details_type); + type.setText(sms ? R.string.text_message : R.string.multimedia_message); + + bindStatus(intent); + bindPriority(intent.getIntExtra(EXTRA_PRIORITY, PduHeaders.PRIORITY_NORMAL)); + bindOptional(R.id.home_message_details_subject_section, + R.id.home_message_details_subject, + MmsUtils.cleanseMmsSubject(getResources(), intent.getStringExtra(EXTRA_SUBJECT))); + final int size = intent.getIntExtra(EXTRA_SIZE, 0); + if (size > 0) { + bindOptional(R.id.home_message_details_size_section, + R.id.home_message_details_size, + Formatter.formatFileSize(this, size)); + } + } + + private void bindStatus(final Intent intent) { + final boolean incoming = intent.getBooleanExtra(EXTRA_INCOMING, true); + final boolean sendComplete = intent.getBooleanExtra(EXTRA_SEND_COMPLETE, false); + final long sent = intent.getLongExtra(EXTRA_SENT, MmsUtils.INVALID_TIMESTAMP); + final long received = intent.getLongExtra(EXTRA_RECEIVED, MmsUtils.INVALID_TIMESTAMP); + + final boolean showReceived = incoming && received != MmsUtils.INVALID_TIMESTAMP; + final boolean showSent; + final long sentDisplay; + if (incoming) { + showSent = sent != MmsUtils.INVALID_TIMESTAMP; + sentDisplay = sent; + } else if (sendComplete && received != MmsUtils.INVALID_TIMESTAMP) { + showSent = true; + sentDisplay = received; + } else { + showSent = sent != MmsUtils.INVALID_TIMESTAMP; + sentDisplay = sent; + } + + setRow(R.id.home_message_details_received_row, + R.id.home_message_details_received, showReceived, received); + setRow(R.id.home_message_details_sent_row, + R.id.home_message_details_sent, showSent, sentDisplay); + findViewById(R.id.home_message_details_status_divider) + .setVisibility(showReceived && showSent ? View.VISIBLE : View.GONE); + findViewById(R.id.home_message_details_status_section) + .setVisibility(showReceived || showSent ? View.VISIBLE : View.GONE); + } + + private void bindPriority(final int priority) { + final View section = findViewById(R.id.home_message_details_priority_section); + final TextView value = findViewById(R.id.home_message_details_priority); + final int label; + switch (priority) { + case PduHeaders.PRIORITY_HIGH: + label = R.string.priority_high; + break; + case PduHeaders.PRIORITY_LOW: + label = R.string.priority_low; + break; + case PduHeaders.PRIORITY_NORMAL: + default: + label = R.string.priority_normal; + break; + } + value.setText(label); + section.setVisibility(View.VISIBLE); + } + + private void bindOptional(final int sectionId, final int valueId, final CharSequence text) { + final View section = findViewById(sectionId); + final TextView value = findViewById(valueId); + if (TextUtils.isEmpty(text)) { + section.setVisibility(View.GONE); + return; + } + value.setText(text); + section.setVisibility(View.VISIBLE); + } + + private void setRow(final int rowId, final int valueId, final boolean show, final long time) { + final View row = findViewById(rowId); + final TextView value = findViewById(valueId); + if (!show || time == MmsUtils.INVALID_TIMESTAMP) { + row.setVisibility(View.GONE); + return; + } + value.setText(formatTime(time)); + row.setVisibility(View.VISIBLE); + } + + private String formatTime(final long time) { + return DateUtils.formatDateTime(this, time, + DateUtils.FORMAT_SHOW_DATE + | DateUtils.FORMAT_SHOW_YEAR + | DateUtils.FORMAT_SHOW_TIME + | DateUtils.FORMAT_ABBREV_MONTH); + } + + private static String previewText(final Context context, final ConversationMessageData data) { + final String text = data.getText(); + if (!TextUtils.isEmpty(text)) { + return text; + } + final List attachments = data.getAttachments(); + if (attachments != null) { + for (final MessagePartData part : attachments) { + if (part.isImage()) { + return context.getString(R.string.home_message_details_photo); + } + if (part.isVideo()) { + return context.getString(R.string.home_message_details_video); + } + if (part.isAudio()) { + return context.getString(R.string.home_message_details_audio); + } + if (part.isVCard()) { + return context.getString(R.string.home_message_details_contact); + } + } + if (!attachments.isEmpty()) { + return context.getString(R.string.home_message_details_attachment); + } + } + return context.getString(R.string.home_message_details_attachment); + } + + @Override + public void finish() { + super.finish(); + overridePendingTransition(R.anim.home_activity_hold, R.anim.home_activity_close); + } +} diff --git a/app/src/main/java/com/android/messaging/ui/home/HomeModal.java b/app/src/main/java/com/android/messaging/ui/home/HomeModal.java index e0a4424..172c480 100644 --- a/app/src/main/java/com/android/messaging/ui/home/HomeModal.java +++ b/app/src/main/java/com/android/messaging/ui/home/HomeModal.java @@ -97,8 +97,10 @@ public final class HomeModal { private CharSequence mTitle; private CharSequence mMessage; private CharSequence mPositive; + private CharSequence mNeutral; private CharSequence mNegative; private Action mOnPositive; + private Action mOnNeutral; private Action mOnNegative; private View mCustom; private boolean mCancelable = true; @@ -149,6 +151,18 @@ public final class HomeModal { return this; } + public Builder neutral(@StringRes final int labelRes, @Nullable final Action action) { + mNeutral = mContext.getString(labelRes); + mOnNeutral = action; + return this; + } + + public Builder neutral(final CharSequence label, @Nullable final Action action) { + mNeutral = label; + mOnNeutral = action; + return this; + } + public Builder negative(@StringRes final int labelRes, @Nullable final Action action) { mNegative = mContext.getString(labelRes); mOnNegative = action; @@ -195,9 +209,11 @@ public final class HomeModal { } final TextView positive = content.findViewById(R.id.home_modal_positive); + final TextView neutral = content.findViewById(R.id.home_modal_neutral); final TextView negative = content.findViewById(R.id.home_modal_negative); - bindButton(negative, options.mNegative, options.mOnNegative, dialog, true); bindButton(positive, options.mPositive, options.mOnPositive, dialog, true); + bindButton(neutral, options.mNeutral, options.mOnNeutral, dialog, true); + bindButton(negative, options.mNegative, options.mOnNegative, dialog, true); dialog.show(); diff --git a/app/src/main/java/com/android/messaging/ui/home/HomeProfileActivity.java b/app/src/main/java/com/android/messaging/ui/home/HomeProfileActivity.java index c47a9d8..3627d72 100644 --- a/app/src/main/java/com/android/messaging/ui/home/HomeProfileActivity.java +++ b/app/src/main/java/com/android/messaging/ui/home/HomeProfileActivity.java @@ -403,7 +403,16 @@ public class HomeProfileActivity extends BugleActionBarActivity } private ParticipantData getOtherParticipant() { - return mBinding.isBound() ? mBinding.getData().getOtherParticipant() : null; + if (mBinding.isBound()) { + final ParticipantData bound = mBinding.getData().getOtherParticipant(); + if (bound != null) { + return bound; + } + } + if (mParticipants != null && mParticipants.size() == 1) { + return mParticipants.get(0); + } + return null; } private String getCallablePhoneNumber() { @@ -460,15 +469,19 @@ public class HomeProfileActivity extends BugleActionBarActivity private void showRenameDialog() { final View content = LayoutInflater.from(this) - .inflate(R.layout.home_profile_rename_dialog, null); + .inflate(R.layout.home_profile_rename_dialog, null, false); final EditText field = content.findViewById(R.id.home_profile_rename_field); - field.setText(mNameView.getText()); - field.setSelection(field.getText().length()); - new AlertDialog.Builder(this, R.style.Home_Dialog) - .setTitle(R.string.home_profile_rename) - .setView(content) - .setNegativeButton(android.R.string.cancel, null) - .setPositiveButton(R.string.home_profile_rename_save, (dialog, which) -> { + final CharSequence current = mNameView == null ? "" : mNameView.getText(); + field.setText(current); + if (!TextUtils.isEmpty(current)) { + field.setSelection(current.length()); + } + final HomeModal.Handle handle = HomeModal.builder(this) + .icon(R.drawable.ic_home_edit) + .title(R.string.home_profile_rename) + .custom(content) + .negative(android.R.string.cancel, null) + .positive(R.string.home_profile_rename_save, () -> { final String name = field.getText().toString().trim(); // An empty name would hide the conversation everywhere it is listed. if (!TextUtils.isEmpty(name)) { @@ -476,6 +489,15 @@ public class HomeProfileActivity extends BugleActionBarActivity } }) .show(); + handle.dialog.setOnDismissListener(dialog -> { + if (!isFinishing() && mConversation != null) { + updateViews(); + } + }); + field.post(() -> { + field.requestFocus(); + ImeUtil.get().showImeKeyboard(this, field); + }); } private void openNotificationSettings() { @@ -522,15 +544,27 @@ public class HomeProfileActivity extends BugleActionBarActivity } private void confirmDelete() { - new AlertDialog.Builder(this, R.style.Home_Dialog) - .setTitle(getResources().getQuantityString( - R.plurals.delete_conversations_confirmation_dialog_title, 1)) - .setNegativeButton(R.string.delete_conversation_decline_button, null) - .setPositiveButton(R.string.delete_conversation_confirmation_button, - (dialog, which) -> { - mConversation.deleteConversation(); - closeConversation(); - }) + if (mConversation == null) { + return; + } + final boolean trashed = mConversation.getIsTrashed(); + HomeModal.builder(this) + .icon(R.drawable.ic_home_menu_trash) + .title(trashed + ? getString(R.string.home_trash_permanent_title) + : getResources().getQuantityString( + R.plurals.delete_conversations_confirmation_dialog_title, 1)) + .message(trashed + ? getString(R.string.home_trash_permanent_message_one) + : getString(R.string.home_delete_alert_message_one)) + .negative(R.string.delete_conversation_decline_button, null) + .positive(R.string.home_delete_confirm, () -> { + mConversation.deleteConversation(); + if (!trashed) { + HomeToast.show(this, R.string.home_trash_moved_toast_one); + } + closeConversation(); + }) .show(); } diff --git a/app/src/main/java/com/android/messaging/ui/home/HomeSpamActivity.java b/app/src/main/java/com/android/messaging/ui/home/HomeSpamActivity.java index 48ce7c4..cbc2f1f 100644 --- a/app/src/main/java/com/android/messaging/ui/home/HomeSpamActivity.java +++ b/app/src/main/java/com/android/messaging/ui/home/HomeSpamActivity.java @@ -17,14 +17,14 @@ package com.android.messaging.ui.home; import android.os.Bundle; -import android.widget.ImageButton; -import android.widget.TextView; import com.android.messaging.R; import com.android.messaging.ui.BugleActionBarActivity; /** Home-style screen to view and remove spam-registered (blocked) numbers. */ -public class HomeSpamActivity extends BugleActionBarActivity { +public class HomeSpamActivity extends BugleActionBarActivity implements HomeSpamFragment.Host { + + private boolean mSelecting; @Override protected void onCreate(final Bundle savedInstanceState) { @@ -34,16 +34,62 @@ public class HomeSpamActivity extends BugleActionBarActivity { getSupportActionBar().hide(); } - final ImageButton back = findViewById(R.id.home_sub_header_back); - if (back != null) { - back.setOnClickListener(v -> finish()); + HomeSubHeader.bind(this, R.string.home_spam_title, v -> onBackPressed()); + HomeInboxMenu.bind(this, HomeInboxMenu.Screen.SPAM); + } + + public void toggleSelectMode() { + if (!mSelecting) { + final HomeSpamFragment fragment = fragment(); + if (fragment == null || fragment.getVisibleCount() == 0) { + HomeToast.show(this, R.string.home_toast_nothing_to_select); + return; + } } - final TextView title = findViewById(R.id.home_sub_header_title); - if (title != null) { - title.setText(R.string.home_spam_title); + setSelecting(!mSelecting); + } + + public void onSelectPrimaryAction() { + final HomeSpamFragment fragment = fragment(); + if (fragment != null) { + fragment.unblockSelectedOrAll(); } } + void setSelecting(final boolean selecting) { + mSelecting = selecting; + final HomeSpamFragment fragment = fragment(); + if (fragment != null) { + fragment.setSelecting(selecting); + } else { + onSpamSelectionChanged(0, 0, selecting); + } + HomeInboxMenu.setSelecting(this, selecting); + } + + @Override + public void onSpamSelectionChanged(final int selectedCount, final int visibleCount, + final boolean selecting) { + mSelecting = selecting; + HomeInboxMenu.setSelectAction(this, R.drawable.ic_home_menu_spam, + selectedCount > 0 ? R.string.home_sub_unblock : R.string.home_sub_unblock_all, + visibleCount > 0, false); + } + + @Override + public void onBackPressed() { + if (mSelecting) { + setSelecting(false); + return; + } + super.onBackPressed(); + } + + private HomeSpamFragment fragment() { + return (HomeSpamFragment) getSupportFragmentManager() + .findFragmentById(R.id.home_spam_fragment_host); + } + @Override public void finish() { super.finish(); diff --git a/app/src/main/java/com/android/messaging/ui/home/HomeSpamFragment.java b/app/src/main/java/com/android/messaging/ui/home/HomeSpamFragment.java index 6725b3a..6373c5e 100644 --- a/app/src/main/java/com/android/messaging/ui/home/HomeSpamFragment.java +++ b/app/src/main/java/com/android/messaging/ui/home/HomeSpamFragment.java @@ -1,31 +1,20 @@ -/* - * Copyright (C) 2025 The LineageOS 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.home; import android.content.Context; import android.database.Cursor; import android.os.Bundle; +import android.text.TextUtils; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; -import android.widget.ListView; -import androidx.cursoradapter.widget.CursorAdapter; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.annotation.NonNull; import androidx.fragment.app.Fragment; import androidx.loader.app.LoaderManager; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; import com.android.messaging.R; import com.android.messaging.datamodel.DataModel; @@ -33,43 +22,177 @@ import com.android.messaging.datamodel.binding.Binding; import com.android.messaging.datamodel.binding.BindingBase; import com.android.messaging.datamodel.data.BlockedParticipantsData; import com.android.messaging.datamodel.data.BlockedParticipantsData.BlockedParticipantsDataListener; -import com.android.messaging.ui.BlockedParticipantListItemView; -import com.android.messaging.util.Assert; +import com.android.messaging.datamodel.data.ParticipantListItemData; +import com.android.messaging.ui.ContactIconView; +import com.android.messaging.ui.CursorRecyclerAdapter; -/** Home UI list of spam-registered (blocked) senders with unblock on tap. */ +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** Home UI list of spam-registered (blocked) senders. Tap Unblock to restore the number. */ public class HomeSpamFragment extends Fragment implements BlockedParticipantsDataListener { - private ListView mListView; + public interface Host { + void onSpamSelectionChanged(int selectedCount, int visibleCount, boolean selecting); + } + + private RecyclerView mListView; private HomeEmptyState mEmptyView; - private BlockedParticipantListAdapter mAdapter; + private SpamAdapter mAdapter; + private Host mHost; + private boolean mSelecting; + private final Set mSelectedDestinations = new HashSet<>(); private final Binding mBinding = BindingBase.createBinding(this); @Override public View onCreateView(final LayoutInflater inflater, final ViewGroup container, final Bundle savedInstanceState) { - final View root = inflater.inflate(R.layout.blocked_participants_fragment, container, - false); - mListView = root.findViewById(android.R.id.list); - mAdapter = new BlockedParticipantListAdapter(requireContext(), null); + final View root = inflater.inflate(R.layout.home_spam_fragment, container, false); + mListView = root.findViewById(R.id.home_spam_list); + mAdapter = new SpamAdapter(requireContext()); + mListView.setLayoutManager(new LinearLayoutManager(requireContext())); mListView.setAdapter(mAdapter); mBinding.bind(DataModel.get().createBlockedParticipantsData(requireContext(), this)); mBinding.getData().init(LoaderManager.getInstance(this), mBinding); - final View activityRoot = requireActivity().findViewById(R.id.home_spam_empty); - if (activityRoot instanceof HomeEmptyState) { - mEmptyView = (HomeEmptyState) activityRoot; + final View activityEmpty = requireActivity().findViewById(R.id.home_spam_empty); + if (activityEmpty instanceof HomeEmptyState) { + mEmptyView = (HomeEmptyState) activityEmpty; mEmptyView.setContent(R.drawable.ic_home_menu_spam, R.string.home_empty_spam_title, R.string.home_empty_spam_body); } + HomeSubListSearch.bind(requireActivity(), mListView, query -> { + if (mAdapter != null) { + mAdapter.setSearchQuery(query); + pruneSelection(); + updateEmptyState(); + notifyHost(); + } + }); + HomeSubListSearch.bindListFades(mListView, + root.findViewById(R.id.home_list_top_fade), + root.findViewById(R.id.home_list_bottom_fade)); + if (getActivity() instanceof Host) { + mHost = (Host) getActivity(); + } + notifyHost(); return root; } + void setSelecting(final boolean selecting) { + mSelecting = selecting; + if (!selecting) { + mSelectedDestinations.clear(); + } + notifyHost(); + final View list = mListView; + if (list != null) { + list.post(() -> { + if (!isAdded() || mListView == null) { + return; + } + applySelectPadding(); + if (mAdapter != null) { + mAdapter.notifyDataSetChanged(); + } + }); + } else { + applySelectPadding(); + if (mAdapter != null) { + mAdapter.notifyDataSetChanged(); + } + } + } + + int getSelectedCount() { + return mSelectedDestinations.size(); + } + + int getVisibleCount() { + return mAdapter == null ? 0 : mAdapter.getItemCount(); + } + + void unblockSelectedOrAll() { + final Context context = getContext(); + if (context == null || mAdapter == null) { + return; + } + final List targets = mSelectedDestinations.isEmpty() + ? mAdapter.snapshotVisible() + : mAdapter.snapshotSelected(mSelectedDestinations); + if (targets.isEmpty()) { + HomeToast.show(context, R.string.home_toast_nothing_to_unblock); + return; + } + for (final ParticipantListItemData data : targets) { + data.unblockQuietly(context); + } + HomeToast.show(context, R.string.home_sub_unblocked_toast); + if (getActivity() instanceof HomeSpamActivity) { + ((HomeSpamActivity) getActivity()).setSelecting(false); + } else { + setSelecting(false); + } + } + + private void applySelectPadding() { + if (mListView == null) { + return; + } + mListView.setPadding( + mListView.getPaddingLeft(), + mListView.getPaddingTop(), + mListView.getPaddingRight(), + getResources().getDimensionPixelSize(mSelecting + ? R.dimen.home_sub_select_list_padding + : R.dimen.looply_space_lg)); + } + + private void notifyHost() { + if (mHost != null) { + mHost.onSpamSelectionChanged(mSelectedDestinations.size(), + getVisibleCount(), mSelecting); + } + } + + private void onRowClicked(final ParticipantListItemData data) { + if (mSelecting) { + toggleSelected(data); + } else { + confirmUnblock(data); + } + } + + private void toggleSelected(final ParticipantListItemData data) { + if (data == null) { + return; + } + final String destination = data.getNormalizedDestination(); + if (TextUtils.isEmpty(destination)) { + return; + } + if (!mSelectedDestinations.add(destination)) { + mSelectedDestinations.remove(destination); + } + if (mAdapter != null) { + mAdapter.notifyDataSetChanged(); + } + notifyHost(); + } + + private boolean isSelected(final ParticipantListItemData data) { + return data != null && mSelectedDestinations.contains(data.getNormalizedDestination()); + } + @Override public void onDestroyView() { super.onDestroyView(); mEmptyView = null; mListView = null; + mAdapter = null; } @Override @@ -80,33 +203,249 @@ public class HomeSpamFragment extends Fragment implements BlockedParticipantsDat @Override public void onBlockedParticipantsCursorUpdated(final Cursor cursor) { - mAdapter.swapCursor(cursor); - final boolean empty = mAdapter.getCount() == 0; + if (mAdapter != null) { + mAdapter.swapCursor(cursor); + pruneSelection(); + } + updateEmptyState(); + notifyHost(); + } + + private void pruneSelection() { + if (mAdapter == null || mSelectedDestinations.isEmpty()) { + return; + } + final HashSet visible = new HashSet<>(); + for (final ParticipantListItemData data : mAdapter.snapshotVisible()) { + visible.add(data.getNormalizedDestination()); + } + mSelectedDestinations.retainAll(visible); + } + + private void updateEmptyState() { + final boolean empty = mAdapter == null || mAdapter.getItemCount() == 0; if (mListView != null) { mListView.setVisibility(empty ? View.GONE : View.VISIBLE); } if (mEmptyView != null) { + if (empty && mAdapter != null && mAdapter.hasSearchQuery()) { + mEmptyView.setContent(R.drawable.ic_home_search_line, + getString(R.string.home_empty_sub_search_title), + getString(R.string.home_empty_sub_search_body, mAdapter.getSearchQuery())); + } else if (empty) { + mEmptyView.setContent(R.drawable.ic_home_menu_spam, + R.string.home_empty_spam_title, + R.string.home_empty_spam_body); + } mEmptyView.setVisibility(empty ? View.VISIBLE : View.GONE); } } - private class BlockedParticipantListAdapter extends CursorAdapter { + private void confirmUnblock(final ParticipantListItemData data) { + final Context context = getContext(); + if (context == null || data == null) { + return; + } + final String name = data.getDisplayName(); + HomeModal.builder(context) + .icon(R.drawable.ic_home_menu_spam) + .title(R.string.home_spam_unblock_title) + .message(context.getString(R.string.home_spam_unblock_message, name)) + .negative(android.R.string.cancel, null) + .positive(R.string.home_spam_unblock_action, () -> data.unblock(context)) + .show(); + } - BlockedParticipantListAdapter(final Context context, final Cursor cursor) { - super(context, cursor, 0); + private final class SpamAdapter extends CursorRecyclerAdapter { + private String mSearchQuery = ""; + private int[] mDisplayPositions = new int[0]; + + SpamAdapter(final Context context) { + super(context, null, 0); + setHasStableIds(true); + } + + void setSearchQuery(final String query) { + final String next = query == null ? "" : query.trim(); + if (next.equals(mSearchQuery)) { + return; + } + mSearchQuery = next; + rebuildFilterIndex(); + notifyDataSetChanged(); + } + + String getSearchQuery() { + return mSearchQuery; + } + + boolean hasSearchQuery() { + return !mSearchQuery.isEmpty(); } @Override - public View newView(final Context context, final Cursor cursor, final ViewGroup parent) { - return LayoutInflater.from(context).inflate( - R.layout.blocked_participant_list_item_view, parent, false); + public Cursor swapCursor(final Cursor newCursor) { + final Cursor old = super.swapCursor(newCursor); + rebuildFilterIndex(); + notifyDataSetChanged(); + return old; } @Override - public void bindView(final View view, final Context context, final Cursor cursor) { - Assert.isTrue(view instanceof BlockedParticipantListItemView); - final BlockedParticipantListItemView item = (BlockedParticipantListItemView) view; - item.bind(mBinding.getData().createParticipantListItemData(cursor)); + public int getItemCount() { + return mDisplayPositions.length; + } + + @Override + public long getItemId(final int position) { + return super.getItemId(mapPosition(position)); + } + + @Override + public void onBindViewHolder(@NonNull final SpamHolder holder, final int position) { + super.onBindViewHolder(holder, mapPosition(position)); + } + + @Override + public SpamHolder createViewHolder(final Context context, final ViewGroup parent, + final int viewType) { + final View item = LayoutInflater.from(context).inflate( + R.layout.home_spam_list_item, parent, false); + return new SpamHolder(item); + } + + @Override + public void bindViewHolder(final SpamHolder holder, final Context context, + final Cursor cursor) { + if (!mBinding.isBound()) { + return; + } + holder.bind(mBinding.getData().createParticipantListItemData(cursor)); + } + + private int mapPosition(final int position) { + if (position < 0 || position >= mDisplayPositions.length) { + return position; + } + return mDisplayPositions[position]; + } + + private void rebuildFilterIndex() { + final Cursor cursor = getCursor(); + if (cursor == null || cursor.getCount() == 0) { + mDisplayPositions = new int[0]; + return; + } + final int saved = cursor.getPosition(); + final int[] matches = new int[cursor.getCount()]; + int count = 0; + for (int i = 0; i < cursor.getCount(); i++) { + if (!cursor.moveToPosition(i)) { + continue; + } + final ParticipantListItemData data = + mBinding.isBound() ? mBinding.getData().createParticipantListItemData(cursor) + : null; + if (data == null) { + continue; + } + if (HomeSubListSearch.matches(mSearchQuery, data.getDisplayName(), + data.getDetails(), data.getNormalizedDestination())) { + matches[count++] = i; + } + } + if (saved >= 0 && saved < cursor.getCount()) { + cursor.moveToPosition(saved); + } + mDisplayPositions = new int[count]; + System.arraycopy(matches, 0, mDisplayPositions, 0, count); + } + + List snapshotVisible() { + return snapshotMatching(null); + } + + List snapshotSelected(final Set destinations) { + return snapshotMatching(destinations); + } + + private List snapshotMatching(final Set destinations) { + final List out = new ArrayList<>(); + final Cursor cursor = getCursor(); + if (cursor == null || !mBinding.isBound()) { + return out; + } + final int saved = cursor.getPosition(); + for (int i = 0; i < mDisplayPositions.length; i++) { + if (!cursor.moveToPosition(mDisplayPositions[i])) { + continue; + } + final ParticipantListItemData data = + mBinding.getData().createParticipantListItemData(cursor); + if (data == null) { + continue; + } + if (destinations == null + || destinations.contains(data.getNormalizedDestination())) { + out.add(data); + } + } + if (saved >= 0 && saved < cursor.getCount()) { + cursor.moveToPosition(saved); + } + return out; + } + } + + private final class SpamHolder extends RecyclerView.ViewHolder { + private final ContactIconView mAvatar; + private final TextView mName; + private final TextView mDetails; + private final View mUnblock; + private final View mIndicator; + private final View mRing; + private final ImageView mCheck; + private ParticipantListItemData mData; + + SpamHolder(final View itemView) { + super(itemView); + mAvatar = itemView.findViewById(R.id.home_spam_avatar); + mName = itemView.findViewById(R.id.home_spam_name); + mDetails = itemView.findViewById(R.id.home_spam_details); + mUnblock = itemView.findViewById(R.id.home_spam_unblock); + mIndicator = itemView.findViewById(R.id.home_spam_selection_indicator); + mRing = itemView.findViewById(R.id.home_spam_selection_ring); + mCheck = itemView.findViewById(R.id.home_spam_selection_check); + itemView.setOnClickListener(v -> onRowClicked(mData)); + mUnblock.setOnClickListener(v -> confirmUnblock(mData)); + } + + void bind(final ParticipantListItemData data) { + mData = data; + mName.setText(data.getDisplayName()); + final String details = data.getDetails(); + if (TextUtils.isEmpty(details) + || TextUtils.equals(details, data.getDisplayName())) { + mDetails.setText(R.string.home_spam_item_hint); + } else { + mDetails.setText(details); + } + mAvatar.setImageResourceUri(data.getAvatarUri(), data.getContactId(), + data.getLookupKey(), data.getNormalizedDestination()); + mAvatar.setClickable(!mSelecting); + mUnblock.setVisibility(mSelecting ? View.GONE : View.VISIBLE); + if (mIndicator != null) { + mIndicator.setVisibility(mSelecting ? View.VISIBLE : View.GONE); + } + final boolean selected = mSelecting && isSelected(data); + if (mRing != null) { + mRing.setBackgroundResource(selected + ? R.drawable.home_bg_selection_ring_checked + : R.drawable.home_bg_selection_ring); + } + if (mCheck != null) { + mCheck.setVisibility(selected ? View.VISIBLE : View.GONE); + } } } } diff --git a/app/src/main/java/com/android/messaging/ui/home/HomeSubListSearch.java b/app/src/main/java/com/android/messaging/ui/home/HomeSubListSearch.java new file mode 100644 index 0000000..b88e084 --- /dev/null +++ b/app/src/main/java/com/android/messaging/ui/home/HomeSubListSearch.java @@ -0,0 +1,363 @@ +package com.android.messaging.ui.home; + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.animation.ValueAnimator; +import android.app.Activity; +import android.text.Editable; +import android.text.TextUtils; +import android.text.TextWatcher; +import android.view.View; +import android.view.ViewGroup; +import android.view.inputmethod.EditorInfo; +import android.widget.EditText; +import android.widget.ImageButton; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import com.android.messaging.R; +import com.android.messaging.util.ImeUtil; +import com.android.messaging.util.UiUtils; + +/** Binds the shared home-style search field on Spam / Archive / Trash lists. */ +public final class HomeSubListSearch { + + private static final int ANIM_MS = 220; + private static final int SCROLL_THRESHOLD_DP = 10; + + public interface Listener { + void onQueryChanged(String query); + } + + private static final class Chrome { + View collapse; + View search; + EditText field; + int naturalHeight; + boolean shown; + int accum; + ValueAnimator heightAnimator; + } + + private HomeSubListSearch() { + } + + public static boolean matches(final String query, final String... fields) { + if (TextUtils.isEmpty(query)) { + return true; + } + final String needle = query.trim().toLowerCase(); + if (needle.isEmpty()) { + return true; + } + final String digits = digitsOnly(needle); + for (final String field : fields) { + if (TextUtils.isEmpty(field)) { + continue; + } + if (field.toLowerCase().contains(needle)) { + return true; + } + if (!digits.isEmpty() && digitsOnly(field).contains(digits)) { + return true; + } + } + return false; + } + + public static void bind(final Activity activity, final Listener listener) { + bind(activity, null, listener); + } + + public static void bind(final Activity activity, final RecyclerView list, + final Listener listener) { + if (activity == null || listener == null) { + return; + } + final EditText field = activity.findViewById(R.id.home_sub_search_field); + final ImageButton clear = activity.findViewById(R.id.home_sub_search_clear); + if (field == null) { + return; + } + field.addTextChangedListener(new TextWatcher() { + @Override + public void beforeTextChanged(final CharSequence s, final int start, final int count, + final int after) { + } + + @Override + public void onTextChanged(final CharSequence s, final int start, final int before, + final int count) { + } + + @Override + public void afterTextChanged(final Editable s) { + final String query = s == null ? "" : s.toString(); + if (clear != null) { + clear.setVisibility(TextUtils.isEmpty(query) ? View.GONE : View.VISIBLE); + } + listener.onQueryChanged(query); + } + }); + field.setOnEditorActionListener((v, actionId, event) -> { + if (actionId == EditorInfo.IME_ACTION_SEARCH || actionId == EditorInfo.IME_ACTION_DONE) { + ImeUtil.get().hideImeKeyboard(activity, field); + return true; + } + return false; + }); + if (clear != null) { + clear.setOnClickListener(v -> field.setText("")); + } + attachScrollChrome(activity, list); + } + + public static void bindListFades(final RecyclerView list, final View topFade, + final View bottomFade) { + if (list == null) { + return; + } + final Runnable update = () -> { + setFadeVisible(topFade, !isListFlushAtTop(list)); + setFadeVisible(bottomFade, !isListFlushAtBottom(list)); + }; + list.addOnScrollListener(new RecyclerView.OnScrollListener() { + @Override + public void onScrolled(@NonNull final RecyclerView recyclerView, final int dx, + final int dy) { + update.run(); + } + }); + list.addOnLayoutChangeListener((v, l, t, r, b, ol, ot, or, ob) -> update.run()); + list.post(update); + } + + private static void setFadeVisible(final View fade, final boolean show) { + if (fade == null) { + return; + } + final int visibility = show ? View.VISIBLE : View.GONE; + if (fade.getVisibility() != visibility) { + fade.setVisibility(visibility); + } + } + + private static void attachScrollChrome(final Activity activity, final RecyclerView list) { + if (activity == null || list == null) { + return; + } + final View collapse = activity.findViewById(R.id.home_sub_search_collapse); + final View search = activity.findViewById(R.id.home_sub_search_box); + final EditText field = activity.findViewById(R.id.home_sub_search_field); + if (collapse == null || search == null) { + return; + } + final Chrome chrome = new Chrome(); + chrome.collapse = collapse; + chrome.search = search; + chrome.field = field; + search.setVisibility(View.INVISIBLE); + search.setAlpha(1f); + setChromeInteractive(search, false); + applyCollapseHeight(collapse, 0, false); + collapse.addOnLayoutChangeListener((v, l, t, r, b, ol, ot, or, ob) -> { + if (chrome.shown && v.getHeight() > 0) { + chrome.naturalHeight = v.getHeight(); + } + }); + if (field != null) { + field.setOnFocusChangeListener((v, hasFocus) -> { + if (hasFocus) { + setCollapseShown(chrome, true, true); + } + }); + } + list.post(() -> applyChromeSlot(chrome, chrome.shown, false)); + list.addOnScrollListener(new RecyclerView.OnScrollListener() { + @Override + public void onScrolled(@NonNull final RecyclerView recyclerView, final int dx, + final int dy) { + if (dy == 0 || isSearchPinned(chrome)) { + return; + } + if (isScrolledToFirstItem(recyclerView) && dy < 0) { + chrome.accum = 0; + if (field != null) { + ImeUtil.get().hideImeKeyboard(activity, field); + } + setCollapseShown(chrome, false, true); + return; + } + chrome.accum += dy; + final int threshold = dp(recyclerView, SCROLL_THRESHOLD_DP); + if (chrome.accum > threshold) { + chrome.accum = 0; + setCollapseShown(chrome, true, true); + } else if (chrome.accum < -threshold) { + chrome.accum = 0; + if (field != null) { + ImeUtil.get().hideImeKeyboard(activity, field); + } + setCollapseShown(chrome, false, true); + } + } + }); + } + + private static boolean isSearchPinned(final Chrome chrome) { + if (chrome.field == null) { + return false; + } + return chrome.field.hasFocus() || !TextUtils.isEmpty(chrome.field.getText()); + } + + private static boolean isScrolledToFirstItem(final RecyclerView list) { + final RecyclerView.LayoutManager manager = list.getLayoutManager(); + if (!(manager instanceof LinearLayoutManager)) { + return !list.canScrollVertically(-1); + } + final int first = ((LinearLayoutManager) manager).findFirstCompletelyVisibleItemPosition(); + return first != RecyclerView.NO_POSITION && first <= 0; + } + + private static boolean isListFlushAtTop(final RecyclerView list) { + if (list.getAdapter() == null || list.getAdapter().getItemCount() == 0) { + return true; + } + final RecyclerView.LayoutManager manager = list.getLayoutManager(); + if (!(manager instanceof LinearLayoutManager)) { + return !list.canScrollVertically(-1); + } + final LinearLayoutManager linear = (LinearLayoutManager) manager; + if (linear.findFirstVisibleItemPosition() > 0) { + return false; + } + final View first = linear.findViewByPosition(0); + if (first == null) { + return !list.canScrollVertically(-1); + } + return first.getTop() >= list.getPaddingTop() - 1; + } + + private static boolean isListFlushAtBottom(final RecyclerView list) { + if (list.getAdapter() == null || list.getAdapter().getItemCount() == 0) { + return true; + } + return !list.canScrollVertically(1); + } + + /** Messages chrome: search slides in a clipped slot; slot is 0 when hidden so the list is flush. */ + private static void setCollapseShown(final Chrome chrome, final boolean shown, + final boolean animate) { + if (chrome.shown == shown) { + return; + } + chrome.shown = shown; + applyChromeSlot(chrome, shown, animate); + } + + private static void applyChromeSlot(final Chrome chrome, final boolean searchShown, + final boolean animate) { + final int slot = resolveNaturalHeight(chrome); + final float searchY = searchShown ? 0f : -slot; + final int targetHeight = searchShown ? slot : 0; + chrome.search.animate().cancel(); + if (chrome.heightAnimator != null) { + chrome.heightAnimator.cancel(); + chrome.heightAnimator = null; + } + chrome.search.setAlpha(1f); + setChromeInteractive(chrome.search, searchShown); + if (!animate || slot <= 0) { + chrome.search.setTranslationY(searchY); + chrome.search.setVisibility(searchShown ? View.VISIBLE : View.INVISIBLE); + applyCollapseHeight(chrome.collapse, targetHeight, searchShown); + return; + } + chrome.search.setVisibility(View.VISIBLE); + chrome.search.animate() + .translationY(searchY) + .setDuration(ANIM_MS) + .setInterpolator(UiUtils.DEFAULT_INTERPOLATOR) + .withEndAction(() -> { + if (!chrome.shown) { + chrome.search.setVisibility(View.INVISIBLE); + } + }); + final int from = chrome.collapse.getHeight(); + final ValueAnimator va = ValueAnimator.ofInt(from, targetHeight); + chrome.heightAnimator = va; + va.setDuration(ANIM_MS); + va.setInterpolator(UiUtils.DEFAULT_INTERPOLATOR); + va.addUpdateListener(a -> applyCollapseHeight(chrome.collapse, + (int) a.getAnimatedValue(), false)); + va.addListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(final Animator animation) { + applyCollapseHeight(chrome.collapse, targetHeight, searchShown); + chrome.heightAnimator = null; + } + }); + va.start(); + } + + private static void applyCollapseHeight(final View collapse, final int height, + final boolean wrap) { + final ViewGroup.LayoutParams lp = collapse.getLayoutParams(); + if (lp == null) { + return; + } + lp.height = wrap && height > 0 ? ViewGroup.LayoutParams.WRAP_CONTENT : height; + collapse.setLayoutParams(lp); + } + + private static void setChromeInteractive(final View view, final boolean interactive) { + if (view == null) { + return; + } + view.setClickable(interactive); + view.setImportantForAccessibility(interactive + ? View.IMPORTANT_FOR_ACCESSIBILITY_YES + : View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS); + if (view instanceof ViewGroup) { + ((ViewGroup) view).setDescendantFocusability(interactive + ? ViewGroup.FOCUS_AFTER_DESCENDANTS + : ViewGroup.FOCUS_BLOCK_DESCENDANTS); + } + view.setOnTouchListener(interactive ? null : (v, event) -> true); + } + + private static int resolveNaturalHeight(final Chrome chrome) { + if (chrome.naturalHeight > 0) { + return chrome.naturalHeight; + } + if (chrome.search.getHeight() > 0) { + chrome.naturalHeight = chrome.search.getHeight(); + return chrome.naturalHeight; + } + final int width = Math.max(chrome.collapse.getWidth(), chrome.search.getWidth()); + chrome.search.measure( + View.MeasureSpec.makeMeasureSpec(Math.max(width, 0), + width > 0 ? View.MeasureSpec.EXACTLY : View.MeasureSpec.UNSPECIFIED), + View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)); + chrome.naturalHeight = chrome.search.getMeasuredHeight(); + return chrome.naturalHeight; + } + + private static int dp(final View view, final int dp) { + return (int) (dp * view.getResources().getDisplayMetrics().density + 0.5f); + } + + private static String digitsOnly(final String value) { + final StringBuilder out = new StringBuilder(value.length()); + for (int i = 0; i < value.length(); i++) { + final char c = value.charAt(i); + if (Character.isDigit(c)) { + out.append(c); + } + } + return out.toString(); + } +} diff --git a/app/src/main/java/com/android/messaging/ui/home/HomeUiMotion.java b/app/src/main/java/com/android/messaging/ui/home/HomeUiMotion.java index c396d93..08c35d6 100644 --- a/app/src/main/java/com/android/messaging/ui/home/HomeUiMotion.java +++ b/app/src/main/java/com/android/messaging/ui/home/HomeUiMotion.java @@ -282,6 +282,97 @@ public final class HomeUiMotion { } } + /** + * Overlay chip swap used by Select/Edit ↔ round check (list, archive, spam). + * Incoming pops in (scale + fade); outgoing shrinks out. + */ + public static void swapOverlayChip(final View outgoing, final View incoming) { + if (incoming == null && outgoing == null) { + return; + } + final boolean alreadyIn = incoming != null + && incoming.getVisibility() == View.VISIBLE + && incoming.getAlpha() > 0.99f + && outgoing != null + && outgoing.getVisibility() != View.VISIBLE; + if (alreadyIn) { + return; + } + if (!enabled()) { + applyOverlayChipInstant(outgoing, incoming); + return; + } + if (outgoing != null) { + outgoing.setTag(R.id.home_sub_header_done, null); + outgoing.animate().cancel(); + if (outgoing.getVisibility() == View.VISIBLE) { + beginLayer(outgoing); + outgoing.animate() + .alpha(0f) + .scaleX(0.6f) + .scaleY(0.6f) + .setDuration(DOCK_SWAP_MS) + .setInterpolator(EASE_MATERIAL) + .withEndAction(() -> { + outgoing.setVisibility(View.INVISIBLE); + outgoing.setAlpha(1f); + outgoing.setScaleX(1f); + outgoing.setScaleY(1f); + endLayer(outgoing); + }) + .start(); + } else if (outgoing.getVisibility() != View.INVISIBLE) { + outgoing.setVisibility(View.INVISIBLE); + } + } + if (incoming != null) { + incoming.animate().cancel(); + incoming.setVisibility(View.VISIBLE); + incoming.setAlpha(0f); + incoming.setScaleX(0.6f); + incoming.setScaleY(0.6f); + beginLayer(incoming); + final Runnable play = () -> incoming.animate() + .alpha(1f) + .scaleX(1f) + .scaleY(1f) + .setDuration(DOCK_SWAP_MS) + .setInterpolator(EASE_MATERIAL) + .withEndAction(() -> endLayer(incoming)) + .start(); + if (incoming.getWidth() == 0) { + final Object token = new Object(); + incoming.setTag(R.id.home_sub_header_done, token); + incoming.post(() -> { + if (incoming.getTag(R.id.home_sub_header_done) != token) { + return; + } + play.run(); + }); + } else { + incoming.setTag(R.id.home_sub_header_done, null); + play.run(); + } + } + } + + private static void applyOverlayChipInstant(final View outgoing, final View incoming) { + if (incoming != null) { + incoming.animate().cancel(); + incoming.setVisibility(View.VISIBLE); + incoming.setAlpha(1f); + incoming.setScaleX(1f); + incoming.setScaleY(1f); + } + if (outgoing != null && outgoing != incoming) { + outgoing.animate().cancel(); + outgoing.setVisibility(View.INVISIBLE); + outgoing.setAlpha(1f); + outgoing.setScaleX(1f); + outgoing.setScaleY(1f); + } + } + /** * Swaps two headers that share a vertical container, so they are never both laid out. */ @@ -429,6 +520,71 @@ public final class HomeUiMotion { } } + /** + * Spam / Archive / Trash selection dock. Animates the inner pill after the overlay has + * laid out so a GONE→VISIBLE snap does not cut the scale+fade short. + */ + public static void swapSubListDock(final View bar, final boolean selectionActive) { + if (bar == null) { + return; + } + final View pill = bar.findViewById(R.id.home_sub_select_pill); + final View target = pill != null ? pill : bar; + target.animate().cancel(); + bar.setTag(R.id.home_sub_select_bar, null); + if (!enabled()) { + bar.setVisibility(selectionActive ? View.VISIBLE : View.GONE); + resetDockTransform(target); + return; + } + if (selectionActive) { + final boolean showing = bar.getVisibility() == View.VISIBLE + && target.getAlpha() > 0.99f + && target.getScaleX() > 0.99f; + if (showing) { + return; + } + bar.setVisibility(View.VISIBLE); + target.setAlpha(0f); + target.setScaleX(0.86f); + target.setScaleY(0.86f); + beginLayer(target); + final Object token = new Object(); + bar.setTag(R.id.home_sub_select_bar, token); + bar.post(() -> { + if (bar.getTag(R.id.home_sub_select_bar) != token) { + return; + } + target.animate() + .alpha(1f) + .scaleX(1f) + .scaleY(1f) + .setDuration(DOCK_SWAP_MS) + .setInterpolator(EASE_MATERIAL) + .withEndAction(() -> endLayer(target)) + .start(); + }); + return; + } + if (bar.getVisibility() != View.VISIBLE) { + resetDockTransform(target); + return; + } + beginLayer(target); + target.animate() + .alpha(0f) + .scaleX(0.86f) + .scaleY(0.86f) + .setDuration(DOCK_SWAP_MS) + .setInterpolator(EASE_MATERIAL) + .withEndAction(() -> { + bar.setVisibility(View.GONE); + resetDockTransform(target); + endLayer(target); + }) + .start(); + } + /** Main bottom nav ↔ selection action dock; both sit stacked at the bottom of a frame. */ public static void swapBottomDock(final View mainNav, final View selectionBar, final boolean selectionActive, final Runnable onEnd) { @@ -445,6 +601,23 @@ public final class HomeUiMotion { } return; } + final boolean barVisible = selectionBar != null + && selectionBar.getVisibility() == View.VISIBLE + && selectionBar.getAlpha() > 0.99f; + if (selectionActive && barVisible) { + if (onEnd != null) { + onEnd.run(); + } + return; + } + if (!selectionActive && (selectionBar == null + || selectionBar.getVisibility() != View.VISIBLE) + && (mainNav == null || mainNav.getVisibility() == View.VISIBLE)) { + if (onEnd != null) { + onEnd.run(); + } + return; + } if (mainNav != null) { mainNav.animate().cancel(); } @@ -456,14 +629,17 @@ public final class HomeUiMotion { if (selectionActive) { if (mainNav != null && mainNav.getVisibility() == View.VISIBLE) { + beginLayer(mainNav); mainNav.animate() .alpha(0f) + .scaleX(0.88f) + .scaleY(0.88f) .setDuration(DOCK_SWAP_MS) .setInterpolator(EASE_MATERIAL) .withEndAction(() -> { mainNav.setVisibility(View.GONE); - mainNav.setAlpha(1f); - mainNav.setTranslationY(0f); + resetDockTransform(mainNav); + endLayer(mainNav); }) .start(); } else if (mainNav != null) { @@ -472,26 +648,41 @@ public final class HomeUiMotion { if (selectionBar != null) { selectionBar.setVisibility(View.VISIBLE); selectionBar.setAlpha(0f); - selectionBar.setTranslationY(0f); - selectionBar.animate() + selectionBar.setScaleX(0.86f); + selectionBar.setScaleY(0.86f); + beginLayer(selectionBar); + final Runnable play = () -> selectionBar.animate() .alpha(1f) + .scaleX(1f) + .scaleY(1f) .setDuration(DOCK_SWAP_MS) .setInterpolator(EASE_MATERIAL) - .withEndAction(finish) + .withEndAction(() -> { + endLayer(selectionBar); + finish.run(); + }) .start(); + if (selectionBar.getWidth() == 0) { + selectionBar.post(play); + } else { + play.run(); + } } else { finish.run(); } } else { if (selectionBar != null && selectionBar.getVisibility() == View.VISIBLE) { + beginLayer(selectionBar); selectionBar.animate() .alpha(0f) + .scaleX(0.86f) + .scaleY(0.86f) .setDuration(DOCK_SWAP_MS) .setInterpolator(EASE_MATERIAL) .withEndAction(() -> { selectionBar.setVisibility(View.GONE); - selectionBar.setAlpha(1f); - selectionBar.setTranslationY(0f); + resetDockTransform(selectionBar); + endLayer(selectionBar); }) .start(); } else if (selectionBar != null) { @@ -500,12 +691,19 @@ public final class HomeUiMotion { if (mainNav != null) { mainNav.setVisibility(View.VISIBLE); mainNav.setAlpha(0f); - mainNav.setTranslationY(0f); + mainNav.setScaleX(0.88f); + mainNav.setScaleY(0.88f); + beginLayer(mainNav); mainNav.animate() .alpha(1f) + .scaleX(1f) + .scaleY(1f) .setDuration(DOCK_SWAP_MS) .setInterpolator(EASE_MATERIAL) - .withEndAction(finish) + .withEndAction(() -> { + endLayer(mainNav); + finish.run(); + }) .start(); } else { finish.run(); @@ -731,16 +929,21 @@ public final class HomeUiMotion { final boolean selectionActive) { if (mainNav != null) { mainNav.setVisibility(selectionActive ? View.GONE : View.VISIBLE); - mainNav.setAlpha(1f); - mainNav.setTranslationY(0f); + resetDockTransform(mainNav); } if (selectionBar != null) { selectionBar.setVisibility(selectionActive ? View.VISIBLE : View.GONE); - selectionBar.setAlpha(1f); - selectionBar.setTranslationY(0f); + resetDockTransform(selectionBar); } } + private static void resetDockTransform(final View view) { + view.setAlpha(1f); + view.setTranslationY(0f); + view.setScaleX(1f); + view.setScaleY(1f); + } + public static void popoverResetInstant(final View content) { if (content == null) { return; diff --git a/app/src/main/java/com/android/messaging/util/SimNameUtil.java b/app/src/main/java/com/android/messaging/util/SimNameUtil.java index a6f7c41..23e7c10 100644 --- a/app/src/main/java/com/android/messaging/util/SimNameUtil.java +++ b/app/src/main/java/com/android/messaging/util/SimNameUtil.java @@ -8,6 +8,7 @@ import android.text.TextUtils; import com.android.messaging.R; import com.android.messaging.datamodel.data.ParticipantData; +import java.util.Collections; import java.util.List; public final class SimNameUtil { @@ -25,28 +26,45 @@ public final class SimNameUtil { } } - public static String getSimDisplayName(final Context context, final String selfId) { - List infos; + public static List getActiveSubscriptions() { try { - infos = PhoneUtils.getDefault().getActiveSubscriptionInfoList(); + final List infos = + PhoneUtils.getDefault().getActiveSubscriptionInfoList(); + if (infos == null) { + return Collections.emptyList(); + } + return infos; } catch (final Exception ignored) { - infos = null; + return Collections.emptyList(); } - if (infos == null || infos.isEmpty()) { - return context.getString(R.string.sim_slot_identifier, "1"); + } + + public static SubscriptionInfo getSubscriptionForSelfId(final String selfId) { + final List infos = getActiveSubscriptions(); + if (infos.isEmpty()) { + return null; } - SubscriptionInfo match = infos.get(0); - if (infos.size() > 1 && !TextUtils.isEmpty(selfId)) { + if (!TextUtils.isEmpty(selfId)) { for (final SubscriptionInfo info : infos) { final String participantId = ParticipantData.getCachedParticipantId(info.getSubscriptionId()); if (TextUtils.equals(participantId, selfId)) { - match = info; - break; + return info; } } } - return formatName(context, match); + return infos.get(0); + } + + public static String getDisplayName(final Context context, final SubscriptionInfo info) { + if (info == null) { + return context.getString(R.string.sim_slot_identifier, "1"); + } + return formatName(context, info); + } + + public static String getSimDisplayName(final Context context, final String selfId) { + return getDisplayName(context, getSubscriptionForSelfId(selfId)); } private static final int NAME_SOURCE_USER_INPUT = 2; diff --git a/app/src/main/java/com/android/messaging/widget/WidgetConversationListService.java b/app/src/main/java/com/android/messaging/widget/WidgetConversationListService.java index 096783b..603d0f6 100644 --- a/app/src/main/java/com/android/messaging/widget/WidgetConversationListService.java +++ b/app/src/main/java/com/android/messaging/widget/WidgetConversationListService.java @@ -68,7 +68,7 @@ public class WidgetConversationListService extends RemoteViewsService { protected Cursor doQuery() { return mContext.getContentResolver().query(MessagingContentProvider.CONVERSATIONS_URI, ConversationListItemData.PROJECTION, - ConversationListData.WHERE_NOT_ARCHIVED, + ConversationListData.WHERE_INBOX, null, // selection args ConversationListData.SORT_ORDER); } diff --git a/app/src/main/res/drawable/home_bg_alert_dialog.xml b/app/src/main/res/drawable/home_bg_details_card.xml similarity index 80% rename from app/src/main/res/drawable/home_bg_alert_dialog.xml rename to app/src/main/res/drawable/home_bg_details_card.xml index 8463ce6..70e0f42 100644 --- a/app/src/main/res/drawable/home_bg_alert_dialog.xml +++ b/app/src/main/res/drawable/home_bg_details_card.xml @@ -1,6 +1,6 @@ - + diff --git a/app/src/main/res/drawable/home_bg_mute_badge.xml b/app/src/main/res/drawable/home_bg_mute_badge.xml new file mode 100644 index 0000000..6c311db --- /dev/null +++ b/app/src/main/res/drawable/home_bg_mute_badge.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/home_bg_unblock_pill.xml b/app/src/main/res/drawable/home_bg_unblock_pill.xml new file mode 100644 index 0000000..80939a2 --- /dev/null +++ b/app/src/main/res/drawable/home_bg_unblock_pill.xml @@ -0,0 +1,10 @@ + + + + + + + + + diff --git a/app/src/main/res/layout/conversation_fragment.xml b/app/src/main/res/layout/conversation_fragment.xml index a8a6919..81563f7 100644 --- a/app/src/main/res/layout/conversation_fragment.xml +++ b/app/src/main/res/layout/conversation_fragment.xml @@ -141,6 +141,8 @@ android:layout_width="match_parent" android:layout_height="wrap_content" /> + + diff --git a/app/src/main/res/layout/conversation_list_fragment.xml b/app/src/main/res/layout/conversation_list_fragment.xml index e596bd4..34d25be 100644 --- a/app/src/main/res/layout/conversation_list_fragment.xml +++ b/app/src/main/res/layout/conversation_list_fragment.xml @@ -400,41 +400,70 @@ android:layout_gravity="bottom" android:clipChildren="false" android:clipToPadding="false" - android:gravity="center_vertical" + android:gravity="center" android:orientation="horizontal" android:paddingStart="@dimen/looply_space_md" android:paddingEnd="@dimen/looply_space_md" android:paddingBottom="@dimen/looply_space_md" android:visibility="gone"> - + android:paddingStart="@dimen/home_nav_item_padding" + android:paddingTop="@dimen/home_nav_item_padding" + android:paddingEnd="@dimen/home_nav_item_padding" + android:paddingBottom="@dimen/home_nav_dock_padding_bottom"> - + + + + + + + + + + + diff --git a/app/src/main/res/layout/conversation_list_item_view.xml b/app/src/main/res/layout/conversation_list_item_view.xml index 7de1cff..e2e152d 100644 --- a/app/src/main/res/layout/conversation_list_item_view.xml +++ b/app/src/main/res/layout/conversation_list_item_view.xml @@ -151,7 +151,9 @@ + android:layout_height="wrap_content" + android:clipChildren="false" + android:clipToPadding="false"> + - + android:layout_height="0dp" + android:background="@color/home_bg" + android:clipChildren="true" + android:clipToPadding="true"> + + + + android:background="@color/home_bg" + android:clipChildren="false" + android:clipToPadding="false"> + + + + + diff --git a/app/src/main/res/layout/home_conversation_trash_bar.xml b/app/src/main/res/layout/home_conversation_trash_bar.xml new file mode 100644 index 0000000..e1b29f7 --- /dev/null +++ b/app/src/main/res/layout/home_conversation_trash_bar.xml @@ -0,0 +1,36 @@ + + + + + + + diff --git a/app/src/main/res/layout/home_delete_alert_dialog.xml b/app/src/main/res/layout/home_delete_alert_dialog.xml deleted file mode 100644 index ea60000..0000000 --- a/app/src/main/res/layout/home_delete_alert_dialog.xml +++ /dev/null @@ -1,44 +0,0 @@ - - - - - - - - - diff --git a/app/src/main/res/layout/home_lock_pattern_body.xml b/app/src/main/res/layout/home_lock_pattern_body.xml index 02df0b8..7ea88d1 100644 --- a/app/src/main/res/layout/home_lock_pattern_body.xml +++ b/app/src/main/res/layout/home_lock_pattern_body.xml @@ -5,17 +5,20 @@ android:gravity="center_horizontal" android:orientation="vertical"> - + android:layout_weight="1" + android:gravity="center" + android:orientation="vertical"> - + android:layout_height="wrap_content" + android:layout_marginTop="@dimen/looply_space_lg" /> + - + + + + + android:layout_height="wrap_content" /> diff --git a/app/src/main/res/layout/home_message_action_popup.xml b/app/src/main/res/layout/home_message_action_popup.xml index 9d4a755..47936bf 100644 --- a/app/src/main/res/layout/home_message_action_popup.xml +++ b/app/src/main/res/layout/home_message_action_popup.xml @@ -80,28 +80,6 @@ android:text="@string/home_message_menu_star" /> - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/home_modal.xml b/app/src/main/res/layout/home_modal.xml index 68a0eba..773bbf0 100644 --- a/app/src/main/res/layout/home_modal.xml +++ b/app/src/main/res/layout/home_modal.xml @@ -95,6 +95,18 @@ android:textStyle="bold" android:visibility="gone" /> + + + + + + + + + + + + + + + + + + + + + + + @@ -46,10 +47,9 @@ @@ -68,10 +68,9 @@ @@ -91,13 +90,33 @@ + + + + + + + diff --git a/app/src/main/res/layout/home_popover_filter.xml b/app/src/main/res/layout/home_popover_filter.xml index 90049af..838c47d 100644 --- a/app/src/main/res/layout/home_popover_filter.xml +++ b/app/src/main/res/layout/home_popover_filter.xml @@ -24,7 +24,7 @@ android:layout_marginEnd="8dp" android:contentDescription="@null" android:src="@drawable/ic_home_check" - android:visibility="visible" /> + android:visibility="invisible" /> - + android:layout_marginEnd="8dp" + android:contentDescription="@null" + android:src="@drawable/ic_home_check" + android:visibility="invisible" /> - + android:layout_marginEnd="8dp" + android:contentDescription="@null" + android:src="@drawable/ic_home_check" + android:visibility="invisible" /> + + + + + + + + + - + android:layout_marginEnd="8dp" + android:contentDescription="@null" + android:src="@drawable/ic_home_check" + android:visibility="invisible" /> - + android:layout_marginEnd="8dp" + android:contentDescription="@null" + android:src="@drawable/ic_home_check" + android:visibility="invisible" /> - + + android:ellipsize="end" + android:maxWidth="72dp" + android:maxLines="1" /> + android:layout_weight="1" /> - + android:layout_marginEnd="8dp" + android:contentDescription="@null" + android:src="@drawable/ic_home_check" + android:visibility="invisible" /> + android:ellipsize="end" + android:maxWidth="72dp" + android:maxLines="1" /> + android:layout_weight="1" /> + android:orientation="vertical"> + android:maxLines="1" /> diff --git a/app/src/main/res/layout/home_spam_activity.xml b/app/src/main/res/layout/home_spam_activity.xml index a7bfcdb..2aba730 100644 --- a/app/src/main/res/layout/home_spam_activity.xml +++ b/app/src/main/res/layout/home_spam_activity.xml @@ -1,28 +1,32 @@ - + android:layout_height="0dp" + android:background="@color/home_bg" + android:clipChildren="true" + android:clipToPadding="true"> + + + + android:layout_weight="1" + android:clipChildren="false" + android:clipToPadding="false"> + + diff --git a/app/src/main/res/layout/home_spam_fragment.xml b/app/src/main/res/layout/home_spam_fragment.xml new file mode 100644 index 0000000..c8ba658 --- /dev/null +++ b/app/src/main/res/layout/home_spam_fragment.xml @@ -0,0 +1,41 @@ + + + + + + + + + diff --git a/app/src/main/res/layout/home_spam_list_item.xml b/app/src/main/res/layout/home_spam_list_item.xml new file mode 100644 index 0000000..66127d5 --- /dev/null +++ b/app/src/main/res/layout/home_spam_list_item.xml @@ -0,0 +1,108 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/home_sub_header.xml b/app/src/main/res/layout/home_sub_header.xml index 5c04ff3..fd3a5a4 100644 --- a/app/src/main/res/layout/home_sub_header.xml +++ b/app/src/main/res/layout/home_sub_header.xml @@ -1,54 +1,124 @@ - - + - + - - + - + + - + + + + + - + android:layout_gravity="end|center_vertical" + android:clipChildren="false" + android:clipToPadding="false" + android:gravity="center_vertical" + android:orientation="horizontal" + android:visibility="gone"> + + + + + + + + diff --git a/app/src/main/res/layout/home_sub_search_field.xml b/app/src/main/res/layout/home_sub_search_field.xml new file mode 100644 index 0000000..0afc0ae --- /dev/null +++ b/app/src/main/res/layout/home_sub_search_field.xml @@ -0,0 +1,63 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/layout/home_sub_select_bar.xml b/app/src/main/res/layout/home_sub_select_bar.xml new file mode 100644 index 0000000..e3d5e0e --- /dev/null +++ b/app/src/main/res/layout/home_sub_select_bar.xml @@ -0,0 +1,38 @@ + + + + + + + + diff --git a/app/src/main/res/values-ko/looply_strings.xml b/app/src/main/res/values-ko/looply_strings.xml index 85d2a7c..e5bc66d 100644 --- a/app/src/main/res/values-ko/looply_strings.xml +++ b/app/src/main/res/values-ko/looply_strings.xml @@ -14,6 +14,19 @@ Message Q 프로필 편집 + 선택 + 모두 스팸 해제 + 스팸 해제 + 모두 보관 해제 + 보관 해제 + 모두 비우기 + 편집할 대화가 없습니다 + 선택할 항목이 없습니다 + 휴지통이 비어 있습니다 + 차단 해제할 스팸이 없습니다 + 보관 해제할 대화가 없습니다 + 스팸을 해제했습니다 + 대화 %1$d개의 보관을 해제했습니다 검색 개인 업무 @@ -142,10 +155,18 @@ 메시지를 자동으로 처리할 규칙을 만드세요. 대화가 없습니다 새 채팅을 시작하면 여기에 표시됩니다. + 안 읽은 대화가 없습니다 + 안 읽은 대화가 여기에 표시됩니다. + 업무 대화가 없습니다 + 업무 SIM이나 업무 관련 메시지가 여기에 표시됩니다. + 개인 대화가 없습니다 + 개인 SIM이나 지인과의 대화가 여기에 표시됩니다. 대화를 불러오는 중 잠시 후 메시지가 여기에 나타납니다. 보관된 대화가 없습니다 보관한 채팅은 복원하거나 삭제할 때까지 여기에 있습니다. + 휴지통이 비어 있습니다 + 삭제한 채팅은 30일 동안 여기에 있다가 영구 삭제됩니다. 스팸 번호가 없습니다 차단된 발신자가 여기에 표시됩니다. 항목을 눌러 해제하세요. 표시할 연락처가 없습니다 @@ -172,11 +193,20 @@ 안 읽음 완료 선택한 메시지 삭제 + 스팸신고 + 보관함 + 대화 %1$d개를 보관했습니다 + 스팸으로 신고할까요? + 이 발신자를 차단하고 대화를 스팸으로 보냅니다. + 발신자 %1$d명을 차단하고 대화를 스팸으로 보냅니다. + 그룹 대화는 스팸으로 신고할 수 없습니다 + 스팸으로 신고했습니다 + 대화 %1$d개를 스팸으로 신고했습니다 1개 선택됨 복사 전달 공유 - 상세 + 세부정보 저장 다시 보내기 다운로드 @@ -191,18 +221,43 @@ 별표 해제 삭제 더 선택 - 상세 - 이 대화가 삭제됩니다.\n\n스팸으로 신고하면 발신자도 차단 목록에 추가됩니다. - 선택한 대화 %1$d개가 삭제됩니다.\n\n스팸으로 신고하면 1:1 대화 발신자도 차단 목록에 추가됩니다. + 세부정보 + 세부정보 + 상태 + 수신됨 + 전송됨 + 유형 + 우선순위 + 제목 + 크기 + 사진 + 동영상 + 음성 + 연락처 + 첨부파일 + 이 대화는 휴지통으로 옮겨지고 30일 후 영구 삭제됩니다. + 선택한 대화 %1$d개가 휴지통으로 옮겨지고 30일 후 영구 삭제됩니다. 삭제 - 삭제하고 스팸 신고 대화를 삭제했습니다 대화 %1$d개를 삭제했습니다 - 삭제하고 스팸으로 신고했습니다 - 대화 %1$d개를 삭제하고 스팸으로 신고했습니다 + 휴지통으로 옮겼습니다 + 대화 %1$d개를 휴지통으로 옮겼습니다 + 휴지통에서 복원했습니다 + 이 대화는 휴지통에 있습니다. 메시지를 보내려면 복원하세요. + 지금 삭제할까요? + 이 대화가 바로 삭제됩니다. 되돌릴 수 없습니다. + 대화 %1$d개가 바로 삭제됩니다. 되돌릴 수 없습니다. + 모두 비울까요? + 휴지통의 모든 대화가 바로 삭제됩니다. 되돌릴 수 없습니다. + 휴지통 메시지 삭제 스팸 목록의 번호에서 온 메시지는 차단됩니다. 항목을 눌러 제거하세요. 스팸 번호가 없습니다. + 차단된 발신자 + 이 번호를 차단 해제할까요? + %1$s에서 온 메시지와 알림을 다시 받게 됩니다. + 차단 해제 + 복원 뒤로 닫기 추가 @@ -211,10 +266,13 @@ 메시지 스팸 - 보관함 - 보관함 - 보관한 대화는 복원하거나 삭제할 때까지 여기에 있습니다. - 보관된 대화가 없습니다 + 보관함 + 휴지통 + 보관함 + 보관한 대화는 복원하거나 삭제할 때까지 여기에 있습니다. + 휴지통 + 삭제한 대화는 30일 동안 여기에 있다가 영구 삭제됩니다. + 휴지통이 비어 있습니다 필터 안 읽음 업무 @@ -235,6 +293,9 @@ 검색 메시지 또는 연락처 검색 + 검색 + 결과가 없습니다 + “%1$s”와 일치하는 항목이 없습니다. 지우기 초기화 음성 검색 @@ -373,6 +434,7 @@ 안 읽음으로 표시했습니다 읽음으로 표시했습니다 알림 숨기기 + 알림 켜기 삭제 보관 기본 SMS 앱을 Messages로 설정하세요 diff --git a/app/src/main/res/values/looply_dimens.xml b/app/src/main/res/values/looply_dimens.xml index 8af0307..c5c635e 100644 --- a/app/src/main/res/values/looply_dimens.xml +++ b/app/src/main/res/values/looply_dimens.xml @@ -99,6 +99,7 @@ -4dp 20dp 8dp + 18dp 12dp 22dp @@ -122,6 +123,9 @@ 52dp 55dp 5dp + 104dp + 148dp + 20dp 6dp 3dp diff --git a/app/src/main/res/values/looply_strings.xml b/app/src/main/res/values/looply_strings.xml index 5770074..d6eeb7d 100644 --- a/app/src/main/res/values/looply_strings.xml +++ b/app/src/main/res/values/looply_strings.xml @@ -15,6 +15,19 @@ Message Q Profile Edit + Select + Unblock all + Unblock + Unarchive all + Unarchive + Empty All + Nothing to edit + Nothing to select + Trash is empty + No spam to unblock + No conversations to unarchive + Unblocked + %1$d conversations unarchived Search Personal Work @@ -143,10 +156,18 @@ Create rules to handle messages automatically. No conversations yet Start a new chat and it will show up here. + You\'re all caught up + Unread conversations will show up here. + No work conversations + Chats on your work SIM, and business messages, appear here. + No personal conversations + Chats on your personal SIM, and messages from people you know, appear here. Loading conversations Your messages will appear here in a moment. No archived conversations Archived chats stay here until you restore or delete them. + Trash is empty + Deleted chats stay here for 30 days, then they are permanently deleted. No spam numbers yet Blocked senders will appear here. Tap an entry to unblock. No contacts to show @@ -173,6 +194,15 @@ Unread Done Delete selected messages + Spam + Archive + %1$d conversations archived + Report as spam? + This sender will be blocked and the conversation moved to Spam. + %1$d senders will be blocked and their conversations moved to Spam. + Group conversations can\'t be reported as spam + Reported as spam + %1$d conversations reported as spam 1 selected Copy Forward @@ -193,17 +223,42 @@ Delete Select more Details - This conversation will be deleted.\n\nReporting it as spam also adds the sender to your blocked list. - The %1$d selected conversations will be deleted.\n\nReporting them as spam also adds the senders of one-to-one conversations to your blocked list. + Details + Status + Received + Sent + Type + Priority + Subject + Size + Photo + Video + Audio + Contact + Attachment + This conversation will move to Trash and be permanently deleted after 30 days. + The %1$d selected conversations will move to Trash and be permanently deleted after 30 days. Delete - Delete and report spam Conversation deleted %1$d conversations deleted - Deleted and reported as spam - %1$d conversations deleted and reported as spam + Moved to Trash + %1$d conversations moved to Trash + Restored from Trash + This conversation is in Trash. Restore it to send messages. + Delete permanently? + This conversation will be deleted now. This can\'t be undone. + %1$d conversations will be deleted now. This can\'t be undone. + Empty All? + All conversations in trash will be deleted now. This can\'t be undone. + Delete trash messages Spam Messages from listed numbers are blocked. Tap an entry to remove it. No spam numbers yet. + Blocked sender + Unblock this number? + You\'ll receive messages and notifications from %1$s again. + Unblock + Restore Back Close Add @@ -213,10 +268,13 @@ Messages Spam - Archive - Archive - Archived conversations stay here until you restore or delete them. - No archived conversations + Archive + Trash + Archive + Archived conversations stay here until you restore or delete them. + Trash + Deleted conversations stay here for 30 days, then they are permanently deleted. + Trash is empty Filter By Unread Work @@ -238,6 +296,9 @@ Search Search messages or contacts + Search + No results + Nothing matches “%1$s”. Clear Reset Voice search @@ -380,6 +441,7 @@ Marked as unread Marked as read Hide alerts + Show alerts Delete Archive Set Messages as the default SMS app diff --git a/app/src/main/res/values/versions.xml b/app/src/main/res/values/versions.xml index 14c6b47..a021372 100644 --- a/app/src/main/res/values/versions.xml +++ b/app/src/main/res/values/versions.xml @@ -16,7 +16,7 @@ --> - 2 + 3