From 8e5d88183cf1ecc2fcb1f9f235156e09141c0278 Mon Sep 17 00:00:00 2001 From: "MINIPC\\frivaong" Date: Fri, 25 Sep 2026 01:23:10 +0900 Subject: [PATCH] Messaging: Looply conversation profile, search, and in-app gallery Replace People & Options with the home profile and search screens, and attach media from an in-conversation gallery sheet that avoids the system picker flash. Co-authored-by: Cursor --- app/src/main/AndroidManifest.xml | 53 +- .../messaging/datamodel/DataModel.java | 17 +- .../messaging/datamodel/DataModelImpl.java | 24 +- .../datamodel/MessagingContentProvider.java | 50 ++ .../datamodel/action/MarkAsUnreadAction.java | 146 ++++ .../action/RenameConversationAction.java | 95 ++ .../datamodel/data/ContactPickerData.java | 50 +- .../data/ConversationMessageData.java | 15 + ...Data.java => ConversationProfileData.java} | 163 ++-- .../data/ConversationSearchData.java | 132 +++ .../datamodel/data/PeopleOptionsItemData.java | 79 -- .../android/messaging/ui/ContactIconView.java | 12 +- .../com/android/messaging/ui/UIIntents.java | 25 +- .../android/messaging/ui/UIIntentsImpl.java | 38 +- .../ui/conversation/ComposeMessageView.java | 235 ++++- .../ui/conversation/ConversationActivity.java | 12 + .../ui/conversation/ConversationFragment.java | 712 ++++++++++----- .../ConversationInputManager.java | 45 +- .../AbstractConversationListActivity.java | 163 +++- .../ConversationListAdapter.java | 28 +- .../ConversationListFragment.java | 581 ++++++++++--- .../ConversationListItemView.java | 87 +- .../ConversationListSwipeHelper.java | 15 +- .../PeopleAndOptionsActivity.java | 66 -- .../PeopleAndOptionsFragment.java | 314 ------- .../PeopleOptionsItemView.java | 70 -- .../ui/home/HomeComposeActivity.java | 227 +++++ .../ui/home/HomeContactsFragment.java | 184 +++- .../home/HomeConversationSearchActivity.java | 233 +++++ .../ui/home/HomeDeleteAlertDialog.java | 94 ++ .../messaging/ui/home/HomeGallerySheet.java | 434 +++++++++ .../ui/home/HomeGroupNameActivity.java | 191 ++++ .../messaging/ui/home/HomePinnedStrip.java | 46 +- .../ui/home/HomeProfileActivity.java | 469 ++++++++++ .../messaging/ui/home/HomeSearchActivity.java | 147 ++++ .../messaging/ui/home/HomeSpamActivity.java | 52 ++ .../messaging/ui/home/HomeSpamFragment.java | 110 +++ .../messaging/ui/home/HomeSubHeader.java | 58 ++ .../messaging/ui/home/HomeUiMotion.java | 823 ++++++++++++++++++ .../ui/home/UnknownSenderPromptStore.java | 57 ++ .../ui/mediapicker/CameraMediaChooser.java | 1 + .../ui/mediapicker/ContactMediaChooser.java | 22 +- .../ui/mediapicker/DocumentImagePicker.java | 47 +- .../ui/mediapicker/GalleryMediaChooser.java | 9 +- .../messaging/ui/mediapicker/MediaPicker.java | 207 ++++- .../ui/mediapicker/MediaPickerPanel.java | 65 +- .../android/messaging/util/ContactUtil.java | 118 +++ .../com/android/messaging/util/OsUtil.java | 12 + app/src/main/res/anim/home_activity_close.xml | 14 + app/src/main/res/anim/home_activity_hold.xml | 5 + app/src/main/res/anim/home_activity_open.xml | 14 + app/src/main/res/anim/home_sheet_close.xml | 6 + app/src/main/res/anim/home_sheet_open.xml | 6 + .../res/drawable/home_bg_alert_dialog.xml | 6 + .../res/drawable/home_bg_bottom_sheet.xml | 8 + .../drawable/home_bg_compose_attach_card.xml | 8 + .../home_bg_compose_attach_circle.xml | 5 + .../res/drawable/home_bg_compose_chip.xml | 6 + .../drawable/home_bg_compose_input_strip.xml | 8 + .../res/drawable/home_bg_compose_pill.xml | 6 + .../drawable/home_bg_compose_send_circle.xml | 9 + .../drawable/home_bg_compose_voice_circle.xml | 9 + .../res/drawable/home_bg_contact_selected.xml | 13 + .../drawable/home_bg_conversation_sheet.xml | 8 + .../drawable/home_bg_delete_icon_circle.xml | 5 + .../res/drawable/home_bg_gallery_handle.xml | 6 + .../main/res/drawable/home_bg_input_field.xml | 6 + .../drawable/home_bg_list_profile_actions.xml | 6 + .../drawable/home_bg_list_profile_card.xml | 6 + .../drawable/home_bg_mediapicker_sheet.xml | 10 + .../res/drawable/home_bg_mediapicker_tile.xml | 6 + .../res/drawable/home_bg_outline_pill.xml | 13 + .../res/drawable/home_bg_primary_pill.xml | 10 + .../res/drawable/home_bg_profile_card.xml | 6 + .../main/res/drawable/home_bg_profile_row.xml | 10 + .../res/drawable/home_bg_sheet_action.xml | 10 + .../drawable/home_bg_sheet_drag_handle.xml | 6 + .../home_bg_unknown_sender_banner.xml | 6 + .../drawable/home_bg_unknown_sender_icon.xml | 5 + .../res/drawable/ic_home_arrow_forward.xml | 11 + app/src/main/res/drawable/ic_home_bell.xml | 7 + .../main/res/drawable/ic_home_camera_line.xml | 18 + .../res/drawable/ic_home_check_on_accent.xml | 11 + .../main/res/drawable/ic_home_chevron_up.xml | 10 + app/src/main/res/drawable/ic_home_close.xml | 10 + .../res/drawable/ic_home_compose_plus.xml | 13 + app/src/main/res/drawable/ic_home_edit.xml | 7 + app/src/main/res/drawable/ic_home_emoji.xml | 6 + .../main/res/drawable/ic_home_group_add.xml | 11 + .../main/res/drawable/ic_home_image_line.xml | 9 + .../main/res/drawable/ic_home_link_line.xml | 7 + .../res/drawable/ic_home_location_live.xml | 10 + .../main/res/drawable/ic_home_place_line.xml | 9 + .../main/res/drawable/ic_home_star_line.xml | 7 + .../main/res/drawable/ic_home_video_line.xml | 7 + .../main/res/drawable/ic_home_voice_wave.xml | 25 + .../layout/action_bar_conversation_name.xml | 56 +- .../main/res/layout/compose_message_view.xml | 527 +++++++++-- .../main/res/layout/conversation_fragment.xml | 77 +- .../res/layout/conversation_list_fragment.xml | 204 +++-- .../layout/conversation_list_item_view.xml | 2 +- app/src/main/res/layout/home_back_button.xml | 9 + .../main/res/layout/home_compose_activity.xml | 164 ++++ app/src/main/res/layout/home_compose_chip.xml | 30 + .../res/layout/home_contacts_fragment.xml | 206 +---- .../res/layout/home_contacts_list_item.xml | 30 +- .../res/layout/home_conversation_empty.xml | 28 + .../res/layout/home_conversation_header.xml | 58 ++ .../home_conversation_message_action_bar.xml | 173 ++++ ..._conversation_message_selection_header.xml | 39 + .../home_conversation_profile_expanded.xml | 146 ++++ .../home_conversation_search_activity.xml | 95 ++ .../layout/home_conversation_search_item.xml | 65 ++ ...ome_conversation_unknown_sender_banner.xml | 121 +++ .../res/layout/home_delete_alert_dialog.xml | 44 + app/src/main/res/layout/home_gallery_item.xml | 25 + .../main/res/layout/home_gallery_sheet.xml | 103 +++ .../res/layout/home_group_name_activity.xml | 119 +++ .../res/layout/home_list_profile_popup.xml | 103 +++ .../home_mediapicker_attachment_sheet.xml | 76 ++ .../res/layout/home_mediapicker_grid_item.xml | 39 + .../res/layout/home_mediapicker_sheet.xml | 12 + .../main/res/layout/home_popover_filter.xml | 45 +- .../main/res/layout/home_profile_activity.xml | 203 +++++ .../res/layout/home_profile_option_row.xml | 32 + .../layout/home_profile_participant_row.xml | 58 ++ .../res/layout/home_profile_quick_action.xml | 36 + .../res/layout/home_profile_rename_dialog.xml | 32 + .../main/res/layout/home_search_activity.xml | 148 ++++ .../res/layout/home_search_filter_item.xml | 33 + .../main/res/layout/home_spam_activity.xml | 45 + app/src/main/res/layout/home_sub_header.xml | 32 + .../main/res/layout/mediapicker_fragment.xml | 60 +- .../layout/people_and_options_activity.xml | 24 - .../layout/people_and_options_fragment.xml | 23 - .../people_and_options_section_header.xml | 38 - .../res/layout/people_options_item_view.xml | 44 - app/src/main/res/menu/conversation_menu.xml | 12 +- app/src/main/res/values/attrs.xml | 1 + app/src/main/res/values/home_colors.xml | 16 + app/src/main/res/values/home_styles.xml | 90 ++ app/src/main/res/values/looply_dimens.xml | 57 +- app/src/main/res/values/looply_strings.xml | 129 ++- app/src/main/res/values/styles.xml | 18 +- 144 files changed, 9272 insertions(+), 1708 deletions(-) create mode 100644 app/src/main/java/com/android/messaging/datamodel/action/MarkAsUnreadAction.java create mode 100644 app/src/main/java/com/android/messaging/datamodel/action/RenameConversationAction.java rename app/src/main/java/com/android/messaging/datamodel/data/{PeopleAndOptionsData.java => ConversationProfileData.java} (50%) create mode 100644 app/src/main/java/com/android/messaging/datamodel/data/ConversationSearchData.java delete mode 100644 app/src/main/java/com/android/messaging/datamodel/data/PeopleOptionsItemData.java delete mode 100644 app/src/main/java/com/android/messaging/ui/conversationsettings/PeopleAndOptionsActivity.java delete mode 100644 app/src/main/java/com/android/messaging/ui/conversationsettings/PeopleAndOptionsFragment.java delete mode 100644 app/src/main/java/com/android/messaging/ui/conversationsettings/PeopleOptionsItemView.java create mode 100644 app/src/main/java/com/android/messaging/ui/home/HomeComposeActivity.java create mode 100644 app/src/main/java/com/android/messaging/ui/home/HomeConversationSearchActivity.java create mode 100644 app/src/main/java/com/android/messaging/ui/home/HomeDeleteAlertDialog.java create mode 100644 app/src/main/java/com/android/messaging/ui/home/HomeGallerySheet.java create mode 100644 app/src/main/java/com/android/messaging/ui/home/HomeGroupNameActivity.java create mode 100644 app/src/main/java/com/android/messaging/ui/home/HomeProfileActivity.java create mode 100644 app/src/main/java/com/android/messaging/ui/home/HomeSearchActivity.java create mode 100644 app/src/main/java/com/android/messaging/ui/home/HomeSpamActivity.java create mode 100644 app/src/main/java/com/android/messaging/ui/home/HomeSpamFragment.java create mode 100644 app/src/main/java/com/android/messaging/ui/home/HomeSubHeader.java create mode 100644 app/src/main/java/com/android/messaging/ui/home/HomeUiMotion.java create mode 100644 app/src/main/java/com/android/messaging/ui/home/UnknownSenderPromptStore.java create mode 100644 app/src/main/res/anim/home_activity_close.xml create mode 100644 app/src/main/res/anim/home_activity_hold.xml create mode 100644 app/src/main/res/anim/home_activity_open.xml create mode 100644 app/src/main/res/anim/home_sheet_close.xml create mode 100644 app/src/main/res/anim/home_sheet_open.xml create mode 100644 app/src/main/res/drawable/home_bg_alert_dialog.xml create mode 100644 app/src/main/res/drawable/home_bg_bottom_sheet.xml create mode 100644 app/src/main/res/drawable/home_bg_compose_attach_card.xml create mode 100644 app/src/main/res/drawable/home_bg_compose_attach_circle.xml create mode 100644 app/src/main/res/drawable/home_bg_compose_chip.xml create mode 100644 app/src/main/res/drawable/home_bg_compose_input_strip.xml create mode 100644 app/src/main/res/drawable/home_bg_compose_pill.xml create mode 100644 app/src/main/res/drawable/home_bg_compose_send_circle.xml create mode 100644 app/src/main/res/drawable/home_bg_compose_voice_circle.xml create mode 100644 app/src/main/res/drawable/home_bg_contact_selected.xml create mode 100644 app/src/main/res/drawable/home_bg_conversation_sheet.xml create mode 100644 app/src/main/res/drawable/home_bg_delete_icon_circle.xml create mode 100644 app/src/main/res/drawable/home_bg_gallery_handle.xml create mode 100644 app/src/main/res/drawable/home_bg_input_field.xml create mode 100644 app/src/main/res/drawable/home_bg_list_profile_actions.xml create mode 100644 app/src/main/res/drawable/home_bg_list_profile_card.xml create mode 100644 app/src/main/res/drawable/home_bg_mediapicker_sheet.xml create mode 100644 app/src/main/res/drawable/home_bg_mediapicker_tile.xml create mode 100644 app/src/main/res/drawable/home_bg_outline_pill.xml create mode 100644 app/src/main/res/drawable/home_bg_primary_pill.xml create mode 100644 app/src/main/res/drawable/home_bg_profile_card.xml create mode 100644 app/src/main/res/drawable/home_bg_profile_row.xml create mode 100644 app/src/main/res/drawable/home_bg_sheet_action.xml create mode 100644 app/src/main/res/drawable/home_bg_sheet_drag_handle.xml create mode 100644 app/src/main/res/drawable/home_bg_unknown_sender_banner.xml create mode 100644 app/src/main/res/drawable/home_bg_unknown_sender_icon.xml create mode 100644 app/src/main/res/drawable/ic_home_arrow_forward.xml create mode 100644 app/src/main/res/drawable/ic_home_bell.xml create mode 100644 app/src/main/res/drawable/ic_home_camera_line.xml create mode 100644 app/src/main/res/drawable/ic_home_check_on_accent.xml create mode 100644 app/src/main/res/drawable/ic_home_chevron_up.xml create mode 100644 app/src/main/res/drawable/ic_home_close.xml create mode 100644 app/src/main/res/drawable/ic_home_compose_plus.xml create mode 100644 app/src/main/res/drawable/ic_home_edit.xml create mode 100644 app/src/main/res/drawable/ic_home_emoji.xml create mode 100644 app/src/main/res/drawable/ic_home_group_add.xml create mode 100644 app/src/main/res/drawable/ic_home_image_line.xml create mode 100644 app/src/main/res/drawable/ic_home_link_line.xml create mode 100644 app/src/main/res/drawable/ic_home_location_live.xml create mode 100644 app/src/main/res/drawable/ic_home_place_line.xml create mode 100644 app/src/main/res/drawable/ic_home_star_line.xml create mode 100644 app/src/main/res/drawable/ic_home_video_line.xml create mode 100644 app/src/main/res/drawable/ic_home_voice_wave.xml create mode 100644 app/src/main/res/layout/home_back_button.xml create mode 100644 app/src/main/res/layout/home_compose_activity.xml create mode 100644 app/src/main/res/layout/home_compose_chip.xml create mode 100644 app/src/main/res/layout/home_conversation_empty.xml create mode 100644 app/src/main/res/layout/home_conversation_header.xml create mode 100644 app/src/main/res/layout/home_conversation_message_action_bar.xml create mode 100644 app/src/main/res/layout/home_conversation_message_selection_header.xml create mode 100644 app/src/main/res/layout/home_conversation_profile_expanded.xml create mode 100644 app/src/main/res/layout/home_conversation_search_activity.xml create mode 100644 app/src/main/res/layout/home_conversation_search_item.xml create mode 100644 app/src/main/res/layout/home_conversation_unknown_sender_banner.xml create mode 100644 app/src/main/res/layout/home_delete_alert_dialog.xml create mode 100644 app/src/main/res/layout/home_gallery_item.xml create mode 100644 app/src/main/res/layout/home_gallery_sheet.xml create mode 100644 app/src/main/res/layout/home_group_name_activity.xml create mode 100644 app/src/main/res/layout/home_list_profile_popup.xml create mode 100644 app/src/main/res/layout/home_mediapicker_attachment_sheet.xml create mode 100644 app/src/main/res/layout/home_mediapicker_grid_item.xml create mode 100644 app/src/main/res/layout/home_mediapicker_sheet.xml create mode 100644 app/src/main/res/layout/home_profile_activity.xml create mode 100644 app/src/main/res/layout/home_profile_option_row.xml create mode 100644 app/src/main/res/layout/home_profile_participant_row.xml create mode 100644 app/src/main/res/layout/home_profile_quick_action.xml create mode 100644 app/src/main/res/layout/home_profile_rename_dialog.xml create mode 100644 app/src/main/res/layout/home_search_activity.xml create mode 100644 app/src/main/res/layout/home_search_filter_item.xml create mode 100644 app/src/main/res/layout/home_spam_activity.xml create mode 100644 app/src/main/res/layout/home_sub_header.xml delete mode 100644 app/src/main/res/layout/people_and_options_activity.xml delete mode 100644 app/src/main/res/layout/people_and_options_fragment.xml delete mode 100644 app/src/main/res/layout/people_and_options_section_header.xml delete mode 100644 app/src/main/res/layout/people_options_item_view.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a789ef2..482cbaa 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -44,6 +44,8 @@ + + + + + + + + + + - + + android:theme="@style/Home.SubScreen" /> + + + 0) { + updateTelephonyReadStatus(db, latestMessageId); + MessagingContentProvider.notifyMessagesChanged(conversationId); + MessagingContentProvider.notifyConversationListChanged(); + } + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + return null; + } + + private static void updateTelephonyReadStatus(final DatabaseWrapper db, + final String messageId) { + Assert.isNotMainThread(); + try (Cursor cursor = db.query(DatabaseHelper.MESSAGES_TABLE, + new String[] { MessageColumns.SMS_MESSAGE_URI }, + MessageColumns._ID + "=?", + new String[] { messageId }, + null, null, null)) { + if (!cursor.moveToFirst()) { + return; + } + final String smsMessageUri = cursor.getString(0); + if (TextUtils.isEmpty(smsMessageUri)) { + return; + } + final ContentValues telephonyValues = new ContentValues(); + telephonyValues.put("read", 0); + telephonyValues.put("seen", 0); + Factory.get().getApplicationContext().getContentResolver().update( + Uri.parse(smsMessageUri), + telephonyValues, + null, + null); + } + } + + private MarkAsUnreadAction(final Parcel in) { + super(in); + } + + public static final Parcelable.Creator CREATOR = + new Parcelable.Creator<>() { + @Override + public MarkAsUnreadAction createFromParcel(final Parcel in) { + return new MarkAsUnreadAction(in); + } + + @Override + public MarkAsUnreadAction[] newArray(final int size) { + return new MarkAsUnreadAction[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/RenameConversationAction.java b/app/src/main/java/com/android/messaging/datamodel/action/RenameConversationAction.java new file mode 100644 index 0000000..b069e25 --- /dev/null +++ b/app/src/main/java/com/android/messaging/datamodel/action/RenameConversationAction.java @@ -0,0 +1,95 @@ +/* + * 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.datamodel.action; + +import android.content.ContentValues; +import android.os.Parcel; +import android.os.Parcelable; +import android.text.TextUtils; + +import androidx.annotation.NonNull; + +import com.android.messaging.datamodel.BugleDatabaseOperations; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.DatabaseHelper.ConversationColumns; +import com.android.messaging.datamodel.DatabaseWrapper; +import com.android.messaging.datamodel.MessagingContentProvider; + +/** + * Gives a conversation a custom name, used when creating a group chat. The name lives only in + * the local database, so it is visible to this device only. + */ +public class RenameConversationAction extends Action implements Parcelable { + private static final String KEY_CONVERSATION_ID = "conversation_id"; + private static final String KEY_NAME = "name"; + + public static void renameConversation(final String conversationId, final String name) { + final RenameConversationAction action = new RenameConversationAction(conversationId, name); + action.start(); + } + + private RenameConversationAction(final String conversationId, final String name) { + actionParameters.putString(KEY_CONVERSATION_ID, conversationId); + actionParameters.putString(KEY_NAME, name); + } + + @Override + protected Object executeAction() { + final String conversationId = actionParameters.getString(KEY_CONVERSATION_ID); + final String name = actionParameters.getString(KEY_NAME); + if (TextUtils.isEmpty(conversationId) || TextUtils.isEmpty(name)) { + return null; + } + + final DatabaseWrapper db = DataModel.get().getDatabase(); + db.beginTransaction(); + try { + final ContentValues values = new ContentValues(); + values.put(ConversationColumns.NAME, name); + BugleDatabaseOperations.updateConversationRowIfExists(db, conversationId, values); + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + + MessagingContentProvider.notifyConversationMetadataChanged(conversationId); + MessagingContentProvider.notifyConversationListChanged(); + return null; + } + + private RenameConversationAction(final Parcel in) { + super(in); + } + + public static final Parcelable.Creator CREATOR = + new Parcelable.Creator<>() { + @Override + public RenameConversationAction createFromParcel(final Parcel in) { + return new RenameConversationAction(in); + } + + @Override + public RenameConversationAction[] newArray(final int size) { + return new RenameConversationAction[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/ContactPickerData.java b/app/src/main/java/com/android/messaging/datamodel/data/ContactPickerData.java index 068c34d..8ed5d9a 100644 --- a/app/src/main/java/com/android/messaging/datamodel/data/ContactPickerData.java +++ b/app/src/main/java/com/android/messaging/datamodel/data/ContactPickerData.java @@ -20,6 +20,7 @@ package com.android.messaging.datamodel.data; import android.content.Context; import android.database.Cursor; import android.os.Bundle; +import android.text.TextUtils; import androidx.annotation.NonNull; import androidx.loader.app.LoaderManager; @@ -46,6 +47,13 @@ public class ContactPickerData extends BindableData implements void onAllContactsCursorUpdated(Cursor data); void onFrequentContactsCursorUpdated(Cursor data); void onContactCustomColorLoaded(ContactPickerData data); + + /** + * Results for the latest {@link ContactPickerData#setSearchText}, or null once the + * search is cleared. + */ + default void onFilteredContactsCursorUpdated(Cursor data) { + } } private static final String BINDING_ID = "bindingId"; @@ -53,6 +61,7 @@ public class ContactPickerData extends BindableData implements private LoaderManager mLoaderManager; private ContactPickerDataListener mListener; private final FrequentContactsCursorBuilder mFrequentContactsCursorBuilder; + private String mSearchText; public ContactPickerData(final Context context, final ContactPickerDataListener listener) { mListener = listener; @@ -63,6 +72,7 @@ public class ContactPickerData extends BindableData implements private static final int ALL_CONTACTS_LOADER = 1; private static final int FREQUENT_CONTACTS_LOADER = 2; private static final int PARTICIPANT_LOADER = 3; + private static final int FILTERED_CONTACTS_LOADER = 4; @NonNull @Override @@ -81,6 +91,9 @@ public class ContactPickerData extends BindableData implements return new BoundCursorLoader(bindingId, mContext, MessagingContentProvider.PARTICIPANTS_URI, ParticipantData.ParticipantsQuery.PROJECTION, null, null, null); + case FILTERED_CONTACTS_LOADER: + return ContactUtil.filterDestination(mContext, mSearchText) + .createBoundCursorLoader(bindingId); default: Assert.fail("Unknown loader id for contact picker!"); break; @@ -108,12 +121,16 @@ public class ContactPickerData extends BindableData implements case PARTICIPANT_LOADER: mListener.onContactCustomColorLoaded(this); break; + case FILTERED_CONTACTS_LOADER: + mListener.onFilteredContactsCursorUpdated(data); + break; default: Assert.fail("Unknown loader id for contact picker!"); break; } - if (loader.getId() != PARTICIPANT_LOADER) { + if (loader.getId() == ALL_CONTACTS_LOADER + || loader.getId() == FREQUENT_CONTACTS_LOADER) { // The frequent contacts cursor to be used in the UI depends on results from both // all contacts and frequent contacts loader, and we don't know which will finish // first. Therefore, try to build the cursor and notify the listener if it's @@ -151,6 +168,9 @@ public class ContactPickerData extends BindableData implements case PARTICIPANT_LOADER: mListener.onContactCustomColorLoaded(this); break; + case FILTERED_CONTACTS_LOADER: + mListener.onFilteredContactsCursorUpdated(null); + break; default: Assert.fail("Unknown loader id for contact picker!"); break; @@ -170,6 +190,33 @@ public class ContactPickerData extends BindableData implements mLoaderManager.initLoader(PARTICIPANT_LOADER, args, this); } + /** + * Narrows the contact list down to destinations matching {@code searchText}. Passing empty + * text clears the search and reports a null cursor. + */ + public void setSearchText(final BindingBase binding, + final String searchText) { + if (mLoaderManager == null) { + return; + } + final String trimmed = searchText == null ? null : searchText.trim(); + final String normalized = TextUtils.isEmpty(trimmed) ? null : trimmed; + if (TextUtils.equals(mSearchText, normalized)) { + return; + } + mSearchText = normalized; + if (mSearchText == null) { + mLoaderManager.destroyLoader(FILTERED_CONTACTS_LOADER); + if (mListener != null) { + mListener.onFilteredContactsCursorUpdated(null); + } + return; + } + final Bundle args = new Bundle(); + args.putString(BINDING_ID, binding.getBindingId()); + mLoaderManager.restartLoader(FILTERED_CONTACTS_LOADER, args, this); + } + @Override protected void unregisterListeners() { mListener = null; @@ -180,6 +227,7 @@ public class ContactPickerData extends BindableData implements mLoaderManager.destroyLoader(ALL_CONTACTS_LOADER); mLoaderManager.destroyLoader(FREQUENT_CONTACTS_LOADER); mLoaderManager.destroyLoader(PARTICIPANT_LOADER); + mLoaderManager.destroyLoader(FILTERED_CONTACTS_LOADER); mLoaderManager = null; } mFrequentContactsCursorBuilder.resetBuilder(); diff --git a/app/src/main/java/com/android/messaging/datamodel/data/ConversationMessageData.java b/app/src/main/java/com/android/messaging/datamodel/data/ConversationMessageData.java index dd7e4ca..33d1f86 100644 --- a/app/src/main/java/com/android/messaging/datamodel/data/ConversationMessageData.java +++ b/app/src/main/java/com/android/messaging/datamodel/data/ConversationMessageData.java @@ -619,6 +619,21 @@ public class ConversationMessageData { + CONVERSATION_MESSAGES_QUERY_SQL_GROUP_BY; } + /** + * Same rows as {@link #getConversationMessagesQuerySql}, narrowed to the messages that have + * a part matching the search term. Takes the conversation id then the LIKE pattern. + */ + public static String getConversationMessagesSearchQuerySql() { + return CONVERSATION_MESSAGES_QUERY_SQL + + " AND " + + DatabaseHelper.MESSAGES_TABLE + "." + MessageColumns.CONVERSATION_ID + "=?" + + " AND " + + DatabaseHelper.MESSAGES_TABLE + "." + MessageColumns._ID + " IN (SELECT " + + PartColumns.MESSAGE_ID + " FROM " + DatabaseHelper.PARTS_TABLE + + " WHERE " + PartColumns.TEXT + " LIKE ? ESCAPE '\\'))" + + CONVERSATION_MESSAGES_QUERY_SQL_GROUP_BY; + } + static String getConversationMessageIdsQuerySql() { return CONVERSATION_MESSAGES_IDS_QUERY_SQL + " AND " diff --git a/app/src/main/java/com/android/messaging/datamodel/data/PeopleAndOptionsData.java b/app/src/main/java/com/android/messaging/datamodel/data/ConversationProfileData.java similarity index 50% rename from app/src/main/java/com/android/messaging/datamodel/data/PeopleAndOptionsData.java rename to app/src/main/java/com/android/messaging/datamodel/data/ConversationProfileData.java index 1dbdb7c..5de5384 100644 --- a/app/src/main/java/com/android/messaging/datamodel/data/PeopleAndOptionsData.java +++ b/app/src/main/java/com/android/messaging/datamodel/data/ConversationProfileData.java @@ -1,6 +1,6 @@ /* * Copyright (C) 2015 The Android Open Source Project - * Copyright (C) 2024 The LineageOS Project + * 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. @@ -38,125 +38,122 @@ import com.android.messaging.util.LogUtil; import java.util.List; /** - * Services data needs for PeopleAndOptionsFragment. + * Serves the conversation metadata and the participant list needed by the conversation profile + * screen. */ -public class PeopleAndOptionsData extends BindableData implements +public class ConversationProfileData extends BindableData implements LoaderManager.LoaderCallbacks { - public interface PeopleAndOptionsDataListener { - void onOptionsCursorUpdated(PeopleAndOptionsData data, Cursor cursor); - void onParticipantsListLoaded(PeopleAndOptionsData data, + public interface ConversationProfileDataListener { + void onConversationMetadataUpdated(ConversationProfileData data, + ConversationListItemData conversation); + void onParticipantsListLoaded(ConversationProfileData data, List participants); } private static final String BINDING_ID = "bindingId"; + private static final int CONVERSATION_METADATA_LOADER = 1; + private static final int PARTICIPANT_LOADER = 2; + private final Context mContext; private final String mConversationId; private final ConversationParticipantsData mParticipantData; private LoaderManager mLoaderManager; - private PeopleAndOptionsDataListener mListener; + private ConversationProfileDataListener mListener; - public PeopleAndOptionsData(final String conversationId, final Context context, - final PeopleAndOptionsDataListener listener) { + public ConversationProfileData(final String conversationId, final Context context, + final ConversationProfileDataListener listener) { mListener = listener; mContext = context; mConversationId = conversationId; mParticipantData = new ConversationParticipantsData(); } - private static final int CONVERSATION_OPTIONS_LOADER = 1; - private static final int PARTICIPANT_LOADER = 2; - @NonNull @Override public Loader onCreateLoader(final int id, final Bundle args) { final String bindingId = args.getString(BINDING_ID); - // Check if data still bound to the requesting ui element - if (isBound(bindingId)) { - switch (id) { - case CONVERSATION_OPTIONS_LOADER: { - final Uri uri = - MessagingContentProvider.buildConversationMetadataUri(mConversationId); - return new BoundCursorLoader(bindingId, mContext, uri, - new String[]{}, null, null, null); - } - - case PARTICIPANT_LOADER: { - final Uri uri = - MessagingContentProvider - .buildConversationParticipantsUri(mConversationId); - return new BoundCursorLoader(bindingId, mContext, uri, - ParticipantData.ParticipantsQuery.PROJECTION, null, null, null); - } - - default: - Assert.fail("Unknown loader id for PeopleAndOptionsFragment!"); - break; - } - } else { - LogUtil.w(LogUtil.BUGLE_TAG, "Loader created after unbinding PeopleAndOptionsFragment"); + if (!isBound(bindingId)) { + LogUtil.w(LogUtil.BUGLE_TAG, "Loader created after unbinding conversation profile"); + return null; + } + switch (id) { + case CONVERSATION_METADATA_LOADER: { + final Uri uri = + MessagingContentProvider.buildConversationMetadataUri(mConversationId); + return new BoundCursorLoader(bindingId, mContext, uri, + ConversationListItemData.PROJECTION, null, null, null); + } + + case PARTICIPANT_LOADER: { + final Uri uri = + MessagingContentProvider.buildConversationParticipantsUri(mConversationId); + return new BoundCursorLoader(bindingId, mContext, uri, + ParticipantData.ParticipantsQuery.PROJECTION, null, null, null); + } + + default: + Assert.fail("Unknown loader id for the conversation profile!"); + return null; } - return null; } - /** - * {@inheritDoc} - */ @Override public void onLoadFinished(@NonNull final Loader loader, final Cursor data) { final BoundCursorLoader cursorLoader = (BoundCursorLoader) loader; - if (isBound(cursorLoader.getBindingId())) { - switch (loader.getId()) { - case CONVERSATION_OPTIONS_LOADER: - mListener.onOptionsCursorUpdated(this, data); - break; - - case PARTICIPANT_LOADER: - mParticipantData.bind(data); - mListener.onParticipantsListLoaded(this, - mParticipantData.getParticipantListExcludingSelf()); - break; - - default: - Assert.fail("Unknown loader id for PeopleAndOptionsFragment!"); - break; + if (!isBound(cursorLoader.getBindingId()) || mListener == null) { + LogUtil.w(LogUtil.BUGLE_TAG, "Loader finished after unbinding conversation profile"); + return; + } + switch (loader.getId()) { + case CONVERSATION_METADATA_LOADER: { + ConversationListItemData conversation = null; + if (data != null && data.moveToFirst()) { + conversation = new ConversationListItemData(); + conversation.bind(data); + } + mListener.onConversationMetadataUpdated(this, conversation); + break; } - } else { - LogUtil.w(LogUtil.BUGLE_TAG, - "Loader finished after unbinding PeopleAndOptionsFragment"); + + case PARTICIPANT_LOADER: + mParticipantData.bind(data); + mListener.onParticipantsListLoaded(this, + mParticipantData.getParticipantListExcludingSelf()); + break; + + default: + Assert.fail("Unknown loader id for the conversation profile!"); + break; } } - /** - * {@inheritDoc} - */ @Override public void onLoaderReset(@NonNull final Loader loader) { final BoundCursorLoader cursorLoader = (BoundCursorLoader) loader; - if (isBound(cursorLoader.getBindingId())) { - switch (loader.getId()) { - case CONVERSATION_OPTIONS_LOADER: - mListener.onOptionsCursorUpdated(this, null); - break; + if (!isBound(cursorLoader.getBindingId()) || mListener == null) { + return; + } + switch (loader.getId()) { + case CONVERSATION_METADATA_LOADER: + mListener.onConversationMetadataUpdated(this, null); + break; - case PARTICIPANT_LOADER: - mParticipantData.bind(null); - break; + case PARTICIPANT_LOADER: + mParticipantData.bind(null); + break; - default: - Assert.fail("Unknown loader id for PeopleAndOptionsFragment!"); - break; - } - } else { - LogUtil.w(LogUtil.BUGLE_TAG, "Loader reset after unbinding PeopleAndOptionsFragment"); + default: + Assert.fail("Unknown loader id for the conversation profile!"); + break; } } public void init(final LoaderManager loaderManager, - final BindingBase binding) { + final BindingBase binding) { final Bundle args = new Bundle(); args.putString(BINDING_ID, binding.getBindingId()); mLoaderManager = loaderManager; - mLoaderManager.initLoader(CONVERSATION_OPTIONS_LOADER, args, this); + mLoaderManager.initLoader(CONVERSATION_METADATA_LOADER, args, this); mLoaderManager.initLoader(PARTICIPANT_LOADER, args, this); } @@ -166,17 +163,16 @@ public class PeopleAndOptionsData extends BindableData implements // This could be null if we bind but the caller doesn't init the BindableData if (mLoaderManager != null) { - mLoaderManager.destroyLoader(CONVERSATION_OPTIONS_LOADER); + mLoaderManager.destroyLoader(CONVERSATION_METADATA_LOADER); mLoaderManager.destroyLoader(PARTICIPANT_LOADER); mLoaderManager = null; } } - public void setDestinationBlocked(final BindingBase binding, + public void setDestinationBlocked(final BindingBase binding, final boolean blocked) { - final String bindingId = binding.getBindingId(); final ParticipantData participantData = mParticipantData.getOtherParticipant(); - if (isBound(bindingId) && participantData != null) { + if (isBound(binding.getBindingId()) && participantData != null) { UpdateDestinationBlockedAction.updateDestinationBlocked( participantData.getNormalizedDestination(), blocked, mConversationId, @@ -184,6 +180,11 @@ public class PeopleAndOptionsData extends BindableData implements } } + /** The single other participant of a one to one conversation, or null for a group. */ + public ParticipantData getOtherParticipant() { + return mParticipantData.getOtherParticipant(); + } + public String getConversationId() { return mConversationId; } diff --git a/app/src/main/java/com/android/messaging/datamodel/data/ConversationSearchData.java b/app/src/main/java/com/android/messaging/datamodel/data/ConversationSearchData.java new file mode 100644 index 0000000..ecda726 --- /dev/null +++ b/app/src/main/java/com/android/messaging/datamodel/data/ConversationSearchData.java @@ -0,0 +1,132 @@ +/* + * 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.datamodel.data; + +import android.content.Context; +import android.database.Cursor; +import android.os.Bundle; +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.loader.app.LoaderManager; +import androidx.loader.content.Loader; + +import com.android.messaging.datamodel.BoundCursorLoader; +import com.android.messaging.datamodel.MessagingContentProvider; +import com.android.messaging.datamodel.binding.BindableData; +import com.android.messaging.datamodel.binding.BindingBase; +import com.android.messaging.util.Assert; +import com.android.messaging.util.LogUtil; + +/** + * Searches the messages of a single conversation. No loader runs until + * {@link #setSearchText} gets a non empty term. + */ +public class ConversationSearchData extends BindableData implements + LoaderManager.LoaderCallbacks { + public interface ConversationSearchDataListener { + /** Results for the latest search term, or null once the search is cleared. */ + void onConversationSearchCursorUpdated(Cursor cursor); + } + + private static final String BINDING_ID = "bindingId"; + private static final int SEARCH_LOADER = 1; + + private final Context mContext; + private final String mConversationId; + private LoaderManager mLoaderManager; + private ConversationSearchDataListener mListener; + private String mSearchText; + + public ConversationSearchData(final Context context, final String conversationId, + final ConversationSearchDataListener listener) { + mContext = context; + mConversationId = conversationId; + mListener = listener; + } + + public void init(final LoaderManager loaderManager) { + mLoaderManager = loaderManager; + } + + public void setSearchText(final BindingBase binding, + final String searchText) { + if (mLoaderManager == null) { + return; + } + final String trimmed = searchText == null ? null : searchText.trim(); + final String normalized = TextUtils.isEmpty(trimmed) ? null : trimmed; + if (TextUtils.equals(mSearchText, normalized)) { + return; + } + mSearchText = normalized; + if (mSearchText == null) { + mLoaderManager.destroyLoader(SEARCH_LOADER); + if (mListener != null) { + mListener.onConversationSearchCursorUpdated(null); + } + return; + } + final Bundle args = new Bundle(); + args.putString(BINDING_ID, binding.getBindingId()); + mLoaderManager.restartLoader(SEARCH_LOADER, args, this); + } + + public String getSearchText() { + return mSearchText; + } + + @NonNull + @Override + public Loader onCreateLoader(final int id, final Bundle args) { + Assert.isTrue(id == SEARCH_LOADER); + final String bindingId = args.getString(BINDING_ID); + if (!isBound(bindingId)) { + LogUtil.w(LogUtil.BUGLE_TAG, "Loader created after unbinding conversation search"); + return null; + } + return new BoundCursorLoader(bindingId, mContext, + MessagingContentProvider.buildConversationMessagesSearchUri( + mConversationId, mSearchText), + null, null, null, null); + } + + @Override + public void onLoadFinished(@NonNull final Loader loader, final Cursor cursor) { + final BoundCursorLoader cursorLoader = (BoundCursorLoader) loader; + if (isBound(cursorLoader.getBindingId()) && mListener != null) { + mListener.onConversationSearchCursorUpdated(cursor); + } + } + + @Override + public void onLoaderReset(@NonNull final Loader loader) { + final BoundCursorLoader cursorLoader = (BoundCursorLoader) loader; + if (isBound(cursorLoader.getBindingId()) && mListener != null) { + mListener.onConversationSearchCursorUpdated(null); + } + } + + @Override + protected void unregisterListeners() { + mListener = null; + if (mLoaderManager != null) { + mLoaderManager.destroyLoader(SEARCH_LOADER); + mLoaderManager = null; + } + } +} diff --git a/app/src/main/java/com/android/messaging/datamodel/data/PeopleOptionsItemData.java b/app/src/main/java/com/android/messaging/datamodel/data/PeopleOptionsItemData.java deleted file mode 100644 index 6669325..0000000 --- a/app/src/main/java/com/android/messaging/datamodel/data/PeopleOptionsItemData.java +++ /dev/null @@ -1,79 +0,0 @@ -/* - * Copyright (C) 2015 The Android Open Source Project - * Copyright (C) 2024 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.datamodel.data; - -import android.content.Context; -import android.database.Cursor; - -import com.android.messaging.R; -import com.android.messaging.util.Assert; - -public class PeopleOptionsItemData { - // Identification for each setting that's surfaced to the UI layer. - public static final int SETTING_NOTIFICATION = 0; - public static final int SETTING_BLOCKED = 1; - public static final int SETTINGS_COUNT = 2; - - private String mTitle; - private int mItemId; - private ParticipantData mOtherParticipant; - - private final Context mContext; - - public PeopleOptionsItemData(final Context context) { - mContext = context; - } - - /** - * Bind to a specific setting column on conversation metadata cursor. (Note - * that it binds to columns because it treats individual columns of the cursor as - * separate options to display for the conversation, e.g. notification settings). - */ - public void bind( - final Cursor cursor, final ParticipantData otherParticipant, final int settingType) { - mItemId = settingType; - mOtherParticipant = otherParticipant; - - switch (settingType) { - case SETTING_NOTIFICATION: - mTitle = mContext.getString(R.string.notifications_enabled_conversation_pref_title); - break; - - case SETTING_BLOCKED: - Assert.notNull(otherParticipant); - final int resourceId = otherParticipant.isBlocked() ? - R.string.unblock_contact_title : R.string.block_contact_title; - mTitle = mContext.getString(resourceId, otherParticipant.getDisplayDestination()); - break; - - default: - Assert.fail("Unsupported conversation option type!"); - } - } - - public String getTitle() { - return mTitle; - } - - public int getItemId() { - return mItemId; - } - - public ParticipantData getOtherParticipant() { - return mOtherParticipant; - } -} diff --git a/app/src/main/java/com/android/messaging/ui/ContactIconView.java b/app/src/main/java/com/android/messaging/ui/ContactIconView.java index 333bec4..e778071 100644 --- a/app/src/main/java/com/android/messaging/ui/ContactIconView.java +++ b/app/src/main/java/com/android/messaging/ui/ContactIconView.java @@ -41,8 +41,10 @@ public class ContactIconView extends AsyncImageView { private static final int NORMAL_ICON_SIZE_ID = 0; private static final int LARGE_ICON_SIZE_ID = 1; private static final int SMALL_ICON_SIZE_ID = 2; + private static final int PROFILE_POPUP_ICON_SIZE_ID = 3; protected final int mIconSize; + private final boolean mCropToCircle; private final int mColorPressedId; private long mContactId; @@ -58,6 +60,7 @@ public class ContactIconView extends AsyncImageView { final TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.ContactIconView); final int iconSizeId = a.getInt(R.styleable.ContactIconView_contactIconSize, 0); + boolean cropToCircle = true; switch (iconSizeId) { case NORMAL_ICON_SIZE_ID: mIconSize = (int) resources.getDimension( @@ -71,11 +74,17 @@ public class ContactIconView extends AsyncImageView { mIconSize = (int) resources.getDimension( R.dimen.contact_icon_view_small_size); break; + case PROFILE_POPUP_ICON_SIZE_ID: + mIconSize = (int) resources.getDimension( + R.dimen.home_list_profile_avatar_size); + cropToCircle = false; + break; default: // For the compiler, something has to be set even with the assert. mIconSize = 0; Assert.fail("Unsupported ContactIconView icon size attribute"); } + mCropToCircle = cropToCircle; mColorPressedId = resources.getColor(R.color.contact_avatar_pressed_color, context.getTheme()); @@ -118,7 +127,8 @@ public class ContactIconView extends AsyncImageView { if (AvatarUriUtil.TYPE_GROUP_URI.equals(avatarType)) { setImageResourceId(new AvatarGroupRequestDescriptor(uri, mIconSize, mIconSize)); } else { - setImageResourceId(new AvatarRequestDescriptor(uri, mIconSize, mIconSize)); + setImageResourceId(new AvatarRequestDescriptor(uri, mIconSize, mIconSize, + mCropToCircle)); } } 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 b8a08fd..b0179b9 100644 --- a/app/src/main/java/com/android/messaging/ui/UIIntents.java +++ b/app/src/main/java/com/android/messaging/ui/UIIntents.java @@ -166,9 +166,15 @@ public abstract class UIIntents { public abstract void launchAddContactActivity(final Context context, final String destination); /** - * Launch an activity to show people & options for a given conversation. + * Launch home UI to show the profile of a given conversation. */ - public abstract void launchPeopleAndOptionsActivity(final Activity context, + public abstract void launchHomeProfileActivity(final Activity activity, + final String conversationId); + + /** + * Launch home UI to search the messages of a given conversation. + */ + public abstract void launchHomeConversationSearchActivity(final Context context, final String conversationId); /** @@ -189,6 +195,21 @@ public abstract class UIIntents { */ public abstract void launchBlockedParticipantsActivity(final Context context); + /** + * Launch home UI to manage spam-registered (blocked) senders. + */ + public abstract void launchHomeSpamActivity(final Context context); + + /** + * Launch home UI to search messages. + */ + public abstract void launchHomeSearchActivity(final Context context); + + /** + * Launch home UI to start a new chat. + */ + public abstract void launchHomeComposeActivity(final Context context); + /** * Launch an activity to show a class zero message */ 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 bfa0c70..d7e597e 100644 --- a/app/src/main/java/com/android/messaging/ui/UIIntentsImpl.java +++ b/app/src/main/java/com/android/messaging/ui/UIIntentsImpl.java @@ -57,7 +57,6 @@ import com.android.messaging.ui.conversation.LaunchConversationActivity; import com.android.messaging.ui.conversationlist.ArchivedConversationListActivity; import com.android.messaging.ui.conversationlist.ConversationListActivity; import com.android.messaging.ui.conversationlist.ForwardMessageActivity; -import com.android.messaging.ui.conversationsettings.PeopleAndOptionsActivity; import com.android.messaging.ui.photoviewer.BuglePhotoViewActivity; import com.android.messaging.util.Assert; import com.android.messaging.util.ConversationIdSet; @@ -215,11 +214,42 @@ public class UIIntentsImpl extends UIIntents { } @Override - public void launchPeopleAndOptionsActivity(final Activity activity, - final String conversationId) { - final Intent intent = new Intent(activity, PeopleAndOptionsActivity.class); + public void launchHomeSpamActivity(final Context context) { + final Intent intent = new Intent(context, + com.android.messaging.ui.home.HomeSpamActivity.class); + context.startActivity(intent); + } + + @Override + public void launchHomeSearchActivity(final Context context) { + final Intent intent = new Intent(context, + com.android.messaging.ui.home.HomeSearchActivity.class); + context.startActivity(intent); + } + + @Override + public void launchHomeComposeActivity(final Context context) { + final Intent intent = new Intent(context, + com.android.messaging.ui.home.HomeComposeActivity.class); + context.startActivity(intent); + } + + @Override + public void launchHomeProfileActivity(final Activity activity, final String conversationId) { + final Intent intent = new Intent(activity, + com.android.messaging.ui.home.HomeProfileActivity.class); intent.putExtra(UI_INTENT_EXTRA_CONVERSATION_ID, conversationId); activity.startActivityForResult(intent, 0); + activity.overridePendingTransition(R.anim.home_activity_open, R.anim.home_activity_hold); + } + + @Override + public void launchHomeConversationSearchActivity(final Context context, + final String conversationId) { + final Intent intent = new Intent(context, + com.android.messaging.ui.home.HomeConversationSearchActivity.class); + intent.putExtra(UI_INTENT_EXTRA_CONVERSATION_ID, conversationId); + context.startActivity(intent); } @Override diff --git a/app/src/main/java/com/android/messaging/ui/conversation/ComposeMessageView.java b/app/src/main/java/com/android/messaging/ui/conversation/ComposeMessageView.java index 9204fa0..f690691 100644 --- a/app/src/main/java/com/android/messaging/ui/conversation/ComposeMessageView.java +++ b/app/src/main/java/com/android/messaging/ui/conversation/ComposeMessageView.java @@ -35,11 +35,14 @@ import android.util.AttributeSet; import android.view.ContextThemeWrapper; import android.view.KeyEvent; import android.view.View; +import android.view.ViewGroup; import android.view.accessibility.AccessibilityEvent; import android.view.inputmethod.EditorInfo; import android.widget.ImageButton; +import android.widget.ImageView; import android.widget.LinearLayout; import android.widget.TextView; +import android.widget.Toast; import androidx.annotation.NonNull; import androidx.appcompat.app.ActionBar; @@ -65,6 +68,7 @@ import com.android.messaging.ui.AttachmentPreview; import com.android.messaging.ui.BugleActionBarActivity; import com.android.messaging.ui.PlainTextEditText; import com.android.messaging.ui.conversation.ConversationInputManager.ConversationInputSink; +import com.android.messaging.ui.home.HomeUiMotion; import com.android.messaging.util.AccessibilityUtil; import com.android.messaging.util.Assert; import com.android.messaging.util.AvatarUriUtil; @@ -102,6 +106,7 @@ public class ComposeMessageView extends LinearLayout boolean tooManyVideos); void notifyOfAttachmentLoadFailed(); void showAttachmentChooser(); + void launchSystemGalleryPicker(); boolean shouldShowSubjectEditor(); boolean shouldHideAttachmentsWhenSimSelectorShown(); Uri getSelfSendButtonIconUri(); @@ -128,6 +133,13 @@ public class ComposeMessageView extends LinearLayout private ImageButton mDeleteSubjectButton; private AttachmentPreview mAttachmentPreview; private ImageButton mAttachMediaButton; + private View mAttachmentSheetContainer; + private View mComposeBottomStack; + private View mComposeBar; + private boolean mAttachmentSheetOpen; + private int mComposeBarPaddingBottom; + private ImageButton mVoiceButton; + private View mSendColumn; private final Binding mBinding; private IComposeMessageViewHost mHost; @@ -283,10 +295,37 @@ public class ComposeMessageView extends LinearLayout }); mAttachMediaButton = findViewById(R.id.attach_media_button); - mAttachMediaButton.setOnClickListener(clickView -> { - // Showing the media picker is treated as starting to compose the message. - mInputManager.showHideMediaPicker(true /* show */, true /* animate */); - }); + mAttachmentSheetContainer = findViewById(R.id.home_compose_attachment_sheet_container); + mComposeBottomStack = findViewById(R.id.home_compose_bottom_stack); + mComposeBar = findViewById(R.id.home_conversation_compose_bar); + if (mComposeBar != null) { + mComposeBarPaddingBottom = mComposeBar.getPaddingBottom(); + } + setupComposeAttachmentSheetTiles(); + updateComposeBottomStackBackground(); + mAttachMediaButton.setOnClickListener(clickView -> toggleComposeAttachmentSheet()); + + final ImageButton emojiButton = findViewById(R.id.home_compose_emoji_button); + if (emojiButton != null) { + emojiButton.setOnClickListener(v -> mComposeEditText.requestFocus()); + } + final ImageButton galleryButton = findViewById(R.id.home_compose_gallery_button); + if (galleryButton != null) { + galleryButton.setOnClickListener(v -> { + closeComposeAttachmentSheet(true /* animate */, () -> { + if (mHost != null) { + mHost.launchSystemGalleryPicker(); + } + }); + }); + } + + mVoiceButton = findViewById(R.id.home_conversation_voice_button); + mSendColumn = findViewById(R.id.home_conversation_send_column); + if (mVoiceButton != null) { + mVoiceButton.setOnClickListener(v -> Toast.makeText(mOriginalContext, + R.string.home_conversation_voice_coming_soon, Toast.LENGTH_SHORT).show()); + } mAttachmentPreview = findViewById(R.id.attachment_draft_view); mAttachmentPreview.setComposeMessageView(this); @@ -666,7 +705,7 @@ public class ComposeMessageView extends LinearLayout mMessageBodySize.setVisibility(View.VISIBLE); } } else { - mMessageBodySize.setVisibility(View.INVISIBLE); + mMessageBodySize.setVisibility(View.GONE); } } else { // SMS case // Update the SMS text counter. @@ -685,7 +724,7 @@ public class ComposeMessageView extends LinearLayout mMessageBodySize.setText(counterText); mMessageBodySize.setVisibility(View.VISIBLE); } else { - mMessageBodySize.setVisibility(View.INVISIBLE); + mMessageBodySize.setVisibility(View.GONE); } } @@ -724,6 +763,7 @@ public class ComposeMessageView extends LinearLayout setSendButtonAccessibility(sendWidgetMode); mSendWidgetMode = sendWidgetMode; } + updateVoiceAndSendColumnVisibility(sendWidgetMode); // Update the text hint on the message box depending on the attachment type. final int attachmentCount = attachments.size(); @@ -795,6 +835,16 @@ public class ComposeMessageView extends LinearLayout } } + private void updateVoiceAndSendColumnVisibility(final int sendWidgetMode) { + if (mVoiceButton == null || mSendColumn == null) { + return; + } + final boolean showSendColumn = sendWidgetMode == SEND_WIDGET_MODE_SEND_BUTTON + || sendWidgetMode == SEND_WIDGET_MODE_SIM_SELECTOR; + mSendColumn.setVisibility(showSendColumn ? View.VISIBLE : View.GONE); + mVoiceButton.setVisibility(showSendColumn ? View.GONE : View.VISIBLE); + } + private void setSendButtonAccessibility(final int sendWidgetMode) { switch (sendWidgetMode) { case SEND_WIDGET_MODE_SELF_AVATAR: @@ -920,6 +970,9 @@ public class ComposeMessageView extends LinearLayout } public void hideAllComposeInputs(final boolean animate) { + if (mAttachmentSheetOpen) { + closeComposeAttachmentSheet(animate, null); + } mInputManager.hideAllInputs(animate); } @@ -928,10 +981,17 @@ public class ComposeMessageView extends LinearLayout } public void resetMediaPickerState() { + if (mAttachmentSheetOpen) { + closeComposeAttachmentSheet(false /* animate */, null); + } mInputManager.resetMediaPickerState(); } public boolean onBackPressed() { + if (mAttachmentSheetOpen) { + closeComposeAttachmentSheet(true /* animate */, null); + return true; + } return mInputManager.onBackPressed(); } @@ -982,4 +1042,167 @@ public class ComposeMessageView extends LinearLayout mAttachMediaButton.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO); } } + + private void setupComposeAttachmentSheetTiles() { + if (mAttachmentSheetContainer == null) { + return; + } + bindComposeAttachmentTile(R.id.home_mediapicker_tile_gallery, + R.drawable.ic_home_image_line, R.string.home_mediapicker_gallery, + ConversationInputManager.MEDIA_PICKER_TARGET_GALLERY); + bindComposeAttachmentTile(R.id.home_mediapicker_tile_camera, + R.drawable.ic_home_camera_line, R.string.home_mediapicker_camera, + ConversationInputManager.MEDIA_PICKER_TARGET_CAMERA); + bindComposeAttachmentTile(R.id.home_mediapicker_tile_contact, + R.drawable.ic_home_menu_person, R.string.home_mediapicker_contact, + ConversationInputManager.MEDIA_PICKER_TARGET_CONTACT); + bindComposeAttachmentTile(R.id.home_mediapicker_tile_file, + R.drawable.ic_attachment_dark, R.string.home_mediapicker_file, + ConversationInputManager.MEDIA_PICKER_TARGET_GALLERY); + bindComposeAttachmentTile(R.id.home_mediapicker_tile_gif, + R.drawable.ic_home_star_line, R.string.home_mediapicker_gif, -1); + bindComposeAttachmentTile(R.id.home_mediapicker_tile_sticker, + R.drawable.ic_home_emoji, R.string.home_mediapicker_sticker, -1); + bindComposeAttachmentTile(R.id.home_mediapicker_tile_schedule, + R.drawable.ic_home_bell, R.string.home_mediapicker_schedule, -1); + bindComposeAttachmentTile(R.id.home_mediapicker_tile_location_once, + R.drawable.ic_home_place_line, R.string.home_mediapicker_location_once, -1); + bindComposeAttachmentTile(R.id.home_mediapicker_tile_location_live, + R.drawable.ic_home_location_live, R.string.home_mediapicker_location_live, -1); + } + + private void bindComposeAttachmentTile(final int tileRootId, final int iconRes, + final int labelRes, final int mediaPickerTarget) { + final View tile = findViewById(tileRootId); + if (tile == null) { + return; + } + final ImageView icon = tile.findViewById(R.id.home_mediapicker_tile_icon); + final TextView label = tile.findViewById(R.id.home_mediapicker_tile_label); + if (icon != null) { + icon.setImageResource(iconRes); + } + if (label != null) { + label.setText(labelRes); + } + tile.setOnClickListener(v -> { + if (mediaPickerTarget >= 0) { + openMediaFromAttachmentSheet(mediaPickerTarget); + } else { + Toast.makeText(mOriginalContext, R.string.home_popover_coming_soon, + Toast.LENGTH_SHORT).show(); + } + }); + } + + private void toggleComposeAttachmentSheet() { + if (mAttachmentSheetOpen) { + closeComposeAttachmentSheet(true /* animate */, null); + } else { + if (mInputManager != null) { + mInputManager.showHideImeKeyboard(false, true); + mInputManager.showHideMediaPicker(false, false); + } + mAttachmentSheetOpen = true; + updateAttachButtonContentDescription(); + updateComposeBottomStackBackground(); + HomeUiMotion.composeAttachmentSheetOpen(mAttachmentSheetContainer, mAttachMediaButton); + } + } + + private void closeComposeAttachmentSheet(final boolean animate, final Runnable endAction) { + if (!mAttachmentSheetOpen || mAttachmentSheetContainer == null) { + if (endAction != null) { + endAction.run(); + } + return; + } + mAttachmentSheetOpen = false; + updateAttachButtonContentDescription(); + updateComposeBottomStackBackground(); + if (animate) { + HomeUiMotion.composeAttachmentSheetClose(mAttachmentSheetContainer, mAttachMediaButton, + endAction); + } else { + mAttachmentSheetContainer.setVisibility(View.GONE); + final ViewGroup.LayoutParams params = mAttachmentSheetContainer.getLayoutParams(); + params.height = 0; + mAttachmentSheetContainer.setLayoutParams(params); + mAttachmentSheetContainer.setTranslationY(0f); + mAttachmentSheetContainer.setAlpha(1f); + if (mAttachMediaButton != null) { + mAttachMediaButton.setImageResource(R.drawable.ic_home_compose_plus); + mAttachMediaButton.setRotation(0f); + } + if (endAction != null) { + endAction.run(); + } + } + } + + private void updateComposeBottomStackBackground() { + if (mComposeBottomStack == null) { + return; + } + if (mAttachmentSheetOpen) { + mComposeBottomStack.setBackgroundColor( + getResources().getColor(R.color.home_bg, mComposeBottomStack.getContext().getTheme())); + mComposeBottomStack.setClipToOutline(false); + if (mComposeBar != null) { + mComposeBar.setBackgroundColor( + getResources().getColor(R.color.home_bg, mComposeBar.getContext().getTheme())); + final int gap = getResources().getDimensionPixelSize( + R.dimen.home_compose_attach_sheet_gap); + mComposeBar.setPadding(mComposeBar.getPaddingLeft(), mComposeBar.getPaddingTop(), + mComposeBar.getPaddingRight(), gap); + } + if (mMessageBodySize != null && mMessageBodySize.getVisibility() != View.VISIBLE) { + mMessageBodySize.setVisibility(View.GONE); + } + } else { + mComposeBottomStack.setClipToOutline(false); + mComposeBottomStack.setBackgroundColor( + getResources().getColor(R.color.home_bg, mComposeBottomStack.getContext().getTheme())); + if (mComposeBar != null) { + mComposeBar.setBackgroundColor( + getResources().getColor(R.color.home_bg, mComposeBar.getContext().getTheme())); + mComposeBar.setPadding(mComposeBar.getPaddingLeft(), mComposeBar.getPaddingTop(), + mComposeBar.getPaddingRight(), mComposeBarPaddingBottom); + } + if (mMessageBodySize != null && mMessageBodySize.getVisibility() != View.VISIBLE) { + mMessageBodySize.setVisibility(View.GONE); + } + } + } + + private void openMediaFromAttachmentSheet(final int mediaPickerTarget) { + if (mediaPickerTarget == ConversationInputManager.MEDIA_PICKER_TARGET_GALLERY) { + closeComposeAttachmentSheet(false /* animate */, null); + mHost.launchSystemGalleryPicker(); + return; + } + if (mediaPickerTarget == ConversationInputManager.MEDIA_PICKER_TARGET_CONTACT) { + if (mInputManager != null) { + mInputManager.showHideMediaPicker(true /* show */, false /* animate */, + mediaPickerTarget); + } + closeComposeAttachmentSheet(false /* animate */, null); + return; + } + closeComposeAttachmentSheet(true /* animate */, () -> { + if (mInputManager != null) { + mInputManager.showHideMediaPicker(true /* show */, true /* animate */, + mediaPickerTarget); + } + }); + } + + private void updateAttachButtonContentDescription() { + if (mAttachMediaButton == null) { + return; + } + mAttachMediaButton.setContentDescription(getResources().getText(mAttachmentSheetOpen + ? R.string.home_sub_header_close + : R.string.attachMediaButtonContentDescription)); + } } diff --git a/app/src/main/java/com/android/messaging/ui/conversation/ConversationActivity.java b/app/src/main/java/com/android/messaging/ui/conversation/ConversationActivity.java index 3bf75ad..75d9ce2 100644 --- a/app/src/main/java/com/android/messaging/ui/conversation/ConversationActivity.java +++ b/app/src/main/java/com/android/messaging/ui/conversation/ConversationActivity.java @@ -61,6 +61,10 @@ public class ConversationActivity extends BugleActionBarActivity // Tracks whether onPause is called. private boolean mIsPaused; + // Photo Picker is a system Activity; skip its close animation so a black window + // does not slide down over the conversation when the handle is dismissed. + private boolean mSuppressNextResumeTransition; + @Override protected void onCreate(final Bundle savedInstanceState) { super.onCreate(savedInstanceState); @@ -146,9 +150,17 @@ public class ConversationActivity extends BugleActionBarActivity } } + public void suppressNextResumeTransition() { + mSuppressNextResumeTransition = true; + } + @Override protected void onResume() { super.onResume(); + if (mSuppressNextResumeTransition) { + overridePendingTransition(0, 0); + mSuppressNextResumeTransition = false; + } // we need to reset the mInstanceStateSaved flag since we may have just been restored from // a previous onStop() instead of an onDestroy(). 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 68fbb21..d18075a 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 @@ -43,11 +43,15 @@ import android.telephony.PhoneNumberUtils; import android.text.TextUtils; import android.view.ActionMode; import android.view.Display; +import android.view.HapticFeedbackConstants; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuInflater; import android.view.MenuItem; import android.view.View; +import android.widget.ImageButton; +import android.widget.PopupMenu; +import android.widget.Toast; import android.view.ViewConfiguration; import android.view.ViewGroup; import android.widget.TextView; @@ -72,7 +76,9 @@ import androidx.recyclerview.widget.RecyclerView.ViewHolder; import com.android.messaging.R; import com.android.messaging.datamodel.DataModel; import com.android.messaging.datamodel.MessagingContentProvider; +import com.android.messaging.datamodel.action.BugleActionToasts; import com.android.messaging.datamodel.action.InsertNewMessageAction; +import com.android.messaging.datamodel.action.UpdateDestinationBlockedAction; import com.android.messaging.datamodel.binding.Binding; import com.android.messaging.datamodel.binding.BindingBase; import com.android.messaging.datamodel.binding.ImmutableBindingRef; @@ -88,20 +94,27 @@ import com.android.messaging.datamodel.data.ParticipantData; import com.android.messaging.datamodel.data.SubscriptionListData.SubscriptionListEntry; import com.android.messaging.ui.AttachmentPreview; import com.android.messaging.ui.BugleActionBarActivity; +import com.android.messaging.ui.ContactIconView; import com.android.messaging.ui.ConversationDrawables; import com.android.messaging.ui.SnackBar; import com.android.messaging.ui.UIIntents; import com.android.messaging.ui.animation.PopupTransitionAnimation; +import com.android.messaging.ui.home.HomeGallerySheet; +import com.android.messaging.ui.home.HomeUiMotion; +import com.android.messaging.ui.home.UnknownSenderPromptStore; import com.android.messaging.ui.attachmentchooser.AttachmentChooserActivity; import com.android.messaging.ui.contact.AddContactsConfirmationDialog; import com.android.messaging.ui.conversation.ComposeMessageView.IComposeMessageViewHost; import com.android.messaging.ui.conversation.ConversationInputManager.ConversationInputHost; import com.android.messaging.ui.conversation.ConversationMessageView.ConversationMessageViewHost; +import com.android.messaging.ui.mediapicker.DocumentImagePicker; import com.android.messaging.ui.mediapicker.MediaPicker; import com.android.messaging.util.AccessibilityUtil; import com.android.messaging.util.Assert; import com.android.messaging.util.AvatarUriUtil; +import com.android.messaging.util.Dates; import com.android.messaging.util.ChangeDefaultSmsAppHelper; +import com.android.messaging.util.ContactUtil; import com.android.messaging.util.ImeUtil; import com.android.messaging.util.LogUtil; import com.android.messaging.util.PhoneUtils; @@ -142,11 +155,35 @@ public class ConversationFragment extends Fragment implements ConversationDataLi public static final int MESSAGE_ANIMATION_MAX_WAIT = 500; private ComposeMessageView mComposeMessageView; + private DocumentImagePicker mGalleryPicker; private RecyclerView mRecyclerView; private ConversationMessageAdapter mAdapter; private ConversationFastScroller mFastScroller; private View mConversationComposeDivider; + private View mEmptyContainer; + private TextView mEmptyTime; + private TextView mEmptyStatus; + private View mConversationHeader; + private TextView mConversationHeaderTitle; + private ContactIconView mConversationHeaderAvatar; + private ImageButton mConversationHeaderOverflow; + private boolean mOptionsMenuVisible = true; + private View mUnknownSenderBanner; + private TextView mUnknownSenderTitle; + private boolean mUnknownSenderBannerVisible; + private View mMessageSelectionHeader; + private TextView mMessageSelectionTitle; + private View mComposeContainer; + private View mMessageActionBar; + private View mMessageActionCopy; + private View mMessageActionForward; + private View mMessageActionShare; + private View mMessageActionDetails; + private View mMessageActionSave; + private View mMessageActionSend; + private View mMessageActionDownload; + private View mMessageActionDelete; private ChangeDefaultSmsAppHelper mChangeDefaultSmsAppHelper; private String mConversationId; @@ -267,133 +304,6 @@ public class ConversationFragment extends Fragment implements ConversationDataLi } }; - private final ActionMode.Callback mMessageActionModeCallback = new ActionMode.Callback() { - @Override - public boolean onCreateActionMode(final ActionMode actionMode, final Menu menu) { - if (mSelectedMessage == null) { - return false; - } - final ConversationMessageData data = mSelectedMessage.getData(); - final MenuInflater menuInflater = getActivity().getMenuInflater(); - menuInflater.inflate(R.menu.conversation_fragment_select_menu, menu); - menu.findItem(R.id.action_download).setVisible(data.getShowDownloadMessage()); - menu.findItem(R.id.action_send).setVisible(data.getShowResendMessage()); - - // ShareActionProvider does not work with ActionMode. So we use a normal menu item. - menu.findItem(R.id.share_message_menu).setVisible(data.getCanForwardMessage()); - menu.findItem(R.id.save_attachment).setVisible(mSelectedAttachment != null); - menu.findItem(R.id.forward_message_menu).setVisible(data.getCanForwardMessage()); - - // TODO: We may want to support copying attachments in the future, but it's - // unclear which attachment to pick when we make this context menu at the message level - // instead of the part level - menu.findItem(R.id.copy_text).setVisible(data.getCanCopyMessageToClipboard()); - - return true; - } - - @Override - public boolean onPrepareActionMode(final ActionMode actionMode, final Menu menu) { - return true; - } - - @Override - public boolean onActionItemClicked(final ActionMode actionMode, final MenuItem menuItem) { - final ConversationMessageData data = mSelectedMessage.getData(); - final String messageId = data.getMessageId(); - int itemId = menuItem.getItemId(); - if (itemId == R.id.save_attachment) { - final SaveAttachmentTask saveAttachmentTask = new SaveAttachmentTask( - getActivity()); - for (final MessagePartData part : data.getAttachments()) { - saveAttachmentTask.addAttachmentToSave(part.getContentUri(), - part.getContentType()); - } - if (saveAttachmentTask.getAttachmentCount() > 0) { - saveAttachmentTask.execute(); - mHost.dismissActionMode(); - } - return true; - } else if (itemId == R.id.action_delete_message) { - if (mSelectedMessage != null) { - deleteMessage(messageId); - } - return true; - } else if (itemId == R.id.action_download) { - if (mSelectedMessage != null) { - retryDownload(messageId); - mHost.dismissActionMode(); - } - return true; - } else if (itemId == R.id.action_send) { - if (mSelectedMessage != null) { - retrySend(messageId); - mHost.dismissActionMode(); - } - return true; - } else if (itemId == R.id.copy_text) { - Assert.isTrue(data.hasText()); - final ClipboardManager clipboard = (ClipboardManager) getActivity() - .getSystemService(Context.CLIPBOARD_SERVICE); - clipboard.setPrimaryClip( - ClipData.newPlainText(null /* label */, data.getText())); - mHost.dismissActionMode(); - return true; - } else if (itemId == R.id.details_menu) { - MessageDetailsDialog.show( - getActivity(), data, mBinding.getData().getParticipants(), - mBinding.getData().getSelfParticipantById(data.getSelfParticipantId())); - mHost.dismissActionMode(); - return true; - } else if (itemId == R.id.share_message_menu) { - shareMessage(data); - mHost.dismissActionMode(); - return true; - } else if (itemId == R.id.forward_message_menu) { - // TODO: Currently we are forwarding one part at a time, instead of - // the entire message. Change this to forwarding the entire message when we - // use message-based cursor in conversation. - final MessageData message = mBinding.getData().createForwardedMessage(data); - UIIntents.get().launchForwardMessageActivity(getActivity(), message); - mHost.dismissActionMode(); - return true; - } - return false; - } - - private void shareMessage(final ConversationMessageData data) { - // Figure out what to share. - MessagePartData attachmentToShare = mSelectedAttachment; - // If the user long-pressed on the background, we will share the text (if any) - // or the first attachment. - if (mSelectedAttachment == null - && TextUtil.isAllWhitespace(data.getText())) { - final List attachments = data.getAttachments(); - if (attachments.size() > 0) { - attachmentToShare = attachments.get(0); - } - } - - final Intent shareIntent = new Intent(); - shareIntent.setAction(Intent.ACTION_SEND); - if (attachmentToShare == null) { - shareIntent.putExtra(Intent.EXTRA_TEXT, data.getText()); - shareIntent.setType("text/plain"); - } else { - shareIntent.putExtra( - Intent.EXTRA_STREAM, attachmentToShare.getContentUri()); - shareIntent.setType(attachmentToShare.getContentType()); - } - final CharSequence title = getResources().getText(R.string.action_share); - startActivity(Intent.createChooser(shareIntent, title)); - } - - @Override - public void onDestroyActionMode(final ActionMode actionMode) { - selectMessage(null); - } - }; - private final ActivityResultLauncher mLauncher = registerForActivityResult( new ActivityResultContracts.StartActivityForResult(), result -> { if (result.getResultCode() == Activity.RESULT_OK) { @@ -419,6 +329,11 @@ public class ConversationFragment extends Fragment implements ConversationDataLi @Override public void onCreate(final Bundle savedInstanceState) { super.onCreate(savedInstanceState); + mGalleryPicker = new DocumentImagePicker(this, pendingItem -> { + if (mComposeMessageView != null) { + mComposeMessageView.onPendingAttachmentAdded(pendingItem); + } + }); mAdapter = new ConversationMessageAdapter(getActivity(), null, this, null, // Sets the item click listener on the Recycler item views. @@ -427,6 +342,7 @@ public class ConversationFragment extends Fragment implements ConversationDataLi handleMessageClick(messageView); }, view -> { + view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS); selectMessage((ConversationMessageView) view); return true; } @@ -507,6 +423,12 @@ public class ConversationFragment extends Fragment implements ConversationDataLi final Bundle savedInstanceState) { final View view = inflater.inflate(R.layout.conversation_fragment, container, false); mRecyclerView = view.findViewById(android.R.id.list); + mEmptyContainer = view.findViewById(R.id.home_conversation_empty); + mEmptyTime = view.findViewById(R.id.home_conversation_empty_time); + mEmptyStatus = view.findViewById(R.id.home_conversation_empty_status); + setupConversationHeader(view); + setupUnknownSenderBanner(view); + setupMessageSelectionChrome(view); final LinearLayoutManager manager = new LinearLayoutManager(getActivity()); manager.setStackFromEnd(true); manager.setReverseLayout(false); @@ -672,13 +594,19 @@ public class ConversationFragment extends Fragment implements ConversationDataLi mSelectedMessage = messageView; if (mSelectedMessage == null) { mAdapter.setSelectedMessage(null); - mHost.dismissActionMode(); mSelectedAttachment = null; + updateMessageSelectionChrome(false); + updateUnknownSenderBanner(); return; } mSelectedAttachment = attachment; mAdapter.setSelectedMessage(messageView.getData().getMessageId()); - mHost.startActionMode(mMessageActionModeCallback); + updateMessageSelectionChrome(true); + updateUnknownSenderBanner(); + } + + private void dismissMessageSelection() { + selectMessage(null); } @Override @@ -732,7 +660,7 @@ public class ConversationFragment extends Fragment implements ConversationDataLi @Override public void onCreateOptionsMenu(@NonNull final Menu menu, @NonNull final MenuInflater inflater) { - if (mHost.getActionMode() != null) { + if (mSelectedMessage != null) { return; } @@ -746,7 +674,9 @@ public class ConversationFragment extends Fragment implements ConversationDataLi // See if we can show add contact action. final ParticipantData participant = data.getOtherParticipant(); final boolean addContactActionVisible = (participant != null - && TextUtils.isEmpty(participant.getLookupKey())); + && !ContactUtil.isParticipantInAddressBook(getActivity(), + participant.getContactId(), participant.getLookupKey(), + participant.getNormalizedDestination())); menu.findItem(R.id.action_add_contact).setVisible(addContactActionVisible); // See if we should show archive or unarchive. @@ -765,32 +695,14 @@ public class ConversationFragment extends Fragment implements ConversationDataLi int itemId = item.getItemId(); if (itemId == R.id.action_people_and_options) { Assert.isTrue(mBinding.getData().getParticipantsLoaded()); - UIIntents.get().launchPeopleAndOptionsActivity(getActivity(), mConversationId); + UIIntents.get().launchHomeProfileActivity(getActivity(), mConversationId); + return true; + } else if (itemId == R.id.action_search_conversation) { + UIIntents.get().launchHomeConversationSearchActivity(getActivity(), mConversationId); return true; } else if (itemId == R.id.action_call) { - final String phoneNumber = mBinding.getData().getParticipantPhoneNumber(); - Assert.notNull(phoneNumber); - // Can't make a call to emergency numbers using ACTION_CALL. - if (PhoneNumberUtils.isEmergencyNumber(phoneNumber)) { - UiUtils.showToast(R.string.disallow_emergency_call); - } else { - final View targetView = getActivity().findViewById(R.id.action_call); - Point centerPoint; - if (targetView != null) { - final int[] screenLocation = new int[2]; - targetView.getLocationOnScreen(screenLocation); - final int centerX = screenLocation[0] + targetView.getWidth() / 2; - final int centerY = screenLocation[1] + targetView.getHeight() / 2; - centerPoint = new Point(centerX, centerY); - } else { - // In the overflow menu, just use the center of the screen. - final Display display = - getActivity().getWindowManager().getDefaultDisplay(); - centerPoint = new Point(display.getWidth() / 2, display.getHeight() / 2); - } - UIIntents.get() - .launchPhoneCallActivity(getActivity(), phoneNumber, centerPoint); - } + placeConversationCall(getActivity() != null + ? getActivity().findViewById(R.id.action_call) : null); return true; } else if (itemId == R.id.action_archive) { mBinding.getData().archiveConversation(mBinding); @@ -904,6 +816,8 @@ public class ConversationFragment extends Fragment implements ConversationDataLi } } + updateHomeEmptyState(); + updateUnknownSenderBanner(); mHost.invalidateActionBar(); } @@ -931,6 +845,9 @@ public class ConversationFragment extends Fragment implements ConversationDataLi invalidateOptionsMenu(); mHost.onConversationMetadataUpdated(); mAdapter.notifyDataSetChanged(); + updateHomeEmptyState(); + updateUnknownSenderBanner(); + mHost.invalidateActionBar(); } public void setConversationInfo(final Context context, final String conversationId, @@ -940,6 +857,7 @@ public class ConversationFragment extends Fragment implements ConversationDataLi if (!mBinding.isBound()) { mConversationId = conversationId; mIncomingDraft = draftData; + mUnknownSenderBannerVisible = false; mBinding.bind(DataModel.get().createConversationData(context, this, conversationId)); } else { Assert.isTrue(TextUtils.equals(mBinding.getData().getConversationId(), conversationId)); @@ -1118,12 +1036,12 @@ public class ConversationFragment extends Fragment implements ConversationDataLi (dialog, which) -> mBinding.getData().deleteMessage(mBinding, messageId)) .setNegativeButton(android.R.string.cancel, null); - builder.setOnDismissListener(dialog -> mHost.dismissActionMode()); + builder.setOnDismissListener(dialog -> dismissMessageSelection()); builder.create().show(); } else { warnOfMissingActionConditions(false /*sending*/, null /*commandToRunAfterActionConditionResolved*/); - mHost.dismissActionMode(); + dismissMessageSelection(); } } @@ -1159,6 +1077,7 @@ public class ConversationFragment extends Fragment implements ConversationDataLi mHost.invalidateActionBar(); mRecyclerView.setVisibility(View.VISIBLE); + updateUnknownSenderBanner(); } } @@ -1187,6 +1106,13 @@ public class ConversationFragment extends Fragment implements ConversationDataLi } public boolean onBackPressed() { + if (HomeGallerySheet.dismissIfShowing(getActivity())) { + return true; + } + if (mSelectedMessage != null) { + dismissMessageSelection(); + return true; + } if (mComposeMessageView.onBackPressed()) { return true; } @@ -1366,7 +1292,11 @@ public class ConversationFragment extends Fragment implements ConversationDataLi @Override public void setOptionsMenuVisibility(final boolean visible) { + mOptionsMenuVisible = visible; setHasOptionsMenu(visible); + if (mConversationHeaderOverflow != null) { + mConversationHeaderOverflow.setVisibility(visible ? View.VISIBLE : View.GONE); + } } @Override @@ -1385,6 +1315,9 @@ public class ConversationFragment extends Fragment implements ConversationDataLi @Override public void dismissActionMode() { + if (mSelectedMessage != null) { + dismissMessageSelection(); + } mHost.dismissActionMode(); } @@ -1454,6 +1387,13 @@ public class ConversationFragment extends Fragment implements ConversationDataLi builder.show(); } + @Override + public void launchSystemGalleryPicker() { + if (mGalleryPicker != null) { + mGalleryPicker.launchPicker(); + } + } + @Override public void showAttachmentChooser() { showAttachmentChooser(mConversationId, getActivity()); @@ -1467,62 +1407,444 @@ public class ConversationFragment extends Fragment implements ConversationDataLi } private void updateActionAndStatusBarColor(final ActionBar actionBar) { - final int themeColor = ConversationDrawables.get().getConversationThemeColor(); - actionBar.setBackgroundDrawable(new ColorDrawable(themeColor)); - UiUtils.setStatusBarColor(getActivity(), themeColor); + final int barColor = getResources().getColor(R.color.home_conversation_chrome_bg, + getActivity().getTheme()); + actionBar.setBackgroundDrawable(new ColorDrawable(barColor)); + UiUtils.setStatusBarColor(getActivity(), barColor); } - public void updateActionBar(final ActionBar actionBar) { - if (mComposeMessageView == null || !mComposeMessageView.updateActionBar(actionBar)) { - updateActionAndStatusBarColor(actionBar); - // We update this regardless of whether or not the action bar is showing so that we - // don't get a race when it reappears. - actionBar.setDisplayOptions(ActionBar.DISPLAY_SHOW_CUSTOM); - actionBar.setDisplayHomeAsUpEnabled(true); - // Reset the back arrow to its default - actionBar.setHomeAsUpIndicator(0); - View customView = actionBar.getCustomView(); - if (customView == null || customView.getId() != R.id.conversation_title_container) { - final LayoutInflater inflator = (LayoutInflater) - getActivity().getSystemService(Context.LAYOUT_INFLATER_SERVICE); - customView = inflator.inflate(R.layout.action_bar_conversation_name, null); - customView.setOnClickListener(v -> onBackPressed()); - actionBar.setCustomView(customView); - } + private void updateHomeEmptyState() { + if (mEmptyContainer == null || mAdapter == null) { + return; + } + final boolean empty = mAdapter.getItemCount() == 0; + mEmptyContainer.setVisibility(empty ? View.VISIBLE : View.GONE); + if (!empty) { + return; + } + if (mEmptyTime != null) { + mEmptyTime.setText(Dates.getMessageTimeString(System.currentTimeMillis())); + } + if (mEmptyStatus != null) { + final String name = getConversationName(); + mEmptyStatus.setText(getString(R.string.home_conversation_empty_status, + TextUtils.isEmpty(name) ? getString(R.string.app_name) : name)); + } + } - final TextView conversationNameView = customView.findViewById(R.id.conversation_title); + private void setupUnknownSenderBanner(final View root) { + mUnknownSenderBanner = root.findViewById(R.id.home_unknown_sender_banner); + mUnknownSenderTitle = root.findViewById(R.id.home_unknown_sender_title); + if (mUnknownSenderBanner == null) { + return; + } + final View dismiss = root.findViewById(R.id.home_unknown_sender_dismiss); + if (dismiss != null) { + dismiss.setOnClickListener(v -> { + UnknownSenderPromptStore.setDismissed(requireContext(), mConversationId); + hideUnknownSenderBanner(true /* animate */); + }); + } + final View reportSpam = root.findViewById(R.id.home_unknown_sender_report_spam); + if (reportSpam != null) { + reportSpam.setOnClickListener(v -> { + if (!mBinding.isBound()) { + return; + } + final ParticipantData other = mBinding.getData().getOtherParticipant(); + if (other != null) { + confirmBlockUnknownSender(other); + } + }); + } + final View addContact = root.findViewById(R.id.home_unknown_sender_add_contact); + if (addContact != null) { + addContact.setOnClickListener(v -> { + if (!mBinding.isBound()) { + return; + } + final ParticipantData participant = mBinding.getData().getOtherParticipant(); + if (participant == null) { + return; + } + new AddContactsConfirmationDialog(getActivity(), + AvatarUriUtil.createAvatarUri(participant), + participant.getNormalizedDestination()).show(); + }); + } + } + + private void confirmBlockUnknownSender(final ParticipantData other) { + if (getActivity() == null) { + return; + } + new AlertDialog.Builder(getActivity(), R.style.Home_Dialog) + .setTitle(getString(R.string.block_confirmation_title, + other.getDisplayDestination())) + .setMessage(R.string.block_confirmation_message) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(android.R.string.ok, (dialog, which) -> { + UpdateDestinationBlockedAction.updateDestinationBlocked( + other.getNormalizedDestination(), true, mConversationId, + BugleActionToasts.makeUpdateDestinationBlockedActionListener( + getActivity())); + UnknownSenderPromptStore.setDismissed(requireContext(), mConversationId); + hideUnknownSenderBanner(false /* animate */); + }) + .show(); + } + + private void updateUnknownSenderBanner() { + if (mUnknownSenderBanner == null || !mBinding.isBound()) { + return; + } + final ConversationData data = mBinding.getData(); + final ParticipantData other = data.getOtherParticipant(); + final boolean show = mSelectedMessage == null + && data.getParticipantsLoaded() + && other != null + && TextUtils.isEmpty(other.getLookupKey()) + && !other.isBlocked() + && !UnknownSenderPromptStore.isDismissed(requireContext(), mConversationId); + if (!show) { + hideUnknownSenderBanner(false /* animate */); + return; + } + if (mUnknownSenderTitle != null) { + mUnknownSenderTitle.setText(getString(R.string.home_unknown_sender_title, + other.getDisplayDestination())); + } + if (mUnknownSenderBannerVisible) { + mUnknownSenderBanner.setVisibility(View.VISIBLE); + return; + } + mUnknownSenderBannerVisible = true; + HomeUiMotion.unknownSenderBannerEnter(mUnknownSenderBanner); + } + + private void hideUnknownSenderBanner(final boolean animate) { + if (mUnknownSenderBanner == null) { + return; + } + if (!mUnknownSenderBannerVisible + && mUnknownSenderBanner.getVisibility() != View.VISIBLE) { + return; + } + mUnknownSenderBannerVisible = false; + if (animate) { + HomeUiMotion.unknownSenderBannerHide(mUnknownSenderBanner, null); + } else { + mUnknownSenderBanner.animate().cancel(); + mUnknownSenderBanner.setVisibility(View.GONE); + mUnknownSenderBanner.setAlpha(1f); + mUnknownSenderBanner.setTranslationY(0f); + } + } + + private void setupConversationHeader(final View root) { + mConversationHeader = root.findViewById(R.id.conversation_header); + final View back = root.findViewById(R.id.conversation_header_back); + if (back != null) { + back.setOnClickListener(v -> { + if (getActivity() != null) { + getActivity().onBackPressed(); + } + }); + } + mConversationHeaderTitle = root.findViewById(R.id.conversation_header_title); + mConversationHeaderAvatar = root.findViewById(R.id.conversation_header_avatar); + if (mConversationHeaderAvatar != null) { + mConversationHeaderAvatar.setOnClickListener(v -> openConversationProfileDetails()); + } + if (mConversationHeaderTitle != null) { + mConversationHeaderTitle.setOnClickListener(v -> openConversationProfileDetails()); + } + mConversationHeaderOverflow = root.findViewById(R.id.conversation_header_overflow); + if (mConversationHeaderOverflow != null) { + mConversationHeaderOverflow.setOnClickListener(this::showConversationOverflowMenu); + } + } + + private void openConversationProfileDetails() { + if (getActivity() == null || TextUtils.isEmpty(mConversationId)) { + return; + } + if (mBinding.isBound()) { + final ParticipantData other = mBinding.getData().getOtherParticipant(); + if (other != null && ContactUtil.openContactDetail(getActivity(), + other.getContactId(), other.getLookupKey(), + other.getNormalizedDestination())) { + return; + } + } + UIIntents.get().launchHomeProfileActivity(getActivity(), mConversationId); + } + + private void setupMessageSelectionChrome(final View root) { + mMessageSelectionHeader = root.findViewById(R.id.home_message_selection_header); + mMessageSelectionTitle = root.findViewById(R.id.home_message_selection_title); + mComposeContainer = root.findViewById(R.id.message_compose_view_container); + mMessageActionBar = root.findViewById(R.id.home_message_action_bar); + mMessageActionCopy = root.findViewById(R.id.home_message_action_copy); + mMessageActionForward = root.findViewById(R.id.home_message_action_forward); + mMessageActionShare = root.findViewById(R.id.home_message_action_share); + mMessageActionDetails = root.findViewById(R.id.home_message_action_details); + mMessageActionSave = root.findViewById(R.id.home_message_action_save); + mMessageActionSend = root.findViewById(R.id.home_message_action_send); + mMessageActionDownload = root.findViewById(R.id.home_message_action_download); + mMessageActionDelete = root.findViewById(R.id.home_message_action_delete); + + final View done = root.findViewById(R.id.home_message_selection_done); + if (done != null) { + done.setOnClickListener(v -> dismissMessageSelection()); + } + if (mMessageActionCopy != null) { + mMessageActionCopy.setOnClickListener(v -> performSelectedMessageAction( + R.id.home_message_action_copy)); + } + if (mMessageActionForward != null) { + mMessageActionForward.setOnClickListener(v -> performSelectedMessageAction( + R.id.home_message_action_forward)); + } + if (mMessageActionShare != null) { + mMessageActionShare.setOnClickListener(v -> performSelectedMessageAction( + R.id.home_message_action_share)); + } + if (mMessageActionDetails != null) { + mMessageActionDetails.setOnClickListener(v -> performSelectedMessageAction( + R.id.home_message_action_details)); + } + if (mMessageActionSave != null) { + mMessageActionSave.setOnClickListener(v -> performSelectedMessageAction( + R.id.home_message_action_save)); + } + if (mMessageActionSend != null) { + mMessageActionSend.setOnClickListener(v -> performSelectedMessageAction( + R.id.home_message_action_send)); + } + if (mMessageActionDownload != null) { + mMessageActionDownload.setOnClickListener(v -> performSelectedMessageAction( + R.id.home_message_action_download)); + } + if (mMessageActionDelete != null) { + mMessageActionDelete.setOnClickListener(v -> performSelectedMessageAction( + R.id.home_message_action_delete)); + } + } + + private void updateMessageSelectionChrome(final boolean active) { + if (mMessageSelectionHeader == null || mMessageActionBar == null) { + return; + } + refreshMessageActionButtons(); + if (active) { + final View outgoingHeader = mConversationHeader; + HomeUiMotion.replaceHeaderInstant(outgoingHeader, mMessageSelectionHeader); + } else { + HomeUiMotion.replaceHeaderInstant(mMessageSelectionHeader, mConversationHeader); + mHost.invalidateActionBar(); + } + HomeUiMotion.swapBottomDock(mComposeContainer, mMessageActionBar, active, null); + } + + private void refreshMessageActionButtons() { + if (mSelectedMessage == null) { + return; + } + final ConversationMessageData data = mSelectedMessage.getData(); + setMessageActionVisible(mMessageActionCopy, data.getCanCopyMessageToClipboard()); + final boolean canForward = data.getCanForwardMessage(); + setMessageActionVisible(mMessageActionForward, canForward); + setMessageActionVisible(mMessageActionShare, canForward); + setMessageActionVisible(mMessageActionDetails, true); + setMessageActionVisible(mMessageActionSave, mSelectedAttachment != null); + setMessageActionVisible(mMessageActionSend, data.getShowResendMessage()); + setMessageActionVisible(mMessageActionDownload, data.getShowDownloadMessage()); + setMessageActionVisible(mMessageActionDelete, true); + } + + private static void setMessageActionVisible(final View action, final boolean visible) { + if (action != null) { + action.setVisibility(visible ? View.VISIBLE : View.GONE); + } + } + + private void performSelectedMessageAction(final int actionId) { + if (mSelectedMessage == null || getActivity() == null) { + return; + } + final ConversationMessageData data = mSelectedMessage.getData(); + final String messageId = data.getMessageId(); + if (actionId == R.id.home_message_action_save) { + final SaveAttachmentTask saveAttachmentTask = new SaveAttachmentTask(getActivity()); + for (final MessagePartData part : data.getAttachments()) { + saveAttachmentTask.addAttachmentToSave(part.getContentUri(), + part.getContentType()); + } + if (saveAttachmentTask.getAttachmentCount() > 0) { + saveAttachmentTask.execute(); + dismissMessageSelection(); + } + } else if (actionId == R.id.home_message_action_delete) { + deleteMessage(messageId); + } else if (actionId == R.id.home_message_action_download) { + retryDownload(messageId); + dismissMessageSelection(); + } else if (actionId == R.id.home_message_action_send) { + retrySend(messageId); + dismissMessageSelection(); + } else if (actionId == R.id.home_message_action_copy) { + Assert.isTrue(data.hasText()); + final ClipboardManager clipboard = (ClipboardManager) getActivity() + .getSystemService(Context.CLIPBOARD_SERVICE); + clipboard.setPrimaryClip( + 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())); + dismissMessageSelection(); + } else if (actionId == R.id.home_message_action_share) { + shareSelectedMessage(data); + dismissMessageSelection(); + } else if (actionId == R.id.home_message_action_forward) { + final MessageData message = mBinding.getData().createForwardedMessage(data); + UIIntents.get().launchForwardMessageActivity(getActivity(), message); + dismissMessageSelection(); + } + } + + private void shareSelectedMessage(final ConversationMessageData data) { + MessagePartData attachmentToShare = mSelectedAttachment; + if (mSelectedAttachment == null && TextUtil.isAllWhitespace(data.getText())) { + final List attachments = data.getAttachments(); + if (!attachments.isEmpty()) { + attachmentToShare = attachments.get(0); + } + } + final Intent shareIntent = new Intent(); + shareIntent.setAction(Intent.ACTION_SEND); + if (attachmentToShare == null) { + shareIntent.putExtra(Intent.EXTRA_TEXT, data.getText()); + shareIntent.setType("text/plain"); + } else { + shareIntent.putExtra(Intent.EXTRA_STREAM, attachmentToShare.getContentUri()); + shareIntent.setType(attachmentToShare.getContentType()); + } + final CharSequence title = getResources().getText(R.string.action_share); + startActivity(Intent.createChooser(shareIntent, title)); + } + + private void placeConversationCall(final View anchorView) { + if (!mBinding.isBound() || getActivity() == null) { + return; + } + final String phoneNumber = mBinding.getData().getParticipantPhoneNumber(); + if (phoneNumber == null) { + return; + } + if (PhoneNumberUtils.isEmergencyNumber(phoneNumber)) { + UiUtils.showToast(R.string.disallow_emergency_call); + return; + } + Point centerPoint; + if (anchorView != null) { + final int[] screenLocation = new int[2]; + anchorView.getLocationOnScreen(screenLocation); + centerPoint = new Point(screenLocation[0] + anchorView.getWidth() / 2, + screenLocation[1] + anchorView.getHeight() / 2); + } else { + final Display display = getActivity().getWindowManager().getDefaultDisplay(); + centerPoint = new Point(display.getWidth() / 2, display.getHeight() / 2); + } + UIIntents.get().launchPhoneCallActivity(getActivity(), phoneNumber, centerPoint); + } + + private void showConversationOverflowMenu(final View anchor) { + if (!mOptionsMenuVisible || getActivity() == null) { + return; + } + final PopupMenu popup = new PopupMenu(requireContext(), anchor); + popup.setOnMenuItemClickListener(this::onOptionsItemSelected); + onCreateOptionsMenu(popup.getMenu(), requireActivity().getMenuInflater()); + onPrepareOptionsMenu(popup.getMenu()); + popup.show(); + } + + private void bindConversationHeaderAvatar(final ContactIconView avatar) { + if (avatar == null || !mBinding.isBound()) { + return; + } + final ConversationData data = mBinding.getData(); + final ParticipantData other = data.getOtherParticipant(); + if (other != null) { + avatar.setImageResourceUri(AvatarUriUtil.createAvatarUri(other), + other.getContactId(), other.getLookupKey(), + other.getNormalizedDestination()); + } else if (!TextUtils.isEmpty(data.getIcon())) { + avatar.setImageResourceUri(Uri.parse(data.getIcon()), 0, null, null); + } + avatar.setImageClickHandlerDisabled(true); + } + + private void updateHomeConversationHeader() { + if (mSelectedMessage != null) { + return; + } + if (mConversationHeader == null) { + return; + } + bindConversationHeaderContent(); + } + + private void bindConversationHeaderContent() { + if (mConversationHeader == null) { + return; + } + bindConversationHeaderAvatar(mConversationHeaderAvatar); + + final TextView titleView = mConversationHeaderTitle; + if (titleView != null) { final String conversationName = getConversationName(); if (!TextUtils.isEmpty(conversationName)) { - // RTL : To format conversation title if it happens to be phone numbers. final BidiFormatter bidiFormatter = BidiFormatter.getInstance(); final String formattedName = bidiFormatter.unicodeWrap( UiUtils.commaEllipsize( conversationName, - conversationNameView.getPaint(), - conversationNameView.getWidth(), + titleView.getPaint(), + titleView.getWidth(), getString(R.string.plus_one), getString(R.string.plus_n)).toString(), TextDirectionHeuristicsCompat.LTR); - conversationNameView.setText(formattedName); - // In case phone numbers are mixed in the conversation name, we need to vocalize it. + titleView.setText(formattedName); final String vocalizedConversationName = AccessibilityUtil.getVocalizedPhoneNumber(getResources(), conversationName); - conversationNameView.setContentDescription(vocalizedConversationName); - getActivity().setTitle(conversationName); - } else { + titleView.setContentDescription(vocalizedConversationName); + if (getActivity() != null) { + getActivity().setTitle(conversationName); + } + } else if (getActivity() != null) { final String appName = getString(R.string.app_name); - conversationNameView.setText(appName); + titleView.setText(appName); getActivity().setTitle(appName); } - - // When conversation is showing and media picker is not showing, then hide the action - // bar only when we are in landscape mode, with IME open. - if (mHost.isImeOpen() && UiUtils.isLandscapeMode()) { - actionBar.hide(); - } else { - actionBar.show(); - } } + + final boolean hideForIme = mHost.isImeOpen() && UiUtils.isLandscapeMode(); + mConversationHeader.setVisibility(hideForIme ? View.GONE : View.VISIBLE); + } + + public void updateActionBar(final ActionBar actionBar) { + if (mComposeMessageView != null && mComposeMessageView.updateActionBar(actionBar)) { + if (mConversationHeader != null) { + mConversationHeader.setVisibility(View.GONE); + } + return; + } + updateActionAndStatusBarColor(actionBar); + actionBar.hide(); + updateHomeConversationHeader(); } @Override diff --git a/app/src/main/java/com/android/messaging/ui/conversation/ConversationInputManager.java b/app/src/main/java/com/android/messaging/ui/conversation/ConversationInputManager.java index 0a5f328..3d35bd1 100644 --- a/app/src/main/java/com/android/messaging/ui/conversation/ConversationInputManager.java +++ b/app/src/main/java/com/android/messaging/ui/conversation/ConversationInputManager.java @@ -50,6 +50,13 @@ import java.util.Collection; * as the other components). */ public class ConversationInputManager implements ConversationInput.ConversationInputBase { + + public static final int MEDIA_PICKER_TARGET_DEFAULT = 0; + public static final int MEDIA_PICKER_TARGET_CAMERA = 1; + public static final int MEDIA_PICKER_TARGET_GALLERY = 2; + public static final int MEDIA_PICKER_TARGET_AUDIO = 3; + public static final int MEDIA_PICKER_TARGET_CONTACT = 4; + /** * The host component where all input components are contained. This is typically the * conversation fragment but may be mocked in test code. @@ -189,6 +196,18 @@ public class ConversationInputManager implements ConversationInput.ConversationI } public void showHideMediaPicker(final boolean show, final boolean animate) { + showHideMediaPicker(show, animate, MEDIA_PICKER_TARGET_DEFAULT); + } + + public boolean isMediaPickerOpen() { + return mMediaInput.mShowing; + } + + public void showHideMediaPicker(final boolean show, final boolean animate, + final int mediaPickerTarget) { + if (show) { + mMediaInput.setOpenTarget(mediaPickerTarget); + } showHideInternal(mMediaInput, show, animate); } @@ -322,6 +341,11 @@ public class ConversationInputManager implements ConversationInput.ConversationI } private MediaPicker mMediaPicker; + private int mOpenTarget = MEDIA_PICKER_TARGET_DEFAULT; + + void setOpenTarget(final int openTarget) { + mOpenTarget = openTarget; + } @Override public boolean show(boolean animate) { @@ -393,7 +417,26 @@ public class ConversationInputManager implements ConversationInput.ConversationI }); } - mMediaPicker.open(MediaPicker.MEDIA_TYPE_DEFAULT, animate); + switch (mOpenTarget) { + case MEDIA_PICKER_TARGET_CAMERA: + mMediaPicker.openCamera(animate); + break; + case MEDIA_PICKER_TARGET_GALLERY: + mMediaPicker.openGallery(animate); + // System picker only — do not keep the in-app panel marked open. + return false; + case MEDIA_PICKER_TARGET_AUDIO: + mMediaPicker.openAudio(animate); + break; + case MEDIA_PICKER_TARGET_CONTACT: + mMediaPicker.openContact(animate); + // System picker only — do not keep the in-app panel marked open. + return false; + default: + mMediaPicker.open(MediaPicker.MEDIA_TYPE_DEFAULT, animate); + break; + } + mOpenTarget = MEDIA_PICKER_TARGET_DEFAULT; return isOpen(); } 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 ecc97f7..1011768 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 @@ -26,6 +26,7 @@ import android.content.res.Resources; import android.net.Uri; import android.os.Bundle; import android.view.View; +import android.widget.Toast; import androidx.activity.result.ActivityResultLauncher; import androidx.activity.result.contract.ActivityResultContracts; @@ -42,8 +43,10 @@ import com.android.messaging.datamodel.data.ConversationListItemData; import com.android.messaging.ui.BugleActionBarActivity; import com.android.messaging.ui.SnackBar; import com.android.messaging.ui.SnackBarInteraction; +import com.android.messaging.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.conversationlist.ConversationListFragment.ConversationListFragmentHost; import com.android.messaging.ui.conversationlist.MultiSelectActionModeCallback.SelectedConversation; import com.android.messaging.util.ChangeDefaultSmsAppHelper; @@ -61,6 +64,10 @@ import java.util.List; public abstract class AbstractConversationListActivity extends BugleActionBarActivity implements ConversationListFragmentHost, MultiSelectActionModeCallback.Listener { + private static final UpdateDestinationBlockedAction.UpdateDestinationBlockedActionListener + NOOP_BLOCK_LISTENER = + (action, success, block, destination) -> { }; + protected ConversationListFragment mConversationListFragment; private ChangeDefaultSmsAppHelper mChangeDefaultSmsAppHelper; @@ -91,21 +98,27 @@ public abstract class AbstractConversationListActivity extends BugleActionBarAct } protected void startMultiSelectActionMode() { - startActionMode(new MultiSelectActionModeCallback(this, useHomeSelectionChrome())); - mConversationListFragment.dismissFab(); - if (useHomeSelectionChrome()) { - mConversationListFragment.setHomeSelectionMode(true); + final boolean homeChrome = useHomeSelectionChrome(); + if (homeChrome) { hideHomeContextualActionBar(); + mConversationListFragment.dismissFab(true); + } + startActionMode(new MultiSelectActionModeCallback(this, homeChrome)); + if (homeChrome) { + mConversationListFragment.setHomeSelectionMode(true); + } else { + mConversationListFragment.dismissFab(); } } public void exitMultiSelectState() { + dismissActionMode(); if (useHomeSelectionChrome()) { mConversationListFragment.setHomeSelectionMode(false); + } else { + mConversationListFragment.updateUi(); } mConversationListFragment.showFab(); - dismissActionMode(); - mConversationListFragment.updateUi(); } protected void hideHomeContextualActionBar() { @@ -121,7 +134,28 @@ public abstract class AbstractConversationListActivity extends BugleActionBarAct } final MultiSelectActionModeCallback callback = (MultiSelectActionModeCallback) getActionModeCallback(); - onActionBarDelete(callback.getSelectedConversations()); + final Collection conversations = + callback.getSelectedConversations(); + if (conversations.isEmpty()) { + return; + } + if (useHomeSelectionChrome()) { + showHomeDeleteAlert(conversations); + } else { + onActionBarDelete(conversations); + } + } + + public void markSelectedConversationsUnread() { + if (!isInConversationListSelectMode()) { + return; + } + final MultiSelectActionModeCallback callback = + (MultiSelectActionModeCallback) getActionModeCallback(); + for (final MultiSelectActionModeCallback.SelectedConversation conversation + : callback.getSelectedConversations()) { + MarkAsUnreadAction.markAsUnread(conversation.conversationId); + } } protected boolean isInConversationListSelectMode() { @@ -133,23 +167,97 @@ public abstract class AbstractConversationListActivity extends BugleActionBarAct return isInConversationListSelectMode(); } + private void showHomeDeleteAlert( + final Collection conversations) { + if (!ensureDefaultSmsAppForDelete()) { + return; + } + HomeDeleteAlertDialog.show(this, conversations.size(), + new HomeDeleteAlertDialog.Listener() { + @Override + public void onDelete() { + performDeleteConversations(conversations); + } + + @Override + public void onDeleteAndRegisterSpam() { + performDeleteAndRegisterSpam(conversations); + } + }); + } + + private boolean ensureDefaultSmsAppForDelete() { + if (PhoneUtils.getDefault().isDefaultSmsApp()) { + return true; + } + // TODO: figure out a good way to combine this with the implementation in + // ConversationFragment doing similar things + final Activity activity = this; + UiUtils.showSnackBarWithCustomAction(this, + getWindow().getDecorView().getRootView(), + getString(R.string.requires_default_sms_app), + SnackBar.Action.createCustomAction(() -> { + final Intent intent = + UIIntents.get().getChangeDefaultSmsAppIntent(activity); + mLauncher.launch(intent); + }, + getString(R.string.requires_default_sms_change_button)), + null /* interactions */, + null /* placement */); + return false; + } + + 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); + } + showDeleteCompletedToast(count, reportSpam); + exitMultiSelectState(); + } + + private void showDeleteCompletedToast(final int count, final boolean reportSpam) { + 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); + } + Toast.makeText(this, message, Toast.LENGTH_SHORT).show(); + } + + 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 */); + } + @Override public void onActionBarDelete(final Collection conversations) { - if (!PhoneUtils.getDefault().isDefaultSmsApp()) { - // TODO: figure out a good way to combine this with the implementation in - // ConversationFragment doing similar things - final Activity activity = this; - UiUtils.showSnackBarWithCustomAction(this, - getWindow().getDecorView().getRootView(), - getString(R.string.requires_default_sms_app), - SnackBar.Action.createCustomAction(() -> { - final Intent intent = - UIIntents.get().getChangeDefaultSmsAppIntent(activity); - mLauncher.launch(intent); - }, - getString(R.string.requires_default_sms_change_button)), - null /* interactions */, - null /* placement */); + if (!ensureDefaultSmsAppForDelete()) { return; } @@ -158,14 +266,7 @@ public abstract class AbstractConversationListActivity extends BugleActionBarAct R.plurals.delete_conversations_confirmation_dialog_title, conversations.size())) .setPositiveButton(R.string.delete_conversation_confirmation_button, - (dialog, button) -> { - for (final SelectedConversation conversation : conversations) { - DeleteConversationAction.deleteConversation( - conversation.conversationId, - conversation.timestamp); - } - exitMultiSelectState(); - }) + (dialog, button) -> performDeleteConversations(conversations)) .setNegativeButton(R.string.delete_conversation_decline_button, null) .show(); } @@ -256,7 +357,7 @@ public abstract class AbstractConversationListActivity extends BugleActionBarAct @Override public void onSelectionCountChanged(final int count) { if (useHomeSelectionChrome()) { - mConversationListFragment.updateHomeSelectionDeleteEnabled(count); + mConversationListFragment.updateHomeSelectionBar(count); hideHomeContextualActionBar(); } } 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 10d9ba6..c8d4a93 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 @@ -53,6 +53,7 @@ public class ConversationListAdapter private static final int VIEW_TYPE_PINNED_HEADER = 1; private static final int VIEW_TYPE_CONVERSATION = 2; private static final long PINNED_HEADER_ITEM_ID = -9001L; + static final Object PAYLOAD_SELECTION_CHROME = new Object(); private final ConversationListItemView.HostInterface mClivHostInterface; private final ConversationListItemData mFilterProbe = new ConversationListItemData(); @@ -112,6 +113,13 @@ public class ConversationListAdapter return mFilterMode; } + public void notifySelectionChromeChanged() { + final int count = getItemCount(); + if (count > 0) { + notifyItemRangeChanged(0, count, PAYLOAD_SELECTION_CHROME); + } + } + public void refreshPinOrdering() { refreshPinOrderingAnimated(); } @@ -323,6 +331,22 @@ public class ConversationListAdapter return createConversationViewHolder(parent); } + @Override + public void onBindViewHolder(@NonNull final RecyclerView.ViewHolder holder, + final int position, @NonNull final List payloads) { + if (!payloads.isEmpty() && payloads.contains(PAYLOAD_SELECTION_CHROME)) { + if (holder instanceof PinnedHeaderViewHolder) { + attachPinnedStripToHeader((PinnedHeaderViewHolder) holder); + return; + } + if (holder instanceof ConversationListViewHolder) { + ((ConversationListViewHolder) holder).mView.rebindSelectionChrome(); + return; + } + } + onBindViewHolder(holder, position); + } + @Override public void onBindViewHolder(@NonNull final RecyclerView.ViewHolder holder, final int position) { @@ -348,8 +372,8 @@ public class ConversationListAdapter container.setLayoutParams(new RecyclerView.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); - container.setClipChildren(true); - container.setClipToPadding(true); + container.setClipChildren(false); + container.setClipToPadding(false); return new PinnedHeaderViewHolder(container); } 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 1ee1c18..b8aa1db 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 @@ -20,17 +20,22 @@ import android.app.Activity; import android.app.AlertDialog; import android.content.Context; import android.database.Cursor; +import android.graphics.Point; import android.graphics.Rect; +import android.telephony.PhoneNumberUtils; import android.net.Uri; import android.os.Bundle; import android.os.Parcelable; +import android.text.TextUtils; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuInflater; import android.view.MenuItem; +import android.view.Gravity; import android.view.View; import android.view.ViewGroup; import android.view.ViewGroup.MarginLayoutParams; +import android.widget.FrameLayout; import android.view.ViewPropertyAnimator; import android.view.accessibility.AccessibilityManager; import android.widget.AbsListView; @@ -57,11 +62,16 @@ import com.android.messaging.datamodel.binding.BindingBase; import com.android.messaging.datamodel.data.ConversationListData; import com.android.messaging.datamodel.data.ConversationListData.ConversationListDataListener; import com.android.messaging.datamodel.data.ConversationListItemData; +import com.android.messaging.ui.ContactIconView; import com.android.messaging.ui.ListEmptyView; import com.android.messaging.ui.SnackBarInteraction; import com.android.messaging.ui.UIIntents; -import com.android.messaging.ui.home.HomeContactsFragment; +import com.android.messaging.ui.contact.AddContactsConfirmationDialog; +import com.android.messaging.util.AvatarUriUtil; +import com.android.messaging.util.ContactUtil; +import com.android.messaging.util.PhoneUtils; import com.android.messaging.ui.home.HomePinnedStrip; +import com.android.messaging.ui.home.HomeUiMotion; import com.android.messaging.ui.home.PinnedConversationStore; import com.android.messaging.util.AccessibilityUtil; import com.android.messaging.util.ImeUtil; @@ -105,19 +115,26 @@ public class ConversationListFragment extends Fragment implements ConversationLi private View mHomeBottomNav; private View mHomeSelectionBar; private ImageButton mHomeSelectionDelete; + private TextView mHomeReadAllButton; + private int mHomeSelectionCount; private View mHomeMessagesTop; private View mHomePinnedStrip; private TextView mHomeEditButton; private ImageButton mHomePinnedDoneButton; private boolean mHomePinnedEditMode; - private ViewGroup mHomeContactsHost; private View mHomeNavMessages; - private View mHomeNavContacts; private ImageView mHomeNavMessagesIcon; - private ImageView mHomeNavContactsIcon; private TextView mHomeNavMessagesLabel; - private TextView mHomeNavContactsLabel; private int mHomeTab = HOME_TAB_MESSAGES; + private View mHomeListProfilePopup; + private View mHomeListProfileScrim; + private View mHomeListProfileContent; + private ContactIconView mHomeListProfileAvatar; + private View mHomeListProfileActionCall; + private View mHomeListProfileActionAddContact; + private View mHomeListProfileActions; + private ConversationListItemData mHomeListProfileData; + private RecyclerView mRecyclerView; private View mHomeComposeContainer; private ImageButton mStartNewConversationButton; @@ -132,9 +149,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 HOME_CONTACTS_FRAGMENT_TAG = "home_contacts"; private static final int HOME_TAB_MESSAGES = 0; - private static final int HOME_TAB_CONTACTS = 1; private static final int SEARCH_NEWEST_EDGE_SLOP_DP = 48; private static final int SEARCH_BAR_ANIM_MS = 280; private static final int SEARCH_BAR_SLIDE_FALLBACK_DP = 72; @@ -147,8 +162,19 @@ public class ConversationListFragment extends Fragment implements ConversationLi private final ConversationListItemData mPinnedRowProbe = new ConversationListItemData(); private final HomePinnedStrip.Listener mPinnedStripListener = new HomePinnedStrip.Listener() { @Override - public void onPinnedConversationClicked(final String conversationId) { - openConversationById(conversationId); + public void onPinnedProfileClicked(final String conversationId, final View avatarAnchor) { + final ConversationListItemData data = loadConversationListItemData(conversationId); + if (data != null) { + showHomeListProfilePopup(data, avatarAnchor); + } + } + + @Override + public void onPinnedConversationOpen(final String conversationId) { + final ConversationListItemData data = loadConversationListItemData(conversationId); + if (data != null && mHost != null) { + mHost.onConversationClick(mListBinding.getData(), data, false, null); + } } @Override @@ -254,8 +280,8 @@ public class ConversationListFragment extends Fragment implements ConversationLi mRecyclerView.setLayoutManager(manager); mRecyclerView.setHasFixedSize(false); final DefaultItemAnimator itemAnimator = new DefaultItemAnimator(); - itemAnimator.setMoveDuration(220); - itemAnimator.setChangeDuration(180); + itemAnimator.setMoveDuration(240); + itemAnimator.setChangeDuration(260); mRecyclerView.setItemAnimator(itemAnimator); mHomePinnedStrip = inflater.inflate(R.layout.home_pinned_strip, rootView, false); mAdapter.setPinnedStripRoot(mHomePinnedStrip); @@ -303,6 +329,7 @@ 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); mHomeComposeContainer = rootView.findViewById(R.id.home_compose_container); mStartNewConversationButton = rootView.findViewById(R.id.home_nav_search); @@ -313,27 +340,19 @@ public class ConversationListFragment extends Fragment implements ConversationLi hideHomeChrome(rootView); } else { if (mStartNewConversationButton != null) { - mStartNewConversationButton.setOnClickListener(v -> { - if (mHost != null) { - mHost.onCreateConversationClick(); - } - }); + mStartNewConversationButton.setOnClickListener(v -> launchHomeSubScreen( + () -> UIIntents.get().launchHomeComposeActivity(requireActivity()), + R.anim.home_sheet_open)); } setupHomeChrome(rootView); mHomeMessagesTop = rootView.findViewById(R.id.home_messages_top); - mHomeContactsHost = rootView.findViewById(R.id.home_contacts_host); mHomeNavMessages = rootView.findViewById(R.id.home_nav_messages); - mHomeNavContacts = rootView.findViewById(R.id.home_nav_contacts); bindHomeNavTab(mHomeNavMessages, R.drawable.ic_home_nav_messages, R.string.home_nav_messages); - bindHomeNavTab(mHomeNavContacts, R.drawable.ic_home_nav_contacts, - R.string.home_nav_contacts); bindHomeNavTab(rootView.findViewById(R.id.home_nav_automate), R.drawable.ic_home_nav_automate, R.string.home_nav_automate); mHomeNavMessagesIcon = mHomeNavMessages.findViewById(R.id.home_nav_tab_icon); - mHomeNavContactsIcon = mHomeNavContacts.findViewById(R.id.home_nav_tab_icon); mHomeNavMessagesLabel = mHomeNavMessages.findViewById(R.id.home_nav_tab_label); - mHomeNavContactsLabel = mHomeNavContacts.findViewById(R.id.home_nav_tab_label); if (savedInstanceState != null) { mHomeTab = savedInstanceState.getInt(SAVED_INSTANCE_STATE_HOME_TAB, HOME_TAB_MESSAGES); @@ -370,7 +389,6 @@ 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_contacts_host); setGone(rootView, R.id.home_search_bar_container); setGone(rootView, R.id.home_bottom_nav); setGone(rootView, R.id.home_selection_bar); @@ -538,7 +556,6 @@ public class ConversationListFragment extends Fragment implements ConversationLi final View searchBarContainer = rootView.findViewById(R.id.home_search_bar_container); final View searchBar = rootView.findViewById(R.id.home_search_bar); final View navMessages = rootView.findViewById(R.id.home_nav_messages); - final View navContacts = rootView.findViewById(R.id.home_nav_contacts); final View navAutomate = rootView.findViewById(R.id.home_nav_automate); if (editButton instanceof TextView) { @@ -549,11 +566,10 @@ public class ConversationListFragment extends Fragment implements ConversationLi if (filterButton != null) { filterButton.setOnClickListener(this::showHomeFilterPopover); } - final View.OnClickListener openCompose = v -> - UIIntents.get().launchCreateNewConversationActivity(requireActivity(), null); mSearchBar = searchBarContainer != null ? searchBarContainer : searchBar; if (searchBar != null) { - searchBar.setOnClickListener(openCompose); + searchBar.setOnClickListener(v -> launchHomeSubScreen( + () -> UIIntents.get().launchHomeSearchActivity(requireActivity()))); } if (mSearchBar != null) { setupSearchBarScroll(mSearchBar); @@ -562,9 +578,6 @@ public class ConversationListFragment extends Fragment implements ConversationLi if (navMessages != null) { navMessages.setOnClickListener(v -> selectHomeTab(HOME_TAB_MESSAGES)); } - if (navContacts != null) { - navContacts.setOnClickListener(v -> selectHomeTab(HOME_TAB_CONTACTS)); - } if (navAutomate != null) { navAutomate.setOnClickListener(v -> Toast.makeText(requireContext(), R.string.home_automate_coming_soon, @@ -579,9 +592,8 @@ public class ConversationListFragment extends Fragment implements ConversationLi } }); } - final View readAll = rootView.findViewById(R.id.home_read_all_button); - if (readAll != null) { - readAll.setOnClickListener(v -> markAllVisibleConversationsRead()); + if (mHomeReadAllButton != null) { + mHomeReadAllButton.setOnClickListener(v -> onHomeReadAllOrUnreadClick()); } if (mHomeSelectionDelete != null) { mHomeSelectionDelete.setOnClickListener(v -> { @@ -591,36 +603,296 @@ public class ConversationListFragment extends Fragment implements ConversationLi } }); } + setupHomeListProfilePopup(rootView); + } + + private void setupHomeListProfilePopup(final View rootView) { + mHomeListProfilePopup = rootView.findViewById(R.id.home_list_profile_popup); + if (mHomeListProfilePopup == null) { + return; + } + mHomeListProfileScrim = mHomeListProfilePopup.findViewById(R.id.home_list_profile_scrim); + mHomeListProfileContent = mHomeListProfilePopup.findViewById(R.id.home_list_profile_content); + mHomeListProfileAvatar = mHomeListProfilePopup.findViewById(R.id.home_list_profile_avatar); + mHomeListProfileActionCall = + mHomeListProfilePopup.findViewById(R.id.home_list_profile_action_call); + mHomeListProfileActionAddContact = + mHomeListProfilePopup.findViewById(R.id.home_list_profile_action_add_contact); + mHomeListProfileActions = + mHomeListProfilePopup.findViewById(R.id.home_list_profile_actions); + + final View dismissTarget = mHomeListProfileScrim != null + ? mHomeListProfileScrim : mHomeListProfilePopup; + dismissTarget.setOnClickListener(v -> dismissHomeListProfilePopup(true)); + + final View message = mHomeListProfilePopup.findViewById(R.id.home_list_profile_action_message); + if (message != null) { + message.setOnClickListener(v -> { + final ConversationListItemData data = mHomeListProfileData; + dismissHomeListProfilePopup(false); + if (data != null && mHost != null) { + mHost.onConversationClick(mListBinding.getData(), data, false, null); + } + }); + } + if (mHomeListProfileActionCall != null) { + mHomeListProfileActionCall.setOnClickListener(v -> placeListProfileCall()); + } + if (mHomeListProfileActionAddContact != null) { + mHomeListProfileActionAddContact.setOnClickListener(v -> addListProfileContact()); + } + final View info = mHomeListProfilePopup.findViewById(R.id.home_list_profile_action_info); + if (info != null) { + info.setOnClickListener(v -> openListProfileDetails()); + } + } + + private void showHomeListProfilePopup(final ConversationListItemData data, + final View anchorView) { + if (mHomeListProfilePopup == null || data == null || mArchiveMode || mForwardMessageMode + || isSelectionMode()) { + return; + } + dismissHomePopoverImmediate(); + final String previousProfileId = mHomeListProfileData != null + ? mHomeListProfileData.getConversationId() : null; + final boolean sameProfile = data.getConversationId() != null + && data.getConversationId().equals(previousProfileId) + && mHomeListProfilePopup.getVisibility() == View.VISIBLE; + if (sameProfile) { + return; + } + mHomeListProfileData = data; + Uri iconUri = null; + if (data.getIcon() != null) { + iconUri = Uri.parse(data.getIcon()); + } + if (mHomeListProfileAvatar != null) { + mHomeListProfileAvatar.setImageClickHandlerDisabled(true); + mHomeListProfileAvatar.setImageResourceUri(iconUri, data.getParticipantContactId(), + data.getParticipantLookupKey(), data.getOtherParticipantNormalizedDestination()); + } + final boolean isGroup = data.getIsGroup(); + final boolean canCall = !isGroup && PhoneUtils.getDefault().isVoiceCapable() + && !TextUtils.isEmpty(data.getOtherParticipantNormalizedDestination()); + if (mHomeListProfileActionCall != null) { + mHomeListProfileActionCall.setVisibility(canCall ? View.VISIBLE : View.GONE); + } + final boolean canAddContact = !isGroup + && !TextUtils.isEmpty(data.getOtherParticipantNormalizedDestination()) + && !ContactUtil.isParticipantInAddressBook(requireContext(), + data.getParticipantContactId(), data.getParticipantLookupKey(), + data.getOtherParticipantNormalizedDestination()); + if (mHomeListProfileActionAddContact != null) { + mHomeListProfileActionAddContact.setVisibility( + canAddContact ? View.VISIBLE : View.GONE); + } + updateListProfileActionsPillWidth(); + if (mHomeListProfileScrim != null) { + mHomeListProfileScrim.setAlpha(0f); + } + mHomeListProfileContent.setAlpha(0f); + mHomeListProfilePopup.setVisibility(View.INVISIBLE); + positionListProfilePopup(anchorView, () -> { + mHomeListProfilePopup.setVisibility(View.VISIBLE); + HomeUiMotion.listProfilePopupEnter(mHomeListProfileScrim, mHomeListProfileContent, + anchorView); + }); + } + + private void updateListProfileActionsPillWidth() { + if (mHomeListProfileActions == null) { + return; + } + int visibleActions = 0; + final View message = mHomeListProfilePopup.findViewById( + R.id.home_list_profile_action_message); + final View info = mHomeListProfilePopup.findViewById(R.id.home_list_profile_action_info); + for (final View action : new View[] { message, mHomeListProfileActionCall, + mHomeListProfileActionAddContact, info }) { + if (action != null && action.getVisibility() == View.VISIBLE) { + visibleActions++; + } + } + final android.view.ViewGroup.LayoutParams layoutParams = + mHomeListProfileActions.getLayoutParams(); + final int buttonSize = getResources().getDimensionPixelSize( + R.dimen.home_list_profile_action_button_size); + final int horizontalPadding = getResources().getDimensionPixelSize( + R.dimen.home_list_profile_actions_padding_h); + layoutParams.width = visibleActions * buttonSize + horizontalPadding * 2; + mHomeListProfileActions.setLayoutParams(layoutParams); + } + + private void positionListProfilePopup(final View anchorView, final Runnable onPositioned) { + if (mHomeListProfileContent == null || mHomeListProfilePopup == null) { + if (onPositioned != null) { + onPositioned.run(); + } + return; + } + resetListProfilePopupPosition(); + mHomeListProfileContent.post(() -> { + if (anchorView != null) { + final int contentWidth = measureListProfilePopupContentWidth(); + final int contentHeight = measureListProfilePopupContentHeight(); + final int[] anchorInWindow = new int[2]; + final int[] popupInWindow = new int[2]; + anchorView.getLocationInWindow(anchorInWindow); + mHomeListProfilePopup.getLocationInWindow(popupInWindow); + final int screenPadding = getResources().getDimensionPixelSize( + R.dimen.looply_screen_padding); + final int gap = getResources().getDimensionPixelSize( + R.dimen.home_list_profile_popup_gap_below_avatar); + + final float anchorCenterX = anchorInWindow[0] + anchorView.getWidth() * 0.5f; + float left = anchorCenterX - popupInWindow[0] - contentWidth * 0.5f; + final float maxLeft = mHomeListProfilePopup.getWidth() - contentWidth + - screenPadding; + left = Math.max(screenPadding, Math.min(left, maxLeft)); + + float top = anchorInWindow[1] - popupInWindow[1] + anchorView.getHeight() + gap; + final int maxTop = mHomeListProfilePopup.getHeight() - contentHeight + - screenPadding; + if (top > maxTop) { + top = anchorInWindow[1] - popupInWindow[1] - contentHeight - gap; + } + top = Math.max(screenPadding, Math.min(top, maxTop)); + + final FrameLayout.LayoutParams layoutParams = ensureListProfileContentLayoutParams(); + layoutParams.gravity = Gravity.TOP | Gravity.START; + layoutParams.leftMargin = Math.round(left); + layoutParams.topMargin = Math.round(top); + mHomeListProfileContent.setLayoutParams(layoutParams); + } + mHomeListProfileContent.setTranslationX(0f); + mHomeListProfileContent.setTranslationY(0f); + if (onPositioned != null) { + mHomeListProfileContent.post(onPositioned); + } + }); + } + + private FrameLayout.LayoutParams ensureListProfileContentLayoutParams() { + ViewGroup.LayoutParams params = mHomeListProfileContent.getLayoutParams(); + if (!(params instanceof FrameLayout.LayoutParams)) { + params = new FrameLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT); + mHomeListProfileContent.setLayoutParams(params); + } + return (FrameLayout.LayoutParams) params; + } + + private int measureListProfilePopupContentWidth() { + if (mHomeListProfileContent.getWidth() > 0) { + return mHomeListProfileContent.getWidth(); + } + final int widthSpec = View.MeasureSpec.makeMeasureSpec( + mHomeListProfilePopup.getWidth(), View.MeasureSpec.AT_MOST); + final int heightSpec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED); + mHomeListProfileContent.measure(widthSpec, heightSpec); + return mHomeListProfileContent.getMeasuredWidth(); + } + + private int measureListProfilePopupContentHeight() { + if (mHomeListProfileContent.getHeight() > 0) { + return mHomeListProfileContent.getHeight(); + } + final int widthSpec = View.MeasureSpec.makeMeasureSpec( + mHomeListProfilePopup.getWidth(), View.MeasureSpec.AT_MOST); + final int heightSpec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED); + mHomeListProfileContent.measure(widthSpec, heightSpec); + return mHomeListProfileContent.getMeasuredHeight(); + } + + private void resetListProfilePopupPosition() { + if (mHomeListProfileContent == null) { + return; + } + mHomeListProfileContent.setX(0f); + mHomeListProfileContent.setY(0f); + mHomeListProfileContent.setTranslationX(0f); + mHomeListProfileContent.setTranslationY(0f); + final FrameLayout.LayoutParams layoutParams = ensureListProfileContentLayoutParams(); + layoutParams.gravity = Gravity.TOP | Gravity.START; + layoutParams.leftMargin = 0; + layoutParams.topMargin = 0; + mHomeListProfileContent.setLayoutParams(layoutParams); + } + + private void dismissHomeListProfilePopup(final boolean animate) { + if (mHomeListProfilePopup == null + || mHomeListProfilePopup.getVisibility() != View.VISIBLE) { + mHomeListProfileData = null; + return; + } + final Runnable finish = () -> { + mHomeListProfilePopup.setVisibility(View.GONE); + resetListProfilePopupPosition(); + mHomeListProfileData = null; + }; + if (animate) { + HomeUiMotion.listProfilePopupExit(mHomeListProfileScrim, mHomeListProfileContent, + finish); + } else { + finish.run(); + } + } + + private void placeListProfileCall() { + if (mHomeListProfileData == null || getActivity() == null) { + return; + } + final String phoneNumber = mHomeListProfileData.getOtherParticipantNormalizedDestination(); + if (TextUtils.isEmpty(phoneNumber)) { + return; + } + if (PhoneNumberUtils.isEmergencyNumber(phoneNumber)) { + UiUtils.showToast(R.string.disallow_emergency_call); + return; + } + dismissHomeListProfilePopup(false); + final Point center = new Point( + getResources().getDisplayMetrics().widthPixels / 2, + getResources().getDisplayMetrics().heightPixels / 2); + UIIntents.get().launchPhoneCallActivity(getActivity(), phoneNumber, center); + } + + private void openListProfileDetails() { + final ConversationListItemData data = mHomeListProfileData; + dismissHomeListProfilePopup(false); + if (data == null || getActivity() == null) { + return; + } + if (!data.getIsGroup() && ContactUtil.openContactDetail(requireContext(), + data.getParticipantContactId(), data.getParticipantLookupKey(), + data.getOtherParticipantNormalizedDestination())) { + return; + } + UIIntents.get().launchHomeProfileActivity(getActivity(), data.getConversationId()); + } + + private void addListProfileContact() { + if (mHomeListProfileData == null || getActivity() == null) { + return; + } + final String destination = mHomeListProfileData.getOtherParticipantNormalizedDestination(); + if (TextUtils.isEmpty(destination)) { + return; + } + final Uri avatarUri = AvatarUriUtil.createAvatarUri(null, mHomeListProfileData.getName(), + destination, mHomeListProfileData.getParticipantLookupKey()); + dismissHomeListProfilePopup(false); + new AddContactsConfirmationDialog(getActivity(), avatarUri, destination).show(); } public void setHomeSelectionMode(final boolean enabled) { if (mArchiveMode || mForwardMessageMode || mRootView == null) { return; } - if (mHomeHeader != null) { - mHomeHeader.setVisibility(enabled ? View.GONE : View.VISIBLE); - } - if (mHomeSelectionHeader != null) { - mHomeSelectionHeader.setVisibility(enabled ? View.VISIBLE : View.GONE); - } - if (enabled && mHomeBottomNav != null) { - mHomeBottomNav.setVisibility(View.GONE); - } - if (mHomeSelectionBar != null) { - mHomeSelectionBar.setVisibility(enabled ? View.VISIBLE : View.GONE); - } - if (enabled) { - setPinnedEditMode(false); - updateHomeComposeButtonVisibility(); - refreshPinnedStrip(); - if (mHomeMessagesTop != null) { - mHomeMessagesTop.bringToFront(); - } - } else { - updateHomeMainNavVisibility(); - refreshPinnedStrip(); - } if (enabled) { + setPinnedEditMode(false, false); mSearchBarShown = false; cancelSearchBarAnimation(); if (mSearchBar != null) { @@ -630,8 +902,27 @@ public class ConversationListFragment extends Fragment implements ConversationLi } updateListPaddingForSearchBar(); } - updateHomeSelectionDeleteEnabled(0); - updateUi(); + updateHomeSelectionBar(0); + updateHomeComposeButtonVisibility(); + // Both headers live in the same vertical container, so they must never be visible at + // once or the list below them jumps by a full header height. + HomeUiMotion.replaceHeaderInstant( + enabled ? mHomeHeader : mHomeSelectionHeader, + enabled ? mHomeSelectionHeader : mHomeHeader); + refreshListSelectionChrome(); + HomeUiMotion.swapBottomDock(mHomeBottomNav, mHomeSelectionBar, enabled, () -> { + if (!enabled) { + updateHomeMainNavVisibility(false); + } + }); + } + + /** Rebinds only the per-row selection affordances, leaving avatars and text untouched. */ + private void refreshListSelectionChrome() { + if (mRecyclerView == null || mAdapter == null) { + return; + } + mAdapter.notifySelectionChromeChanged(); } private void selectHomeTab(final int tab) { @@ -646,12 +937,6 @@ public class ConversationListFragment extends Fragment implements ConversationLi if (mHomeMessagesTop != null) { mHomeMessagesTop.setVisibility(showMessages ? View.VISIBLE : View.GONE); } - if (mHomeContactsHost != null) { - mHomeContactsHost.setVisibility(showMessages ? View.GONE : View.VISIBLE); - if (!showMessages) { - ensureHomeContactsFragment(); - } - } if (!showMessages) { mSearchBarShown = false; cancelSearchBarAnimation(); @@ -676,10 +961,19 @@ public class ConversationListFragment extends Fragment implements ConversationLi /** Main tabs show nav; selection mode and other activities hide it ({@link #onPause}). */ private void updateHomeMainNavVisibility() { + updateHomeMainNavVisibility(isSelectionMode()); + } + + /** + * @param homeSelectionChromeActive home selection UI (dock + selection bar), not legacy + * action mode alone + */ + private void updateHomeMainNavVisibility(final boolean homeSelectionChromeActive) { if (mHomeBottomNav == null || mArchiveMode || mForwardMessageMode) { return; } - if (isSelectionMode()) { + final boolean hideForSelection = homeSelectionChromeActive || isSelectionMode(); + if (hideForSelection) { mHomeBottomNav.setVisibility(View.GONE); } else { mHomeBottomNav.setVisibility(isResumed() ? View.VISIBLE : View.GONE); @@ -705,6 +999,10 @@ public class ConversationListFragment extends Fragment implements ConversationLi } private void launchHomeSubScreen(final Runnable action) { + launchHomeSubScreen(action, R.anim.home_activity_open); + } + + private void launchHomeSubScreen(final Runnable action, final int enterAnim) { if (mHomeBottomNav != null) { mHomeBottomNav.setVisibility(View.GONE); } @@ -712,25 +1010,15 @@ public class ConversationListFragment extends Fragment implements ConversationLi mHomeComposeContainer.setVisibility(View.GONE); } action.run(); - } - - private void ensureHomeContactsFragment() { - if (mHomeContactsHost == null - || getChildFragmentManager().findFragmentById(R.id.home_contacts_host) != null) { - return; + final Activity activity = getActivity(); + if (activity != null) { + activity.overridePendingTransition(enterAnim, R.anim.home_activity_hold); } - getChildFragmentManager().beginTransaction() - .replace(R.id.home_contacts_host, new HomeContactsFragment(), - HOME_CONTACTS_FRAGMENT_TAG) - .commit(); } private void updateHomeNavHighlight() { - final boolean messagesActive = mHomeTab == HOME_TAB_MESSAGES; applyHomeNavTabStyle(mHomeNavMessages, mHomeNavMessagesIcon, mHomeNavMessagesLabel, - messagesActive); - applyHomeNavTabStyle(mHomeNavContacts, mHomeNavContactsIcon, mHomeNavContactsLabel, - !messagesActive); + mHomeTab == HOME_TAB_MESSAGES); } private void applyHomeNavTabStyle(final View tab, final ImageView icon, final TextView label, @@ -755,7 +1043,13 @@ public class ConversationListFragment extends Fragment implements ConversationLi } } - public void updateHomeSelectionDeleteEnabled(final int selectedCount) { + public void updateHomeSelectionBar(final int selectedCount) { + mHomeSelectionCount = selectedCount; + if (mHomeReadAllButton != null) { + mHomeReadAllButton.setText(selectedCount > 0 + ? R.string.home_mark_unread + : R.string.home_read_all); + } if (mHomeSelectionDelete == null) { return; } @@ -763,6 +1057,19 @@ public class ConversationListFragment extends Fragment implements ConversationLi mHomeSelectionDelete.setEnabled(enabled); mHomeSelectionDelete.setAlpha(1f); mHomeSelectionDelete.setImageAlpha(enabled ? 255 : 102); + final int tintColor = ContextCompat.getColor(requireContext(), + enabled ? R.color.home_destructive : R.color.home_nav_inactive); + mHomeSelectionDelete.setImageTintList( + android.content.res.ColorStateList.valueOf(tintColor)); + } + + private void onHomeReadAllOrUnreadClick() { + if (mHomeSelectionCount > 0 + && getActivity() instanceof AbstractConversationListActivity) { + ((AbstractConversationListActivity) getActivity()).markSelectedConversationsUnread(); + return; + } + markAllVisibleConversationsRead(); } private void markAllVisibleConversationsRead() { @@ -772,10 +1079,44 @@ public class ConversationListFragment extends Fragment implements ConversationLi private PopupWindow mHomePopover; private void dismissHomePopover() { - if (mHomePopover != null && mHomePopover.isShowing()) { - mHomePopover.dismiss(); + dismissHomePopoverAndThen(null); + } + + /** No exit animation — use when opening selection mode to avoid stacked motion. */ + private void dismissHomePopoverImmediate() { + if (mHomePopover == null || !mHomePopover.isShowing()) { + mHomePopover = null; + return; } + final View content = mHomePopover.getContentView(); + if (content != null) { + content.animate().cancel(); + HomeUiMotion.popoverResetInstant(content); + } + final PopupWindow popup = mHomePopover; mHomePopover = null; + popup.dismiss(); + } + + private void dismissHomePopoverAndThen(final Runnable afterDismiss) { + if (mHomePopover == null || !mHomePopover.isShowing()) { + mHomePopover = null; + if (afterDismiss != null) { + afterDismiss.run(); + } + return; + } + final PopupWindow popup = mHomePopover; + final View content = popup.getContentView(); + mHomePopover = null; + HomeUiMotion.popoverExit(content, () -> { + if (popup.isShowing()) { + popup.dismiss(); + } + if (afterDismiss != null) { + afterDismiss.run(); + } + }); } private PopupWindow showHomePopover(final View anchor, final int layoutResId, @@ -804,6 +1145,7 @@ public class ConversationListFragment extends Fragment implements ConversationLi ? Math.max(0, anchor.getWidth() - content.getMeasuredWidth()) : 0; popup.showAsDropDown(anchor, xOff, 8); + HomeUiMotion.popoverEnter(content, alignEnd); mHomePopover = popup; return popup; } @@ -817,8 +1159,7 @@ public class ConversationListFragment extends Fragment implements ConversationLi }); content.findViewById(R.id.popover_spam).setOnClickListener(v -> { dismissHomePopover(); - launchHomeSubScreen(() -> UIIntents.get().launchBlockedParticipantsActivity( - requireActivity())); + launchHomeSubScreen(() -> UIIntents.get().launchHomeSpamActivity(requireActivity())); }); content.findViewById(R.id.popover_recently_deleted).setOnClickListener(v -> { dismissHomePopover(); @@ -852,14 +1193,13 @@ public class ConversationListFragment extends Fragment implements ConversationLi final PopupWindow popup = showHomePopover(anchor, R.layout.home_popover_edit, false); final View content = popup.getContentView(); content.findViewById(R.id.popover_select_messages).setOnClickListener(v -> { - dismissHomePopover(); + dismissHomePopoverImmediate(); if (getActivity() instanceof ConversationListActivity) { ((ConversationListActivity) getActivity()).enterHomeEditMode(); } }); content.findViewById(R.id.popover_edit_pinned).setOnClickListener(v -> { - dismissHomePopover(); - setPinnedEditMode(true); + dismissHomePopoverAndThen(() -> setPinnedEditMode(true)); }); content.findViewById(R.id.popover_name_photo).setOnClickListener(v -> { dismissHomePopover(); @@ -981,6 +1321,12 @@ public class ConversationListFragment extends Fragment implements ConversationLi conversationView); } + @Override + public void onConversationAvatarClicked(final ConversationListItemData data, + final View avatarView) { + showHomeListProfilePopup(data, avatarView); + } + /** * {@inheritDoc} from ConversationListItemView.HostInterface */ @@ -1016,9 +1362,6 @@ public class ConversationListFragment extends Fragment implements ConversationLi mAdapter.notifyItemChanged(adapterPosition); } refreshPinnedStrip(); - if (!wasPinned && mRecyclerView != null) { - mRecyclerView.smoothScrollToPosition(mAdapter.getFirstConversationAdapterPosition()); - } } @Override @@ -1063,14 +1406,27 @@ public class ConversationListFragment extends Fragment implements ConversationLi } public void dismissFab() { - // To prevent clicking while animating. + dismissFab(false); + } + + /** @param instant skip slide animation (home selection chrome). */ + public void dismissFab(final boolean instant) { + if (mStartNewConversationButton == null) { + return; + } mStartNewConversationButton.setEnabled(false); + mStartNewConversationButton.animate().cancel(); final MarginLayoutParams lp = (MarginLayoutParams) mStartNewConversationButton.getLayoutParams(); final float fabWidthWithLeftRightMargin = mStartNewConversationButton.getWidth() + lp.leftMargin + lp.rightMargin; final int direction = AccessibilityUtil.isLayoutRtl(mStartNewConversationButton) ? -1 : 1; - getNormalizedFabAnimator().translationX(direction * fabWidthWithLeftRightMargin); + final float offScreen = direction * fabWidthWithLeftRightMargin; + if (instant) { + mStartNewConversationButton.setTranslationX(offScreen); + return; + } + getNormalizedFabAnimator().translationX(offScreen); } public void showFab() { @@ -1101,6 +1457,10 @@ public class ConversationListFragment extends Fragment implements ConversationLi } private void setPinnedEditMode(final boolean enabled) { + setPinnedEditMode(enabled, true); + } + + private void setPinnedEditMode(final boolean enabled, final boolean refreshList) { if (mArchiveMode || mForwardMessageMode) { return; } @@ -1122,8 +1482,10 @@ public class ConversationListFragment extends Fragment implements ConversationLi mHomePinnedEditMode = enabled; updateHomeEditButtonForPinnedMode(); updateSearchBarForPinnedEditMode(); - refreshPinnedStrip(); - updateUi(); + if (refreshList) { + refreshPinnedStrip(); + updateUi(); + } } private void updateSearchBarForPinnedEditMode() { @@ -1171,8 +1533,8 @@ public class ConversationListFragment extends Fragment implements ConversationLi final Cursor cursor = mAdapter.getCursor(); final boolean editMode = mHomePinnedEditMode && !isSelectionMode(); final List pinnedIds = PinnedConversationStore.getPinnedIds(requireContext()); - HomePinnedStrip.bind(mHomePinnedStrip, cursor, pinnedIds, - editMode, isSelectionMode(), mPinnedStripListener); + HomePinnedStrip.bind(mHomePinnedStrip, cursor, pinnedIds, editMode, + mPinnedStripListener); final boolean showHeader = pinnedIds != null && !pinnedIds.isEmpty() && cursor != null && mHomePinnedStrip.getVisibility() == View.VISIBLE; @@ -1213,27 +1575,18 @@ public class ConversationListFragment extends Fragment implements ConversationLi .show(); } - private void openConversationById(final String conversationId) { - if (mHost == null) { - return; + private ConversationListItemData loadConversationListItemData(final String conversationId) { + if (mAdapter == null || TextUtils.isEmpty(conversationId)) { + return null; } - final Cursor cursor = mAdapter.getCursor(); + final android.database.Cursor cursor = mAdapter.getCursor(); if (cursor == null) { - return; + return null; } - final int saved = cursor.getPosition(); - for (int i = 0; i < cursor.getCount(); i++) { - if (!cursor.moveToPosition(i)) { - continue; - } - mPinnedRowProbe.bind(cursor); - if (conversationId.equals(mPinnedRowProbe.getConversationId())) { - mHost.onConversationClick(mListBinding.getData(), mPinnedRowProbe, false, null); - break; - } - } - if (saved >= 0 && saved < cursor.getCount()) { - cursor.moveToPosition(saved); + final ConversationListItemData data = new ConversationListItemData(); + if (!HomePinnedStrip.bindConversationListItem(cursor, conversationId, data)) { + return null; } + return data; } } 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 a7c9377..7324208 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 @@ -87,6 +87,8 @@ public class ConversationListItemView extends FrameLayout implements OnClickList boolean isConversationSelected(final String conversationId); void onConversationClicked(final ConversationListItemData conversationListItemData, boolean isLongClick, final ConversationListItemView conversationView); + void onConversationAvatarClicked(final ConversationListItemData conversationListItemData, + final View avatarView); boolean isSwipeAnimatable(); List getSnackBarInteractions(); void startFullScreenPhotoViewer(final Uri initialPhoto, final Rect initialPhotoBounds, @@ -218,22 +220,14 @@ public class ConversationListItemView extends FrameLayout implements OnClickList private void setWorkProfileIcon() { mWorkProfileIconView.setVisibility(mData.isEnterprise() ? View.VISIBLE : View.GONE); if (mCategoryChipView != null) { - final boolean selectionMode = - mHostInterface != null && mHostInterface.isSelectionMode(); - if (selectionMode) { - mCategoryChipView.setVisibility(View.GONE); - } else { - mCategoryChipView.setVisibility(View.VISIBLE); - mCategoryChipView.setText(mData.isEnterprise() - ? R.string.home_chip_work - : R.string.home_chip_personal); - } + mCategoryChipView.setVisibility(View.VISIBLE); + mCategoryChipView.setText(mData.isEnterprise() + ? R.string.home_chip_work + : R.string.home_chip_personal); } } private void setConversationName() { - final boolean selectionMode = - mHostInterface != null && mHostInterface.isSelectionMode(); final boolean showUnreadDot = !mData.getIsRead() && !mData.getShowDraft(); if (mUnreadDotSlotView != null) { mUnreadDotSlotView.setVisibility(View.VISIBLE); @@ -241,10 +235,7 @@ public class ConversationListItemView extends FrameLayout implements OnClickList if (mUnreadDotView != null) { mUnreadDotView.setVisibility(showUnreadDot ? View.VISIBLE : View.INVISIBLE); } - if (selectionMode) { - mConversationNameView.setTextColor(mListItemReadColor); - mConversationNameView.setTypeface(mListItemUnreadTypeface); - } else if (mData.getIsRead() || mData.getShowDraft()) { + if (mData.getIsRead() || mData.getShowDraft()) { mConversationNameView.setTextColor(mListItemReadColor); mConversationNameView.setTypeface(mListItemReadTypeface); } else { @@ -561,17 +552,14 @@ public class ConversationListItemView extends FrameLayout implements OnClickList final String snippetText = getSnippetText(); final boolean selectionMode = mHostInterface.isSelectionMode(); - if (selectionMode) { - maxLines = 0; - typeface = mListItemReadTypeface; - } else if (mData.getIsRead() || mData.getShowDraft()) { + if (mData.getIsRead() || mData.getShowDraft()) { maxLines = TextUtils.isEmpty(snippetText) ? 0 : NO_UNREAD_SNIPPET_LINE_COUNT; typeface = mListItemReadTypeface; } else { maxLines = TextUtils.isEmpty(snippetText) ? 0 : UNREAD_SNIPPET_LINE_COUNT; typeface = mListItemUnreadTypeface; } - color = getResources().getColor(R.color.home_text_secondary, getContext().getTheme()); + color = getResources().getColor(R.color.home_text_snippet, getContext().getTheme()); mSnippetTextView.setMaxLines(maxLines); mSnippetTextView.setTextColor(color); @@ -579,14 +567,9 @@ public class ConversationListItemView extends FrameLayout implements OnClickList mSubjectTextView.setTextColor(color); mSubjectTextView.setTypeface(typeface, typefaceStyle); - if (selectionMode) { - mSnippetTextView.setVisibility(GONE); - mSubjectTextView.setVisibility(GONE); - } else { - mSnippetTextView.setVisibility(VISIBLE); - setSnippet(); - setSubject(); - } + mSnippetTextView.setVisibility(VISIBLE); + setSnippet(); + setSubject(); setConversationName(); setWorkProfileIcon(); setContentDescription(buildContentDescription(resources, mData, @@ -616,8 +599,10 @@ public class ConversationListItemView extends FrameLayout implements OnClickList mTimestampTextView.setText(resources.getString( R.string.conversation_list_item_view_draft_message)); } else { - mTimestampTextView.setTextColor(resources.getColor(R.color.home_text_secondary, - getContext().getTheme())); + final int timestampColor = (mData.getIsRead() || mData.getShowDraft()) + ? resources.getColor(R.color.home_text_secondary, getContext().getTheme()) + : color; + mTimestampTextView.setTextColor(timestampColor); mTimestampTextView.setTypeface(mListItemReadTypeface, typefaceStyle); final String formattedTimestamp = mData.getFormattedTimestamp(); if (mData.getIsSendRequested()) { @@ -647,9 +632,17 @@ public class ConversationListItemView extends FrameLayout implements OnClickList mContactIconView.setImageResourceUri(iconUri, mData.getParticipantContactId(), mData.getParticipantLookupKey(), mData.getOtherParticipantNormalizedDestination()); mContactIconView.setVisibility(contactIconVisibility); + mContactIconView.setImageClickHandlerDisabled(true); mContactIconView.setOnLongClickListener(this); - mContactIconView.setClickable(!mHostInterface.isSelectionMode()); - mContactIconView.setLongClickable(!mHostInterface.isSelectionMode()); + final boolean avatarInteractive = !mHostInterface.isSelectionMode(); + mContactIconView.setClickable(avatarInteractive); + mContactIconView.setLongClickable(avatarInteractive); + if (avatarInteractive) { + mContactIconView.setOnClickListener(v -> mHostInterface.onConversationAvatarClicked( + mData, mContactIconView)); + } else { + mContactIconView.setOnClickListener(null); + } mContactCheckmarkView.setVisibility(checkmarkVisiblity); mFailedStatusIconView.setVisibility(failStatusVisiblity); @@ -707,6 +700,29 @@ public class ConversationListItemView extends FrameLayout implements OnClickList } } + /** Lightweight refresh when toggling home selection chrome. */ + public void rebindSelectionChrome() { + if (mHostInterface == null || mData == null) { + return; + } + final Resources resources = getContext().getResources(); + final boolean selectionMode = mHostInterface.isSelectionMode(); + final boolean isSelected = mHostInterface.isConversationSelected( + mData.getConversationId()); + setSelected(isSelected); + updateSelectionIndicator(selectionMode, isSelected, resources); + mContactIconView.setClickable(!selectionMode); + mContactIconView.setLongClickable(!selectionMode); + if (!selectionMode) { + mContactIconView.setOnClickListener(v -> mHostInterface.onConversationAvatarClicked( + mData, mContactIconView)); + } else { + mContactIconView.setOnClickListener(null); + } + updatePinButton(selectionMode, resources); + updateListDividerInset(selectionMode); + } + public boolean isSwipeAnimatable() { return mHostInterface.isSwipeAnimatable(); } @@ -813,7 +829,10 @@ public class ConversationListItemView extends FrameLayout implements OnClickList } private boolean processClick(final View v, final boolean isLongClick) { - Assert.isTrue(v == mSwipeableContainer || v == mContactIconView || v == mImagePreviewView); + if (v == mContactIconView) { + return false; + } + Assert.isTrue(v == mSwipeableContainer || v == mImagePreviewView); Assert.notNull(mData.getName()); if (mHostInterface != null) { diff --git a/app/src/main/java/com/android/messaging/ui/conversationlist/ConversationListSwipeHelper.java b/app/src/main/java/com/android/messaging/ui/conversationlist/ConversationListSwipeHelper.java index 54889a4..94f5fb8 100644 --- a/app/src/main/java/com/android/messaging/ui/conversationlist/ConversationListSwipeHelper.java +++ b/app/src/main/java/com/android/messaging/ui/conversationlist/ConversationListSwipeHelper.java @@ -104,12 +104,15 @@ public class ConversationListSwipeHelper implements OnItemTouchListener { mInitialY = event.getY(); final View viewAtPoint = mRecyclerView.findChildViewUnder(mInitialX, mInitialY); - final ConversationListItemView child = (ConversationListItemView) viewAtPoint; - if (viewAtPoint instanceof ConversationListItemView && - child != null && child.isSwipeAnimatable()) { - // Begin detecting swipe on the target for the rest of the gesture. - mListItemView = child; - if (mListItemView.isAnimating()) { + if (viewAtPoint instanceof ConversationListItemView) { + final ConversationListItemView child = (ConversationListItemView) viewAtPoint; + if (child.isSwipeAnimatable()) { + // Begin detecting swipe on the target for the rest of the gesture. + mListItemView = child; + if (mListItemView.isAnimating()) { + mListItemView = null; + } + } else { mListItemView = null; } } else { diff --git a/app/src/main/java/com/android/messaging/ui/conversationsettings/PeopleAndOptionsActivity.java b/app/src/main/java/com/android/messaging/ui/conversationsettings/PeopleAndOptionsActivity.java deleted file mode 100644 index d35b31c..0000000 --- a/app/src/main/java/com/android/messaging/ui/conversationsettings/PeopleAndOptionsActivity.java +++ /dev/null @@ -1,66 +0,0 @@ -/* - * Copyright (C) 2015 The Android Open Source Project - * Copyright (C) 2024 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.conversationsettings; - -import android.os.Bundle; -import android.view.MenuItem; - -import androidx.annotation.NonNull; -import androidx.fragment.app.Fragment; - -import com.android.messaging.R; -import com.android.messaging.ui.BugleActionBarActivity; -import com.android.messaging.ui.UIIntents; -import com.android.messaging.util.Assert; - -/** - * Shows a list of participants in a conversation. - */ -public class PeopleAndOptionsActivity extends BugleActionBarActivity { - - @Override - protected void onCreate(final Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setContentView(R.layout.people_and_options_activity); - - getSupportActionBar().setDisplayHomeAsUpEnabled(true); - } - - @Override - public void onAttachFragment(@NonNull final Fragment fragment) { - if (fragment instanceof PeopleAndOptionsFragment) { - final String conversationId = - getIntent().getStringExtra(UIIntents.UI_INTENT_EXTRA_CONVERSATION_ID); - Assert.notNull(conversationId); - final PeopleAndOptionsFragment peopleAndOptionsFragment = - (PeopleAndOptionsFragment) fragment; - peopleAndOptionsFragment.setConversationId(conversationId); - } - } - - @Override - public boolean onOptionsItemSelected(@NonNull final MenuItem item) { - if (item.getItemId() == android.R.id.home) { - // Treat the home press as back press so that when we go back to - // ConversationActivity, it doesn't lose its original intent (conversation id etc.) - onBackPressed(); - return true; - } - return super.onOptionsItemSelected(item); - } -} diff --git a/app/src/main/java/com/android/messaging/ui/conversationsettings/PeopleAndOptionsFragment.java b/app/src/main/java/com/android/messaging/ui/conversationsettings/PeopleAndOptionsFragment.java deleted file mode 100644 index bea302d..0000000 --- a/app/src/main/java/com/android/messaging/ui/conversationsettings/PeopleAndOptionsFragment.java +++ /dev/null @@ -1,314 +0,0 @@ -/* - * Copyright (C) 2015 The Android Open Source Project - * Copyright (C) 2024 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.conversationsettings; - -import android.app.Activity; -import android.app.AlertDialog; -import android.app.NotificationManager; -import android.content.Context; -import android.content.Intent; -import android.content.res.Resources; -import android.database.Cursor; -import android.os.Bundle; -import android.provider.Settings; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.ArrayAdapter; -import android.widget.BaseAdapter; -import android.widget.ListView; -import android.widget.TextView; - -import androidx.annotation.NonNull; -import androidx.fragment.app.Fragment; -import androidx.loader.app.LoaderManager; - -import com.android.messaging.R; -import com.android.messaging.datamodel.DataModel; -import com.android.messaging.datamodel.binding.Binding; -import com.android.messaging.datamodel.binding.BindingBase; -import com.android.messaging.datamodel.data.ParticipantData; -import com.android.messaging.datamodel.data.ParticipantListItemData; -import com.android.messaging.datamodel.data.PeopleAndOptionsData; -import com.android.messaging.datamodel.data.PeopleAndOptionsData.PeopleAndOptionsDataListener; -import com.android.messaging.datamodel.data.PeopleOptionsItemData; -import com.android.messaging.datamodel.data.PersonItemData; -import com.android.messaging.ui.CompositeAdapter; -import com.android.messaging.ui.PersonItemView; -import com.android.messaging.ui.conversation.ConversationActivity; -import com.android.messaging.util.Assert; -import com.android.messaging.util.NotificationsUtil; - -import java.util.ArrayList; -import java.util.List; - -/** - * Shows a list of participants of a conversation and displays options. - */ -public class PeopleAndOptionsFragment extends Fragment - implements PeopleAndOptionsDataListener, PeopleOptionsItemView.HostInterface { - private ListView mListView; - private OptionsListAdapter mOptionsListAdapter; - private PeopleListAdapter mPeopleListAdapter; - private List mOtherParticipants; - private final Binding mBinding = - BindingBase.createBinding(this); - - @Override - public void onCreate(final Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - mBinding.getData().init(LoaderManager.getInstance(this), mBinding); - } - - @Override - public View onCreateView(final LayoutInflater inflater, final ViewGroup container, - final Bundle savedInstanceState) { - final View view = inflater.inflate(R.layout.people_and_options_fragment, container, false); - mListView = view.findViewById(android.R.id.list); - mPeopleListAdapter = new PeopleListAdapter(getActivity()); - mOptionsListAdapter = new OptionsListAdapter(); - final CompositeAdapter compositeAdapter = new CompositeAdapter(getActivity()); - compositeAdapter.addPartition(new PeopleAndOptionsPartition(mOptionsListAdapter, - R.string.general_settings_title, false)); - compositeAdapter.addPartition(new PeopleAndOptionsPartition(mPeopleListAdapter, - R.string.participant_list_title, true)); - mListView.setAdapter(compositeAdapter); - return view; - } - - @Override - public void onDestroy() { - super.onDestroy(); - mBinding.unbind(); - } - - public void setConversationId(final String conversationId) { - Assert.isTrue(getView() == null); - Assert.notNull(conversationId); - mBinding.bind(DataModel.get().createPeopleAndOptionsData(conversationId, getActivity(), - this)); - } - - @Override - public void onOptionsCursorUpdated(final PeopleAndOptionsData data, final Cursor cursor) { - Assert.isTrue(cursor == null || cursor.getCount() == 1); - mBinding.ensureBound(data); - mOptionsListAdapter.swapCursor(cursor); - } - - @Override - public void onParticipantsListLoaded(final PeopleAndOptionsData data, - final List participants) { - mBinding.ensureBound(data); - mPeopleListAdapter.updateParticipants(participants); - mOtherParticipants = participants; - final ParticipantData otherParticipant = participants.size() == 1 ? - participants.get(0) : null; - mOptionsListAdapter.setOtherParticipant(otherParticipant); - } - - @Override - public void onOptionsItemViewClicked(final PeopleOptionsItemData item) { - if (item.getItemId() == PeopleOptionsItemData.SETTING_NOTIFICATION) { - ArrayList participantsNames = new ArrayList<>(); - for (ParticipantData participant : mOtherParticipants) { - participantsNames.add(participant.getDisplayName(true)); - } - NotificationsUtil.createNotificationChannelGroup(getActivity(), - NotificationsUtil.CONVERSATION_GROUP_NAME, - R.string.notification_channel_messages_title); - NotificationsUtil.createNotificationChannel(getActivity(), - mBinding.getData().getConversationId(), - String.join(", ", participantsNames), - NotificationManager.IMPORTANCE_HIGH, - NotificationsUtil.CONVERSATION_GROUP_NAME); - Intent intent = new Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS); - intent.putExtra(Settings.EXTRA_APP_PACKAGE, getContext().getPackageName()); - startActivity(intent); - } else if (item.getItemId() == PeopleOptionsItemData.SETTING_BLOCKED) { - if (item.getOtherParticipant().isBlocked()) { - mBinding.getData().setDestinationBlocked(mBinding, false); - return; - } - final Resources res = getResources(); - final Activity activity = getActivity(); - new AlertDialog.Builder(activity, R.style.AlertDialogTheme) - .setTitle(res.getString(R.string.block_confirmation_title, - item.getOtherParticipant().getDisplayDestination())) - .setMessage(res.getString(R.string.block_confirmation_message)) - .setNegativeButton(android.R.string.cancel, null) - .setPositiveButton(android.R.string.ok, (arg0, arg1) -> { - mBinding.getData().setDestinationBlocked(mBinding, true); - activity.setResult(ConversationActivity.FINISH_RESULT_CODE); - activity.finish(); - }) - .create() - .show(); - } - } - - /** - * A simple adapter that takes a conversation metadata cursor and binds - * PeopleAndOptionsItemViews to individual COLUMNS of the first cursor record. (Note - * that this is not a CursorAdapter because it treats individual columns of the cursor as - * separate options to display for the conversation, e.g. notification settings). - */ - private class OptionsListAdapter extends BaseAdapter { - private Cursor mOptionsCursor; - private ParticipantData mOtherParticipantData; - - public Cursor swapCursor(final Cursor newCursor) { - final Cursor oldCursor = mOptionsCursor; - if (newCursor != oldCursor) { - mOptionsCursor = newCursor; - notifyDataSetChanged(); - } - return oldCursor; - } - - public void setOtherParticipant(final ParticipantData participantData) { - if (mOtherParticipantData != participantData) { - mOtherParticipantData = participantData; - notifyDataSetChanged(); - } - } - - @Override - public int getCount() { - int count = PeopleOptionsItemData.SETTINGS_COUNT; - if (mOtherParticipantData == null) { - count--; - } - return mOptionsCursor == null ? 0 : count; - } - - @Override - public Object getItem(final int position) { - return null; - } - - @Override - public long getItemId(final int position) { - return 0; - } - - @Override - public View getView(final int position, final View convertView, final ViewGroup parent) { - final PeopleOptionsItemView itemView; - if (convertView != null && convertView instanceof PeopleOptionsItemView) { - itemView = (PeopleOptionsItemView) convertView; - } else { - final LayoutInflater inflater = (LayoutInflater) getActivity() - .getSystemService(Context.LAYOUT_INFLATER_SERVICE); - itemView = (PeopleOptionsItemView) - inflater.inflate(R.layout.people_options_item_view, parent, false); - } - mOptionsCursor.moveToFirst(); - itemView.bind(mOptionsCursor, position, mOtherParticipantData, - PeopleAndOptionsFragment.this); - return itemView; - } - } - - /** - * An adapter that takes a list of ParticipantData and displays them as a list of - * ParticipantListItemViews. - */ - private class PeopleListAdapter extends ArrayAdapter { - public PeopleListAdapter(final Context context) { - super(context, R.layout.people_list_item_view, new ArrayList<>()); - } - - public void updateParticipants(final List newList) { - clear(); - addAll(newList); - notifyDataSetChanged(); - } - - @NonNull - @Override - public View getView(final int position, final View convertView, - @NonNull final ViewGroup parent) { - PersonItemView itemView; - final ParticipantData item = getItem(position); - if (convertView != null && convertView instanceof PersonItemView) { - itemView = (PersonItemView) convertView; - } else { - final LayoutInflater inflater = (LayoutInflater) getContext() - .getSystemService(Context.LAYOUT_INFLATER_SERVICE); - itemView = (PersonItemView) inflater.inflate(R.layout.people_list_item_view, parent, - false); - } - final ParticipantListItemData itemData = - DataModel.get().createParticipantListItemData(item); - itemView.bind(itemData); - - // Any click on the row should have the same effect as clicking the avatar icon - final PersonItemView itemViewClosure = itemView; - itemView.setListener(new PersonItemView.PersonItemViewListener() { - @Override - public void onPersonClicked(final PersonItemData data) { - itemViewClosure.performClickOnAvatar(); - } - - @Override - public boolean onPersonLongClicked(final PersonItemData data) { - if (mBinding.isBound()) { - final CopyContactDetailDialog dialog = new CopyContactDetailDialog( - getContext(), data.getDetails()); - dialog.show(); - return true; - } - return false; - } - }); - return itemView; - } - } - - /** - * Represents a partition/section in the People & Options list (e.g. "general options" and - * "people in this conversation" sections). - */ - private class PeopleAndOptionsPartition extends CompositeAdapter.Partition { - private final int mHeaderResId; - private final boolean mNeedDivider; - - public PeopleAndOptionsPartition(final BaseAdapter adapter, final int headerResId, - final boolean needDivider) { - super(true /* showIfEmpty */, true /* hasHeader */, adapter); - mHeaderResId = headerResId; - mNeedDivider = needDivider; - } - - @Override - public View getHeaderView(final View convertView, final ViewGroup parentView) { - View view; - if (convertView != null && convertView.getId() == R.id.people_and_options_header) { - view = convertView; - } else { - view = LayoutInflater.from(getActivity()).inflate( - R.layout.people_and_options_section_header, parentView, false); - } - final TextView text = view.findViewById(R.id.header_text); - final View divider = view.findViewById(R.id.divider); - text.setText(mHeaderResId); - divider.setVisibility(mNeedDivider ? View.VISIBLE : View.GONE); - return view; - } - } -} diff --git a/app/src/main/java/com/android/messaging/ui/conversationsettings/PeopleOptionsItemView.java b/app/src/main/java/com/android/messaging/ui/conversationsettings/PeopleOptionsItemView.java deleted file mode 100644 index 5bd3dca..0000000 --- a/app/src/main/java/com/android/messaging/ui/conversationsettings/PeopleOptionsItemView.java +++ /dev/null @@ -1,70 +0,0 @@ -/* - * Copyright (C) 2015 The Android Open Source Project - * Copyright (C) 2024 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.conversationsettings; - -import android.content.Context; -import android.database.Cursor; -import androidx.appcompat.widget.SwitchCompat; - -import android.util.AttributeSet; -import android.widget.LinearLayout; -import android.widget.TextView; - -import com.android.messaging.R; -import com.android.messaging.datamodel.DataModel; -import com.android.messaging.datamodel.data.ParticipantData; -import com.android.messaging.datamodel.data.PeopleOptionsItemData; -import com.android.messaging.util.Assert; - -/** - * The view for a single entry in the options section of people & options activity. - */ -public class PeopleOptionsItemView extends LinearLayout { - /** - * Implemented by the host of this view that handles options click event. - */ - public interface HostInterface { - void onOptionsItemViewClicked(PeopleOptionsItemData item); - } - - private TextView mTitle; - private TextView mSubtitle; - private SwitchCompat mSwitch; - private final PeopleOptionsItemData mData; - private HostInterface mHostInterface; - - public PeopleOptionsItemView(final Context context, final AttributeSet attrs) { - super(context, attrs); - mData = DataModel.get().createPeopleOptionsItemData(context); - } - - @Override - protected void onFinishInflate () { - super.onFinishInflate(); - mTitle = findViewById(R.id.title); - setOnClickListener(v -> mHostInterface.onOptionsItemViewClicked(mData)); - } - - public void bind(final Cursor cursor, final int columnIndex, ParticipantData otherParticipant, - final HostInterface hostInterface) { - Assert.isTrue(columnIndex < PeopleOptionsItemData.SETTINGS_COUNT && columnIndex >= 0); - mData.bind(cursor, otherParticipant, columnIndex); - mHostInterface = hostInterface; - - mTitle.setText(mData.getTitle()); - } -} diff --git a/app/src/main/java/com/android/messaging/ui/home/HomeComposeActivity.java b/app/src/main/java/com/android/messaging/ui/home/HomeComposeActivity.java new file mode 100644 index 0000000..259d557 --- /dev/null +++ b/app/src/main/java/com/android/messaging/ui/home/HomeComposeActivity.java @@ -0,0 +1,227 @@ +/* + * 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.Intent; +import android.os.Bundle; +import android.text.Editable; +import android.text.TextUtils; +import android.text.TextWatcher; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.EditText; +import android.widget.HorizontalScrollView; +import android.widget.ImageButton; +import android.widget.LinearLayout; +import android.widget.TextView; + +import com.android.ex.chips.RecipientEntry; +import com.android.messaging.R; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.ui.BugleActionBarActivity; +import com.android.messaging.ui.ContactIconView; +import com.android.messaging.util.AvatarUriUtil; +import com.android.messaging.util.ContactRecipientEntryUtils; +import com.android.messaging.util.ImeUtil; + +import java.util.ArrayList; +import java.util.List; + +/** Home-style "new chat" screen: recipient field, group entry point and contact list. */ +public class HomeComposeActivity extends BugleActionBarActivity + implements HomeContactsFragment.GroupSelectionHost { + + /** Matches the duration of {@code R.anim.home_sheet_open}. */ + private static final long SHEET_ENTER_MS = 300L; + private static final int REQUEST_GROUP_NAME = 1; + private static final int MIN_GROUP_SIZE = 2; + + private EditText mRecipientField; + private TextView mHeaderTitle; + private View mCreateGroupButton; + private HorizontalScrollView mChipScroll; + private LinearLayout mChipContainer; + private TextView mChipHint; + private TextView mNextButton; + private boolean mGroupMode; + + @Override + protected void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.home_compose_activity); + if (getSupportActionBar() != null) { + getSupportActionBar().hide(); + } + + // Presented as a sheet, so the header closes instead of navigating back. + final ImageButton back = findViewById(R.id.home_sub_header_back); + back.setVisibility(View.GONE); + final ImageButton close = findViewById(R.id.home_sub_header_close); + close.setVisibility(View.VISIBLE); + close.setOnClickListener(v -> finish()); + + mHeaderTitle = findViewById(R.id.home_sub_header_title); + mHeaderTitle.setText(R.string.home_compose_title); + + final View scrim = findViewById(R.id.home_compose_scrim); + // The tap that opened the sheet can land here while it is still sliding up, so the + // dismiss handler only arms once the entry animation is done. + scrim.postDelayed(() -> scrim.setOnClickListener(v -> finish()), SHEET_ENTER_MS); + + mChipScroll = findViewById(R.id.home_compose_chip_scroll); + mChipContainer = findViewById(R.id.home_compose_chips); + mChipHint = findViewById(R.id.home_compose_chip_hint); + mNextButton = findViewById(R.id.home_compose_next); + mNextButton.setOnClickListener(v -> openGroupNameStep()); + + mRecipientField = findViewById(R.id.home_compose_recipient); + mRecipientField.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 HomeContactsFragment fragment = getContactsFragment(); + if (fragment != null) { + fragment.setFilterText(s.toString()); + } + } + }); + mRecipientField.setOnEditorActionListener((v, actionId, event) -> { + final HomeContactsFragment fragment = getContactsFragment(); + return fragment != null && fragment.startConversationWithFilterText(); + }); + + mCreateGroupButton = findViewById(R.id.home_compose_create_group); + mCreateGroupButton.setOnClickListener(v -> enterGroupMode()); + } + + private HomeContactsFragment getContactsFragment() { + return (HomeContactsFragment) getSupportFragmentManager() + .findFragmentById(R.id.home_compose_fragment_host); + } + + private void enterGroupMode() { + mGroupMode = true; + ImeUtil.get().hideImeKeyboard(this, mRecipientField); + mHeaderTitle.setText(R.string.home_compose_group_title); + mRecipientField.setText(""); + mRecipientField.setVisibility(View.GONE); + mCreateGroupButton.setVisibility(View.GONE); + mChipScroll.setVisibility(View.VISIBLE); + mChipHint.setVisibility(View.VISIBLE); + mNextButton.setVisibility(View.VISIBLE); + final HomeContactsFragment fragment = getContactsFragment(); + if (fragment != null) { + fragment.setGroupMode(true); + } + updateNextButton(0); + } + + private void exitGroupMode() { + mGroupMode = false; + mHeaderTitle.setText(R.string.home_compose_title); + mRecipientField.setVisibility(View.VISIBLE); + mCreateGroupButton.setVisibility(View.VISIBLE); + mChipScroll.setVisibility(View.GONE); + mChipHint.setVisibility(View.GONE); + mNextButton.setVisibility(View.GONE); + mChipContainer.removeAllViews(); + final HomeContactsFragment fragment = getContactsFragment(); + if (fragment != null) { + fragment.setGroupMode(false); + } + } + + @Override + public void onGroupSelectionChanged(final List selected) { + mChipContainer.removeAllViews(); + final LayoutInflater inflater = LayoutInflater.from(this); + for (final RecipientEntry entry : selected) { + final View chip = inflater.inflate(R.layout.home_compose_chip, mChipContainer, false); + final ContactIconView avatar = chip.findViewById(R.id.home_compose_chip_avatar); + final ParticipantData participant = ParticipantData.getFromRecipientEntry(entry); + avatar.setImageResourceUri(AvatarUriUtil.createAvatarUri(participant), + participant.getContactId(), participant.getLookupKey(), + participant.getNormalizedDestination()); + avatar.setImageClickHandlerDisabled(true); + final TextView name = chip.findViewById(R.id.home_compose_chip_name); + name.setText(ContactRecipientEntryUtils.getDisplayNameForContactList(entry)); + final LinearLayout.LayoutParams params = + (LinearLayout.LayoutParams) chip.getLayoutParams(); + params.setMarginEnd(getResources().getDimensionPixelSize(R.dimen.looply_space_xs)); + mChipContainer.addView(chip, params); + } + mChipHint.setVisibility(selected.isEmpty() ? View.VISIBLE : View.GONE); + updateNextButton(selected.size()); + } + + private void updateNextButton(final int selectedCount) { + final boolean enabled = selectedCount >= MIN_GROUP_SIZE; + mNextButton.setEnabled(enabled); + mNextButton.setAlpha(enabled ? 1f : 0.4f); + } + + private void openGroupNameStep() { + final HomeContactsFragment fragment = getContactsFragment(); + if (fragment == null) { + return; + } + final ArrayList participants = fragment.getSelectedParticipants(); + if (participants.size() < MIN_GROUP_SIZE) { + return; + } + startActivityForResult(HomeGroupNameActivity.buildIntent(this, participants), + REQUEST_GROUP_NAME); + overridePendingTransition(R.anim.home_sheet_open, R.anim.home_activity_hold); + } + + @Override + protected void onActivityResult(final int requestCode, final int resultCode, + final Intent data) { + super.onActivityResult(requestCode, resultCode, data); + if (requestCode == REQUEST_GROUP_NAME && resultCode == RESULT_OK) { + // The conversation is open, so this sheet should not be behind it. + finish(); + } + } + + @Override + public void onBackPressed() { + if (mGroupMode) { + exitGroupMode(); + return; + } + super.onBackPressed(); + } + + @Override + public void finish() { + if (mRecipientField != null && !TextUtils.isEmpty(mRecipientField.getText())) { + ImeUtil.get().hideImeKeyboard(this, mRecipientField); + } + super.finish(); + overridePendingTransition(R.anim.home_activity_hold, R.anim.home_sheet_close); + } +} diff --git a/app/src/main/java/com/android/messaging/ui/home/HomeContactsFragment.java b/app/src/main/java/com/android/messaging/ui/home/HomeContactsFragment.java index 1181dce..0ceae3b 100644 --- a/app/src/main/java/com/android/messaging/ui/home/HomeContactsFragment.java +++ b/app/src/main/java/com/android/messaging/ui/home/HomeContactsFragment.java @@ -2,25 +2,25 @@ package com.android.messaging.ui.home; 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 android.widget.Toast; +import android.widget.TextView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.fragment.app.Fragment; import androidx.loader.app.LoaderManager; +import com.android.ex.chips.RecipientEntry; import com.android.messaging.R; import com.android.messaging.datamodel.DataModel; -import com.android.messaging.datamodel.action.GetOrCreateConversationAction; import com.android.messaging.datamodel.action.ActionMonitor; +import com.android.messaging.datamodel.action.GetOrCreateConversationAction; import com.android.messaging.datamodel.action.GetOrCreateConversationAction.GetOrCreateConversationActionListener; import com.android.messaging.datamodel.action.GetOrCreateConversationAction.GetOrCreateConversationActionMonitor; -import com.android.messaging.util.Assert; -import com.android.messaging.util.Assert.RunsOnMainThread; import com.android.messaging.datamodel.binding.Binding; import com.android.messaging.datamodel.binding.BindingBase; import com.android.messaging.datamodel.data.ContactListItemData; @@ -29,17 +29,38 @@ import com.android.messaging.datamodel.data.ContactPickerData.ContactPickerDataL import com.android.messaging.datamodel.data.ParticipantData; import com.android.messaging.ui.UIIntents; import com.android.messaging.ui.contact.ContactListItemView; +import com.android.messaging.util.Assert; +import com.android.messaging.util.Assert.RunsOnMainThread; +import com.android.messaging.util.ContactRecipientEntryUtils; import com.android.messaging.util.ContactUtil; import com.android.messaging.util.LogUtil; +import com.android.messaging.util.PhoneUtils; import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +/** + * Contact list for the home "new chat" screen. Tapping a row opens that conversation, or picks + * the contact when the host screen is building a group. + */ public class HomeContactsFragment extends Fragment implements ContactPickerDataListener, ContactListItemView.HostInterface, GetOrCreateConversationActionListener { + /** Implemented by a host that lets the user pick several contacts at once. */ + public interface GroupSelectionHost { + void onGroupSelectionChanged(List selected); + } + private final Binding mBinding = BindingBase.createBinding(this); + private final LinkedHashMap mSelected = new LinkedHashMap<>(); private HomeContactListAdapter mAdapter; private GetOrCreateConversationActionMonitor mMonitor; + private TextView mEmptyView; + private ContactListItemView mSendToView; + private Cursor mAllContactsCursor; + private String mFilterText; + private boolean mGroupMode; @Override public void onCreate(@Nullable final Bundle savedInstanceState) { @@ -61,23 +82,58 @@ public class HomeContactsFragment extends Fragment implements ContactPickerDataL public void onViewCreated(@NonNull final View view, @Nullable final Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); final ListView listView = view.findViewById(R.id.home_contacts_list); + mEmptyView = view.findViewById(R.id.home_contacts_empty); + mSendToView = view.findViewById(R.id.home_contacts_send_to); mAdapter = new HomeContactListAdapter(requireContext(), null, this); listView.setAdapter(mAdapter); + updateResultViews(); + } - view.findViewById(R.id.home_contacts_search).setOnClickListener(v -> - Toast.makeText(requireContext(), R.string.home_popover_coming_soon, - Toast.LENGTH_SHORT).show()); - view.findViewById(R.id.home_contacts_invite).setOnClickListener(v -> - Toast.makeText(requireContext(), R.string.home_popover_coming_soon, - Toast.LENGTH_SHORT).show()); - view.findViewById(R.id.home_contacts_recent_calls).setOnClickListener(v -> - Toast.makeText(requireContext(), R.string.home_popover_coming_soon, - Toast.LENGTH_SHORT).show()); - view.findViewById(R.id.home_contacts_sort).setOnClickListener(v -> - Toast.makeText(requireContext(), R.string.home_popover_coming_soon, - Toast.LENGTH_SHORT).show()); - view.findViewById(R.id.home_contacts_add).setOnClickListener(v -> - UIIntents.get().launchCreateNewConversationActivity(requireActivity(), null)); + /** Switches between opening a conversation on tap and collecting group members. */ + public void setGroupMode(final boolean groupMode) { + if (mGroupMode == groupMode) { + return; + } + mGroupMode = groupMode; + if (!groupMode) { + mSelected.clear(); + } + notifyGroupSelectionChanged(); + if (mAdapter != null) { + mAdapter.notifyDataSetChanged(); + } + } + + public ArrayList getSelectedParticipants() { + final ArrayList participants = new ArrayList<>(mSelected.size()); + for (final RecipientEntry entry : mSelected.values()) { + participants.add(ParticipantData.getFromRecipientEntry(entry)); + } + return participants; + } + + /** Narrows the list as the host screen's recipient field is typed into. */ + public void setFilterText(final String filterText) { + mFilterText = filterText; + if (mBinding.isBound()) { + mBinding.getData().setSearchText(mBinding, filterText); + } + updateResultViews(); + } + + /** + * Starts a conversation with the raw text currently typed, if it can be messaged. + * + * @return whether the text was a usable destination + */ + public boolean startConversationWithFilterText() { + final String destination = mFilterText == null ? null : mFilterText.trim(); + if (TextUtils.isEmpty(destination) + || !PhoneUtils.isValidSmsMmsDestination(destination)) { + return false; + } + startConversation(ContactRecipientEntryUtils.constructSendToDestinationEntry(destination)); + return true; } @Override @@ -95,7 +151,19 @@ public class HomeContactsFragment extends Fragment implements ContactPickerDataL @Override public void onAllContactsCursorUpdated(final Cursor data) { mBinding.ensureBound(); - mAdapter.changeCursor(data); + mAllContactsCursor = data; + if (TextUtils.isEmpty(mFilterText)) { + mAdapter.changeCursor(data); + updateResultViews(); + } + } + + @Override + public void onFilteredContactsCursorUpdated(final Cursor data) { + mBinding.ensureBound(); + // A null cursor means the search was cleared, so fall back to the full list. + mAdapter.changeCursor(data != null ? data : mAllContactsCursor); + updateResultViews(); } @Override @@ -112,29 +180,56 @@ public class HomeContactsFragment extends Fragment implements ContactPickerDataL @Override public void onContactListItemClicked(final ContactListItemData item, final ContactListItemView view) { + if (!mGroupMode) { + startConversation(item.getRecipientEntry()); + return; + } + final RecipientEntry entry = item.getRecipientEntry(); + final String key = selectionKey(entry); + if (mSelected.remove(key) == null) { + mSelected.put(key, entry); + } + notifyGroupSelectionChanged(); + if (mAdapter != null) { + mAdapter.notifyDataSetChanged(); + } + } + + private static String selectionKey(final RecipientEntry entry) { + return PhoneUtils.getDefault().getCanonicalBySystemLocale(entry.getDestination()); + } + + private void notifyGroupSelectionChanged() { + if (getActivity() instanceof GroupSelectionHost) { + ((GroupSelectionHost) getActivity()).onGroupSelectionChanged( + new ArrayList<>(mSelected.values())); + } + } + + private void startConversation(final RecipientEntry entry) { if (mMonitor != null) { return; } final ArrayList participants = new ArrayList<>(1); - participants.add(ParticipantData.getFromRecipientEntry(item.getRecipientEntry())); - mMonitor = GetOrCreateConversationAction.getOrCreateConversation(participants, null, - this); + participants.add(ParticipantData.getFromRecipientEntry(entry)); + mMonitor = GetOrCreateConversationAction.getOrCreateConversation(participants, null, this); } @Override public boolean isContactSelected(final ContactListItemData item) { - return false; + return mGroupMode && mSelected.containsKey(selectionKey(item.getRecipientEntry())); } @Override @RunsOnMainThread - public void onGetOrCreateConversationSucceeded(final ActionMonitor monitor, - final Object data, final String conversationId) { + public void onGetOrCreateConversationSucceeded(final ActionMonitor monitor, final Object data, + final String conversationId) { Assert.isTrue(monitor == mMonitor); mMonitor = null; - if (conversationId != null) { + if (conversationId != null && getActivity() != null) { UIIntents.get().launchConversationActivity(requireActivity(), conversationId, null, null, false); + getActivity().finish(); } } @@ -145,4 +240,41 @@ public class HomeContactsFragment extends Fragment implements ContactPickerDataL LogUtil.e(LogUtil.BUGLE_TAG, "HomeContactsFragment onGetOrCreateConversationFailed"); mMonitor = null; } + + /** + * Shows the typed destination as a tappable row whenever the filter matched no contact, so a + * number that is not in the address book can still start a conversation. + */ + private void updateResultViews() { + if (mEmptyView == null || mSendToView == null) { + return; + } + final String destination = mFilterText == null ? null : mFilterText.trim(); + final boolean filtering = !TextUtils.isEmpty(destination); + final boolean noMatches = mAdapter == null || mAdapter.getCount() == 0; + final boolean showSendTo = filtering && noMatches + && PhoneUtils.isValidSmsMmsDestination(destination); + + if (showSendTo) { + final RecipientEntry entry = + ContactRecipientEntryUtils.constructSendToDestinationEntry(destination); + mSendToView.bind(entry, getString(R.string.home_compose_send_to, + ContactRecipientEntryUtils.formatDestination(entry)), null, this, true, false); + // The RecipientEntry bind path is meant for dropdowns, which handle clicks themselves. + mSendToView.setOnClickListener(v -> startConversation(entry)); + mSendToView.setVisibility(View.VISIBLE); + } else { + mSendToView.setVisibility(View.GONE); + } + + if (!noMatches || showSendTo) { + mEmptyView.setVisibility(View.GONE); + return; + } + mEmptyView.setText(filtering ? R.string.home_compose_no_results + : ContactUtil.hasReadContactsPermission() + ? R.string.home_compose_empty + : R.string.home_compose_permission_required); + mEmptyView.setVisibility(View.VISIBLE); + } } diff --git a/app/src/main/java/com/android/messaging/ui/home/HomeConversationSearchActivity.java b/app/src/main/java/com/android/messaging/ui/home/HomeConversationSearchActivity.java new file mode 100644 index 0000000..61f7dd8 --- /dev/null +++ b/app/src/main/java/com/android/messaging/ui/home/HomeConversationSearchActivity.java @@ -0,0 +1,233 @@ +/* + * 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.Editable; +import android.text.Spannable; +import android.text.SpannableString; +import android.text.TextUtils; +import android.text.TextWatcher; +import android.text.style.BackgroundColorSpan; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.EditText; +import android.widget.ImageButton; +import android.widget.ImageView; +import android.widget.ListView; +import android.widget.TextView; + +import androidx.cursoradapter.widget.CursorAdapter; +import androidx.loader.app.LoaderManager; + +import com.android.messaging.R; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.binding.Binding; +import com.android.messaging.datamodel.binding.BindingBase; +import com.android.messaging.datamodel.data.ConversationMessageData; +import com.android.messaging.datamodel.data.ConversationSearchData; +import com.android.messaging.datamodel.data.ConversationSearchData.ConversationSearchDataListener; +import com.android.messaging.ui.BugleActionBarActivity; +import com.android.messaging.ui.ContactIconView; +import com.android.messaging.ui.UIIntents; +import com.android.messaging.util.AvatarUriUtil; +import com.android.messaging.util.Dates; +import com.android.messaging.util.ImeUtil; + +/** Searches the messages of a single conversation as the user types. */ +public class HomeConversationSearchActivity extends BugleActionBarActivity + implements ConversationSearchDataListener { + + private final Binding mBinding = BindingBase.createBinding(this); + + private String mConversationId; + private EditText mSearchField; + private ImageButton mClearButton; + private ListView mListView; + private View mIdleContainer; + private TextView mEmptyView; + private SearchResultAdapter mAdapter; + + @Override + protected void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.home_conversation_search_activity); + if (getSupportActionBar() != null) { + getSupportActionBar().hide(); + } + + mConversationId = getIntent().getStringExtra(UIIntents.UI_INTENT_EXTRA_CONVERSATION_ID); + if (TextUtils.isEmpty(mConversationId)) { + finish(); + return; + } + + mListView = findViewById(R.id.home_conversation_search_list); + mIdleContainer = findViewById(R.id.home_conversation_search_idle); + mEmptyView = findViewById(R.id.home_conversation_search_empty); + mAdapter = new SearchResultAdapter(this); + mListView.setAdapter(mAdapter); + mListView.setOnItemClickListener((parent, view, position, id) -> openConversation()); + + HomeSubHeader.bind(this, R.string.home_search_title, v -> finish()); + + mClearButton = findViewById(R.id.home_conversation_search_clear); + mClearButton.setOnClickListener(v -> mSearchField.setText("")); + + mSearchField = findViewById(R.id.home_conversation_search_field); + mSearchField.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) { + mClearButton.setVisibility(TextUtils.isEmpty(s) ? View.GONE : View.VISIBLE); + mBinding.getData().setSearchText(mBinding, s.toString()); + updateEmptyView(); + } + }); + mSearchField.setOnEditorActionListener((v, actionId, event) -> { + ImeUtil.get().hideImeKeyboard(this, mSearchField); + return true; + }); + + mBinding.bind(DataModel.get().createConversationSearchData(this, mConversationId, this)); + mBinding.getData().init(LoaderManager.getInstance(this)); + + mSearchField.requestFocus(); + mSearchField.post(() -> ImeUtil.get().showImeKeyboard(this, mSearchField)); + updateEmptyView(); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + mBinding.unbind(); + } + + @Override + public void onConversationSearchCursorUpdated(final Cursor cursor) { + mAdapter.swapCursor(cursor); + updateEmptyView(); + } + + private void updateEmptyView() { + final String searchText = mBinding.isBound() ? mBinding.getData().getSearchText() : null; + final ImageView idleIcon = mIdleContainer == null ? null + : mIdleContainer.findViewById(R.id.home_conversation_search_idle_icon); + if (TextUtils.isEmpty(searchText)) { + mEmptyView.setText(R.string.home_conversation_search_prompt); + if (idleIcon != null) { + idleIcon.setVisibility(View.VISIBLE); + } + if (mIdleContainer != null) { + mIdleContainer.setVisibility(View.VISIBLE); + } else { + mEmptyView.setVisibility(View.VISIBLE); + } + mListView.setVisibility(View.GONE); + return; + } + final boolean empty = mAdapter.getCount() == 0; + mEmptyView.setText(getString(R.string.home_conversation_search_empty, searchText)); + if (idleIcon != null) { + idleIcon.setVisibility(View.GONE); + } + if (mIdleContainer != null) { + mIdleContainer.setVisibility(empty ? View.VISIBLE : View.GONE); + } else { + mEmptyView.setVisibility(empty ? View.VISIBLE : View.GONE); + } + mListView.setVisibility(empty ? View.GONE : View.VISIBLE); + } + + private void openConversation() { + // Bugle cannot open a conversation anchored on a single message, so land on the latest. + UIIntents.get().launchConversationActivity(this, mConversationId, null, null, false); + } + + @Override + public void finish() { + if (mSearchField != null) { + ImeUtil.get().hideImeKeyboard(this, mSearchField); + } + super.finish(); + overridePendingTransition(R.anim.home_activity_hold, R.anim.home_activity_close); + } + + private class SearchResultAdapter extends CursorAdapter { + + SearchResultAdapter(final Context context) { + super(context, null, 0); + } + + @Override + public View newView(final Context context, final Cursor cursor, final ViewGroup parent) { + return LayoutInflater.from(context) + .inflate(R.layout.home_conversation_search_item, parent, false); + } + + @Override + public void bindView(final View view, final Context context, final Cursor cursor) { + final ConversationMessageData message = new ConversationMessageData(); + message.bind(cursor); + + final ContactIconView avatar = + view.findViewById(R.id.home_conversation_search_avatar); + avatar.setImageResourceUri(AvatarUriUtil.createAvatarUri( + message.getSenderProfilePhotoUri(), message.getSenderFullName(), + message.getSenderNormalizedDestination(), + message.getSenderContactLookupKey()), + message.getSenderContactId(), message.getSenderContactLookupKey(), + message.getSenderNormalizedDestination()); + avatar.setImageClickHandlerDisabled(true); + + ((TextView) view.findViewById(R.id.home_conversation_search_sender)).setText( + message.getIsIncoming() ? message.getSenderDisplayName() + : getString(R.string.home_conversation_search_you)); + ((TextView) view.findViewById(R.id.home_conversation_search_time)).setText( + Dates.getConversationTimeString(message.getReceivedTimeStamp())); + ((TextView) view.findViewById(R.id.home_conversation_search_text)) + .setText(highlight(message.getText())); + } + + private CharSequence highlight(final String text) { + final String searchText = mBinding.getData().getSearchText(); + if (TextUtils.isEmpty(text) || TextUtils.isEmpty(searchText)) { + return text; + } + final int start = text.toLowerCase().indexOf(searchText.toLowerCase()); + if (start < 0) { + return text; + } + final SpannableString spannable = new SpannableString(text); + spannable.setSpan(new BackgroundColorSpan(getColor(R.color.home_accent_soft)), + start, start + searchText.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + return spannable; + } + } +} 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 new file mode 100644 index 0000000..5dbe478 --- /dev/null +++ b/app/src/main/java/com/android/messaging/ui/home/HomeDeleteAlertDialog.java @@ -0,0 +1,94 @@ +/* + * 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.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. */ +public final class HomeDeleteAlertDialog { + + public interface Listener { + void onDelete(); + + void onDeleteAndRegisterSpam(); + } + + private 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); + } + HomeUiMotion.dialogEnter(content); + } +} diff --git a/app/src/main/java/com/android/messaging/ui/home/HomeGallerySheet.java b/app/src/main/java/com/android/messaging/ui/home/HomeGallerySheet.java new file mode 100644 index 0000000..6cfca5b --- /dev/null +++ b/app/src/main/java/com/android/messaging/ui/home/HomeGallerySheet.java @@ -0,0 +1,434 @@ +/* + * Copyright (C) 2026 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.app.Activity; +import android.content.ContentUris; +import android.database.Cursor; +import android.net.Uri; +import android.os.Handler; +import android.os.Looper; +import android.provider.MediaStore; +import android.view.Gravity; +import android.view.LayoutInflater; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewTreeObserver; +import android.view.inputmethod.InputMethodManager; +import android.widget.FrameLayout; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.view.ViewCompat; +import androidx.core.view.WindowInsetsCompat; +import androidx.recyclerview.widget.GridLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import com.android.messaging.R; +import com.android.messaging.datamodel.media.MessagePartVideoThumbnailRequestDescriptor; +import com.android.messaging.datamodel.media.UriImageRequestDescriptor; +import com.android.messaging.ui.AsyncImageView; +import com.android.messaging.util.OsUtil; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +/** + * In-conversation gallery sheet. Avoids the system Photo Picker overlay, which flashes on open + * and drops a black window when the handle is slid down. + */ +public final class HomeGallerySheet { + + public interface Callback { + void onMediaPicked(@NonNull List uris); + } + + private static final int SPAN_COUNT = 3; + private static final String[] PROJECTION = new String[] { + MediaStore.Files.FileColumns._ID, + MediaStore.Files.FileColumns.MEDIA_TYPE, + MediaStore.Files.FileColumns.MIME_TYPE, + }; + + private HomeGallerySheet() { + } + + public static boolean dismissIfShowing(@Nullable final Activity activity) { + if (activity == null) { + return false; + } + final View root = activity.findViewById(R.id.home_gallery_root); + if (root == null) { + return false; + } + final Object dismiss = root.getTag(); + if (dismiss instanceof Runnable) { + ((Runnable) dismiss).run(); + return true; + } + final ViewGroup parent = (ViewGroup) root.getParent(); + if (parent != null) { + parent.removeView(root); + } + return true; + } + + public static void show(@NonNull final Activity activity, @NonNull final Callback callback, + final Runnable onRequestPermission) { + if (activity.findViewById(R.id.home_gallery_root) != null) { + return; + } + if (OsUtil.hasMediaPermissions()) { + final ExecutorService executor = Executors.newSingleThreadExecutor(); + final Handler handler = new Handler(Looper.getMainLooper()); + executor.execute(() -> { + final List items = queryMedia(activity); + handler.post(() -> { + showReady(activity, callback, onRequestPermission, items); + executor.shutdown(); + }); + }); + return; + } + showReady(activity, callback, onRequestPermission, Collections.emptyList()); + } + + private static void showReady(@NonNull final Activity activity, + @NonNull final Callback callback, final Runnable onRequestPermission, + @NonNull final List items) { + if (activity.isFinishing() || activity.findViewById(R.id.home_gallery_root) != null) { + return; + } + ViewGroup host = activity.findViewById(R.id.conversation_and_compose_container); + if (host == null) { + host = activity.findViewById(android.R.id.content); + } + if (host == null) { + return; + } + final View focused = activity.getCurrentFocus(); + if (focused != null) { + final InputMethodManager imm = activity.getSystemService(InputMethodManager.class); + if (imm != null) { + imm.hideSoftInputFromWindow(focused.getWindowToken(), 0); + } + } + + final View content = LayoutInflater.from(activity).inflate( + R.layout.home_gallery_sheet, host, false); + final View scrim = content.findViewById(R.id.home_gallery_scrim); + final View sheet = content.findViewById(R.id.home_gallery_sheet); + final View header = content.findViewById(R.id.home_gallery_header); + final RecyclerView grid = content.findViewById(R.id.home_gallery_grid); + final View empty = content.findViewById(R.id.home_gallery_empty); + final TextView emptyMessage = content.findViewById(R.id.home_gallery_empty_message); + final View allow = content.findViewById(R.id.home_gallery_allow); + + final int screenHeight = activity.getResources().getDisplayMetrics().heightPixels; + if (scrim != null) { + scrim.setAlpha(0f); + } + sheet.setTranslationY(screenHeight); + + final boolean[] closing = new boolean[] { false }; + final Runnable dismissAnimated = () -> { + if (closing[0]) { + return; + } + closing[0] = true; + HomeUiMotion.gallerySheetClose(scrim, sheet, () -> { + if (content.getParent() instanceof ViewGroup) { + ((ViewGroup) content.getParent()).removeView(content); + } + }); + }; + content.setTag(dismissAnimated); + + scrim.setOnClickListener(v -> dismissAnimated.run()); + bindDragToDismiss(header, scrim, sheet, dismissAnimated); + + final Adapter adapter = new Adapter(uri -> { + dismissAnimated.run(); + callback.onMediaPicked(Collections.singletonList(uri)); + }); + grid.setLayoutManager(new GridLayoutManager(activity, SPAN_COUNT)); + grid.setAdapter(adapter); + grid.addItemDecoration(new GapDecoration( + activity.getResources().getDimensionPixelSize(R.dimen.home_gallery_grid_gap))); + + if (allow != null) { + allow.setOnClickListener(v -> { + if (onRequestPermission != null) { + dismissAnimated.run(); + onRequestPermission.run(); + } + }); + } + + final int sheetTop = resolveSheetTop(activity, host); + final FrameLayout.LayoutParams overlayLp = new FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT); + overlayLp.topMargin = sheetTop; + content.setLayoutParams(overlayLp); + + final int hostHeight = host.getHeight() > 0 ? host.getHeight() : screenHeight; + final float sheetFraction = activity.getResources().getFraction( + R.fraction.home_gallery_sheet_height, 1, 1); + final FrameLayout.LayoutParams sheetLp = new FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + Math.max(1, Math.round(hostHeight * sheetFraction)), + Gravity.BOTTOM); + sheet.setLayoutParams(sheetLp); + + adapter.setItems(items); + if (items.isEmpty()) { + empty.setVisibility(View.VISIBLE); + if (!OsUtil.hasMediaPermissions()) { + emptyMessage.setText(R.string.home_gallery_permission); + allow.setVisibility(View.VISIBLE); + } else { + emptyMessage.setText(R.string.home_gallery_empty); + allow.setVisibility(View.GONE); + } + } else { + empty.setVisibility(View.GONE); + allow.setVisibility(View.GONE); + } + + host.addView(content); + content.getViewTreeObserver().addOnPreDrawListener( + new ViewTreeObserver.OnPreDrawListener() { + @Override + public boolean onPreDraw() { + if (content.getViewTreeObserver().isAlive()) { + content.getViewTreeObserver().removeOnPreDrawListener(this); + } + final int height = sheet.getHeight() > 0 ? sheet.getHeight() : screenHeight; + sheet.setTranslationY(height); + if (scrim != null) { + scrim.setAlpha(0f); + } + HomeUiMotion.gallerySheetOpen(scrim, sheet); + return false; + } + }); + } + + private static void bindDragToDismiss(final View header, final View scrim, final View sheet, + final Runnable dismissAnimated) { + header.setOnTouchListener(new View.OnTouchListener() { + private float mDownY; + private boolean mDragging; + + @Override + public boolean onTouch(final View v, final MotionEvent event) { + switch (event.getActionMasked()) { + case MotionEvent.ACTION_DOWN: + mDownY = event.getRawY(); + mDragging = false; + return true; + case MotionEvent.ACTION_MOVE: { + final float dy = Math.max(0f, event.getRawY() - mDownY); + if (dy > 0f) { + mDragging = true; + } + sheet.setTranslationY(dy); + final int height = Math.max(sheet.getHeight(), 1); + scrim.setAlpha(1f - Math.min(1f, dy / height)); + return true; + } + case MotionEvent.ACTION_UP: + case MotionEvent.ACTION_CANCEL: { + final float dy = Math.max(0f, event.getRawY() - mDownY); + final int height = Math.max(sheet.getHeight(), 1); + if (mDragging && dy > height * 0.18f) { + dismissAnimated.run(); + } else { + sheet.animate().translationY(0f).setDuration(180).start(); + scrim.animate().alpha(1f).setDuration(180).start(); + } + return true; + } + default: + return false; + } + } + }); + } + + private static int resolveSheetTop(@NonNull final Activity activity, + @NonNull final View host) { + final View conversationHeader = activity.findViewById(R.id.conversation_header); + if (conversationHeader != null && conversationHeader.getHeight() > 0) { + final int[] headerLoc = new int[2]; + final int[] hostLoc = new int[2]; + conversationHeader.getLocationInWindow(headerLoc); + host.getLocationInWindow(hostLoc); + return Math.max(0, headerLoc[1] + conversationHeader.getHeight() - hostLoc[1]); + } + int statusTop = 0; + final WindowInsetsCompat insets = ViewCompat.getRootWindowInsets(host); + if (insets != null) { + statusTop = insets.getInsets(WindowInsetsCompat.Type.statusBars()).top; + } + return statusTop + activity.getResources().getDimensionPixelSize( + R.dimen.home_header_height); + } + + @NonNull + private static List queryMedia(final Activity activity) { + final List items = new ArrayList<>(); + final boolean images = OsUtil.hasPermission(android.Manifest.permission.READ_MEDIA_IMAGES); + final boolean videos = OsUtil.hasPermission(android.Manifest.permission.READ_MEDIA_VIDEO); + if (!images && !videos) { + return items; + } + final StringBuilder selection = new StringBuilder(); + final List args = new ArrayList<>(); + if (images) { + selection.append(MediaStore.Files.FileColumns.MEDIA_TYPE).append("=?"); + args.add(String.valueOf(MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE)); + } + if (videos) { + if (selection.length() > 0) { + selection.append(" OR "); + } + selection.append(MediaStore.Files.FileColumns.MEDIA_TYPE).append("=?"); + args.add(String.valueOf(MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO)); + } + try (Cursor cursor = activity.getContentResolver().query( + MediaStore.Files.getContentUri("external"), + PROJECTION, + selection.toString(), + args.toArray(new String[0]), + MediaStore.Files.FileColumns.DATE_ADDED + " DESC")) { + if (cursor == null) { + return items; + } + final int idIndex = cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns._ID); + final int typeIndex = cursor.getColumnIndexOrThrow( + MediaStore.Files.FileColumns.MEDIA_TYPE); + while (cursor.moveToNext()) { + final long id = cursor.getLong(idIndex); + final int type = cursor.getInt(typeIndex); + final boolean video = type == MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO; + final Uri uri = ContentUris.withAppendedId( + video ? MediaStore.Video.Media.EXTERNAL_CONTENT_URI + : MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id); + items.add(new MediaItem(uri, video)); + } + } + return items; + } + + private static final class MediaItem { + final Uri uri; + final boolean video; + + MediaItem(final Uri uri, final boolean video) { + this.uri = uri; + this.video = video; + } + } + + private static final class Adapter extends RecyclerView.Adapter { + interface Listener { + void onItemClick(Uri uri); + } + + private final Listener mListener; + private final List mItems = new ArrayList<>(); + private int mThumbSize; + + Adapter(final Listener listener) { + mListener = listener; + } + + void setItems(final List items) { + mItems.clear(); + mItems.addAll(items); + notifyDataSetChanged(); + } + + @NonNull + @Override + public Holder onCreateViewHolder(@NonNull final ViewGroup parent, final int viewType) { + final View view = LayoutInflater.from(parent.getContext()) + .inflate(R.layout.home_gallery_item, parent, false); + if (mThumbSize <= 0) { + mThumbSize = parent.getResources().getDisplayMetrics().widthPixels / SPAN_COUNT; + } + return new Holder(view); + } + + @Override + public void onBindViewHolder(@NonNull final Holder holder, final int position) { + holder.bind(mItems.get(position), mThumbSize, mListener); + } + + @Override + public int getItemCount() { + return mItems.size(); + } + } + + private static final class Holder extends RecyclerView.ViewHolder { + private final AsyncImageView mImage; + private final ImageView mVideoBadge; + + Holder(final View itemView) { + super(itemView); + mImage = itemView.findViewById(R.id.home_gallery_item_image); + mVideoBadge = itemView.findViewById(R.id.home_gallery_item_video); + } + + void bind(final MediaItem item, final int thumbSize, final Adapter.Listener listener) { + final ViewGroup.LayoutParams params = itemView.getLayoutParams(); + params.height = thumbSize; + itemView.setLayoutParams(params); + if (item.video) { + mImage.setImageResourceId(new MessagePartVideoThumbnailRequestDescriptor(item.uri)); + mVideoBadge.setVisibility(View.VISIBLE); + } else { + mImage.setImageResourceId(new UriImageRequestDescriptor( + item.uri, thumbSize, thumbSize, true, false, false, 0, 0)); + mVideoBadge.setVisibility(View.GONE); + } + itemView.setOnClickListener(v -> listener.onItemClick(item.uri)); + } + } + + private static final class GapDecoration extends RecyclerView.ItemDecoration { + private final int mGap; + + GapDecoration(final int gap) { + mGap = gap; + } + + @Override + public void getItemOffsets(@NonNull final android.graphics.Rect outRect, + @NonNull final View view, @NonNull final RecyclerView parent, + @NonNull final RecyclerView.State state) { + outRect.set(mGap, mGap, mGap, mGap); + } + } +} diff --git a/app/src/main/java/com/android/messaging/ui/home/HomeGroupNameActivity.java b/app/src/main/java/com/android/messaging/ui/home/HomeGroupNameActivity.java new file mode 100644 index 0000000..86e20d0 --- /dev/null +++ b/app/src/main/java/com/android/messaging/ui/home/HomeGroupNameActivity.java @@ -0,0 +1,191 @@ +/* + * 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.content.Intent; +import android.os.Bundle; +import android.text.Editable; +import android.text.TextUtils; +import android.text.TextWatcher; +import android.view.View; +import android.widget.EditText; +import android.widget.ImageButton; +import android.widget.TextView; + +import com.android.messaging.R; +import com.android.messaging.datamodel.action.ActionMonitor; +import com.android.messaging.datamodel.action.GetOrCreateConversationAction; +import com.android.messaging.datamodel.action.GetOrCreateConversationAction.GetOrCreateConversationActionListener; +import com.android.messaging.datamodel.action.GetOrCreateConversationAction.GetOrCreateConversationActionMonitor; +import com.android.messaging.datamodel.action.RenameConversationAction; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.ui.BugleActionBarActivity; +import com.android.messaging.ui.ContactIconView; +import com.android.messaging.ui.UIIntents; +import com.android.messaging.util.Assert; +import com.android.messaging.util.Assert.RunsOnMainThread; +import com.android.messaging.util.AvatarUriUtil; +import com.android.messaging.util.ImeUtil; +import com.android.messaging.util.LogUtil; + +import java.util.ArrayList; + +/** Last step of the group flow: an optional local name for the conversation. */ +public class HomeGroupNameActivity extends BugleActionBarActivity + implements GetOrCreateConversationActionListener { + + private static final String EXTRA_PARTICIPANTS = "participants"; + + public static Intent buildIntent(final Context context, + final ArrayList participants) { + final Intent intent = new Intent(context, HomeGroupNameActivity.class); + intent.putParcelableArrayListExtra(EXTRA_PARTICIPANTS, participants); + return intent; + } + + private ArrayList mParticipants; + private EditText mNameField; + private ImageButton mClearButton; + private GetOrCreateConversationActionMonitor mMonitor; + + @Override + protected void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.home_group_name_activity); + if (getSupportActionBar() != null) { + getSupportActionBar().hide(); + } + + mParticipants = getIntent().getParcelableArrayListExtra(EXTRA_PARTICIPANTS); + if (mParticipants == null || mParticipants.isEmpty()) { + finish(); + return; + } + + final ImageButton back = findViewById(R.id.home_sub_header_back); + if (back != null) { + back.setOnClickListener(v -> finish()); + } + final TextView title = findViewById(R.id.home_sub_header_title); + if (title != null) { + title.setText(R.string.home_group_name_title); + } + final View scrim = findViewById(R.id.home_group_name_scrim); + if (scrim != null) { + scrim.setOnClickListener(v -> finish()); + } + + bindAvatar(R.id.home_group_avatar_first, 0); + bindAvatar(R.id.home_group_avatar_second, 1); + + mNameField = findViewById(R.id.home_group_name_field); + mClearButton = findViewById(R.id.home_group_name_clear); + mClearButton.setOnClickListener(v -> mNameField.setText("")); + mNameField.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) { + mClearButton.setVisibility(TextUtils.isEmpty(s) ? View.GONE : View.VISIBLE); + } + }); + mNameField.setOnEditorActionListener((v, actionId, event) -> { + createConversation(); + return true; + }); + + findViewById(R.id.home_group_name_done).setOnClickListener(v -> createConversation()); + } + + private void bindAvatar(final int viewId, final int participantIndex) { + final ContactIconView view = findViewById(viewId); + if (view == null) { + return; + } + if (participantIndex >= mParticipants.size()) { + view.setVisibility(View.GONE); + return; + } + final ParticipantData participant = mParticipants.get(participantIndex); + view.setImageResourceUri(AvatarUriUtil.createAvatarUri(participant), + participant.getContactId(), participant.getLookupKey(), + participant.getNormalizedDestination()); + view.setImageClickHandlerDisabled(true); + } + + private void createConversation() { + if (mMonitor != null) { + return; + } + ImeUtil.get().hideImeKeyboard(this, mNameField); + mMonitor = GetOrCreateConversationAction.getOrCreateConversation(mParticipants, null, this); + } + + @Override + @RunsOnMainThread + public void onGetOrCreateConversationSucceeded(final ActionMonitor monitor, final Object data, + final String conversationId) { + Assert.isTrue(monitor == mMonitor); + mMonitor = null; + if (conversationId == null) { + return; + } + final String name = mNameField.getText().toString().trim(); + if (!TextUtils.isEmpty(name)) { + RenameConversationAction.renameConversation(conversationId, name); + } + UIIntents.get().launchConversationActivity(this, conversationId, null, null, false); + // Tells the compose screen underneath to close too, so back returns to the list. + setResult(RESULT_OK); + finish(); + } + + @Override + @RunsOnMainThread + public void onGetOrCreateConversationFailed(final ActionMonitor monitor, final Object data) { + Assert.isTrue(monitor == mMonitor); + LogUtil.e(LogUtil.BUGLE_TAG, "HomeGroupNameActivity onGetOrCreateConversationFailed"); + mMonitor = null; + } + + @Override + protected void onDestroy() { + super.onDestroy(); + if (mMonitor != null) { + mMonitor.unregister(); + mMonitor = null; + } + } + + @Override + public void finish() { + if (mNameField != null) { + ImeUtil.get().hideImeKeyboard(this, mNameField); + } + super.finish(); + overridePendingTransition(R.anim.home_activity_hold, R.anim.home_sheet_close); + } +} diff --git a/app/src/main/java/com/android/messaging/ui/home/HomePinnedStrip.java b/app/src/main/java/com/android/messaging/ui/home/HomePinnedStrip.java index bb1285a..30d8631 100644 --- a/app/src/main/java/com/android/messaging/ui/home/HomePinnedStrip.java +++ b/app/src/main/java/com/android/messaging/ui/home/HomePinnedStrip.java @@ -52,7 +52,11 @@ public final class HomePinnedStrip { private static final int COLUMN_COUNT = 3; public interface Listener { - void onPinnedConversationClicked(String conversationId); + /** Same as tapping a list-row avatar: show the profile popup. */ + void onPinnedProfileClicked(String conversationId, View avatarAnchor); + + /** Same as tapping the list row (name / number): open the conversation. */ + void onPinnedConversationOpen(String conversationId); void onPinnedConversationUnpin(String conversationId); } @@ -61,8 +65,7 @@ public final class HomePinnedStrip { } public static void bind(final View stripRoot, final Cursor cursor, - final List pinnedIds, final boolean editMode, final boolean selectionMode, - final Listener listener) { + final List pinnedIds, final boolean editMode, final Listener listener) { if (stripRoot == null) { return; } @@ -127,8 +130,9 @@ public final class HomePinnedStrip { } icon.setImageResourceUri(iconUri, row.getParticipantContactId(), row.getParticipantLookupKey(), row.getOtherParticipantNormalizedDestination()); + icon.setImageClickHandlerDisabled(true); label.setText(labelForPinnedRow(row, editMode)); - bindUnreadPreview(context, row, editMode, selectionMode, previewContainer, preview, + bindUnreadPreview(context, row, editMode, previewContainer, preview, unreadDot); if (editMode) { remove.setVisibility(View.VISIBLE); @@ -144,11 +148,29 @@ public final class HomePinnedStrip { } else { stopEditJiggle(avatarStack); remove.setVisibility(View.GONE); - item.setOnClickListener(v -> { + final View.OnClickListener openProfile = v -> { if (listener != null) { - listener.onPinnedConversationClicked(id); + listener.onPinnedProfileClicked(id, icon != null ? icon : avatarStack); } - }); + }; + item.setOnClickListener(null); + if (pinnedCell != null) { + pinnedCell.setOnClickListener(openProfile); + } + if (avatarStack != null) { + avatarStack.setOnClickListener(openProfile); + } + icon.setOnClickListener(openProfile); + if (previewContainer != null) { + previewContainer.setOnClickListener(openProfile); + } + if (label != null) { + label.setOnClickListener(v -> { + if (listener != null) { + listener.onPinnedConversationOpen(id); + } + }); + } } currentRow.addView(item); itemIndex++; @@ -288,7 +310,7 @@ public final class HomePinnedStrip { private static void bindUnreadPreview(final Context context, final ConversationListItemData row, final boolean editMode, - final boolean selectionMode, final View previewContainer, final TextView preview, + final View previewContainer, final TextView preview, final View unreadDot) { final boolean unread = !row.getIsRead() && !row.getShowDraft(); if (unreadDot != null) { @@ -297,7 +319,7 @@ public final class HomePinnedStrip { if (previewContainer != null) { previewContainer.setTranslationX(0f); } - if (editMode || selectionMode || previewContainer == null || preview == null) { + if (editMode || previewContainer == null || preview == null) { if (previewContainer != null) { previewContainer.setVisibility(View.GONE); } @@ -398,6 +420,12 @@ public final class HomePinnedStrip { avatarStack.setRotation(0f); } + /** Binds {@code out} from {@code cursor} when {@code conversationId} matches a row. */ + public static boolean bindConversationListItem(final Cursor cursor, + final String conversationId, final ConversationListItemData out) { + return bindRow(cursor, conversationId, out); + } + private static boolean bindRow(final Cursor cursor, final String conversationId, final ConversationListItemData out) { final int saved = cursor.getPosition(); 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 new file mode 100644 index 0000000..c289c11 --- /dev/null +++ b/app/src/main/java/com/android/messaging/ui/home/HomeProfileActivity.java @@ -0,0 +1,469 @@ +/* + * 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.app.NotificationManager; +import android.content.Intent; +import android.graphics.Point; +import android.os.Bundle; +import android.provider.Settings; +import android.text.TextUtils; +import android.view.Display; +import android.view.Gravity; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.EditText; +import android.widget.ImageButton; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.PopupMenu; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.DrawableRes; +import androidx.appcompat.app.AlertDialog; +import androidx.annotation.IdRes; +import androidx.annotation.StringRes; +import androidx.loader.app.LoaderManager; + +import com.android.messaging.R; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.action.UpdateConversationArchiveStatusAction; +import com.android.messaging.datamodel.action.RenameConversationAction; +import com.android.messaging.datamodel.binding.Binding; +import com.android.messaging.datamodel.binding.BindingBase; +import com.android.messaging.datamodel.data.ConversationListItemData; +import com.android.messaging.datamodel.data.ConversationProfileData; +import com.android.messaging.datamodel.data.ConversationProfileData.ConversationProfileDataListener; +import com.android.messaging.datamodel.data.ParticipantData; +import com.android.messaging.ui.BugleActionBarActivity; +import com.android.messaging.ui.ContactIconView; +import com.android.messaging.ui.UIIntents; +import com.android.messaging.ui.contact.AddContactsConfirmationDialog; +import com.android.messaging.ui.conversation.ConversationActivity; +import com.android.messaging.ui.conversationsettings.CopyContactDetailDialog; +import com.android.messaging.util.AvatarUriUtil; +import com.android.messaging.util.ContactUtil; +import com.android.messaging.util.NotificationsUtil; +import com.android.messaging.util.PhoneUtils; +import com.android.messaging.util.UiUtils; +import com.google.common.base.Joiner; + +import java.util.ArrayList; +import java.util.List; + +/** Home UI conversation profile: name, shortcuts, options and the people involved. */ +public class HomeProfileActivity extends BugleActionBarActivity + implements ConversationProfileDataListener { + + private final Binding mBinding = BindingBase.createBinding(this); + + private String mConversationId; + private ConversationListItemData mConversation; + private List mParticipants = new ArrayList<>(); + + private TextView mNameView; + private LinearLayout mParticipantsContainer; + private TextView mParticipantsHeader; + + @Override + protected void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.home_profile_activity); + if (getSupportActionBar() != null) { + getSupportActionBar().hide(); + } + + mConversationId = getIntent().getStringExtra(UIIntents.UI_INTENT_EXTRA_CONVERSATION_ID); + if (TextUtils.isEmpty(mConversationId)) { + finish(); + return; + } + + final ImageButton back = findViewById(R.id.home_sub_header_back); + back.setOnClickListener(v -> finish()); + final TextView headerTitle = findViewById(R.id.home_sub_header_title); + if (headerTitle != null) { + headerTitle.setVisibility(View.GONE); + } + + mNameView = findViewById(R.id.home_profile_name); + mParticipantsContainer = findViewById(R.id.home_profile_participants); + mParticipantsHeader = findViewById(R.id.home_profile_participants_header); + findViewById(R.id.home_profile_rename).setOnClickListener(v -> showRenameDialog()); + + bindQuickAction(R.id.home_profile_action_call, R.drawable.ic_phone_small_light, + R.string.home_profile_action_call, v -> onPrimaryQuickAction()); + bindQuickAction(R.id.home_profile_action_add_contact, R.drawable.ic_home_group_add, + R.string.home_profile_action_add_contact, v -> onSecondaryQuickAction()); + bindQuickAction(R.id.home_profile_action_search, R.drawable.ic_home_search_line, + R.string.home_profile_action_search, + v -> UIIntents.get().launchHomeConversationSearchActivity(this, mConversationId)); + + mBinding.bind(DataModel.get().createConversationProfileData(mConversationId, this, this)); + mBinding.getData().init(LoaderManager.getInstance(this), mBinding); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + mBinding.unbind(); + } + + @Override + public void onConversationMetadataUpdated(final ConversationProfileData data, + final ConversationListItemData conversation) { + mBinding.ensureBound(data); + mConversation = conversation; + updateViews(); + } + + @Override + public void onParticipantsListLoaded(final ConversationProfileData data, + final List participants) { + mBinding.ensureBound(data); + mParticipants = participants; + updateViews(); + } + + private void updateViews() { + if (mConversation == null) { + return; + } + updateAvatars(); + updateName(); + updateQuickActions(); + updateOptions(); + updateParticipants(); + } + + private void updateAvatars() { + final View stack = findViewById(R.id.home_profile_avatar_stack); + final ContactIconView single = findViewById(R.id.home_profile_avatar_single); + final boolean isGroup = mParticipants.size() > 1; + stack.setVisibility(isGroup ? View.VISIBLE : View.GONE); + single.setVisibility(isGroup ? View.GONE : View.VISIBLE); + if (isGroup) { + bindAvatar(R.id.home_profile_avatar_first, 0); + bindAvatar(R.id.home_profile_avatar_second, 1); + } else { + bindAvatar(R.id.home_profile_avatar_single, 0); + } + } + + private void bindAvatar(@IdRes final int viewId, final int index) { + final ContactIconView view = findViewById(viewId); + if (index >= mParticipants.size()) { + view.setVisibility(View.GONE); + return; + } + final ParticipantData participant = mParticipants.get(index); + view.setImageResourceUri(AvatarUriUtil.createAvatarUri(participant), + participant.getContactId(), participant.getLookupKey(), + participant.getNormalizedDestination()); + view.setImageClickHandlerDisabled(true); + } + + private void updateName() { + String name = mConversation.getName(); + if (TextUtils.isEmpty(name) && !mParticipants.isEmpty()) { + final List names = new ArrayList<>(mParticipants.size()); + for (final ParticipantData participant : mParticipants) { + names.add(participant.getDisplayName(mParticipants.size() == 1)); + } + name = Joiner.on(", ").join(names); + } + mNameView.setText(name); + } + + private void updateQuickActions() { + final boolean isGroup = mParticipants.size() > 1; + final View primary = findViewById(R.id.home_profile_action_call); + final View secondary = findViewById(R.id.home_profile_action_add_contact); + if (isGroup) { + bindQuickAction(R.id.home_profile_action_call, R.drawable.ic_home_video_line, + R.string.home_profile_action_video, v -> onPrimaryQuickAction()); + bindQuickAction(R.id.home_profile_action_add_contact, R.drawable.ic_home_group_add, + R.string.home_profile_action_add_people, v -> onSecondaryQuickAction()); + primary.setVisibility(View.VISIBLE); + secondary.setVisibility(View.VISIBLE); + } else { + bindQuickAction(R.id.home_profile_action_call, R.drawable.ic_phone_small_light, + R.string.home_profile_action_call, v -> onPrimaryQuickAction()); + bindQuickAction(R.id.home_profile_action_add_contact, R.drawable.ic_home_group_add, + R.string.home_profile_action_add_contact, v -> onSecondaryQuickAction()); + primary.setVisibility(getCallablePhoneNumber() == null ? View.GONE : View.VISIBLE); + final ParticipantData other = getOtherParticipant(); + final boolean canAddContact = other != null + && !ContactUtil.isParticipantInAddressBook(this, + other.getContactId(), other.getLookupKey(), + other.getNormalizedDestination()); + secondary.setVisibility(canAddContact ? View.VISIBLE : View.GONE); + } + } + + private void onPrimaryQuickAction() { + if (mParticipants.size() > 1) { + Toast.makeText(this, R.string.home_popover_coming_soon, Toast.LENGTH_SHORT).show(); + return; + } + placeCall(); + } + + private void onSecondaryQuickAction() { + if (mParticipants.size() > 1) { + Toast.makeText(this, R.string.home_popover_coming_soon, Toast.LENGTH_SHORT).show(); + return; + } + addContact(); + } + + private void updateOptions() { + bindOption(R.id.home_profile_option_notifications, R.drawable.ic_home_bell, + R.string.home_profile_option_notifications, false, v -> openNotificationSettings()); + + final boolean archived = mConversation.getIsArchived(); + bindOption(R.id.home_profile_option_archive, R.drawable.ic_archive_small_dark, + archived ? R.string.home_profile_option_unarchive + : R.string.home_profile_option_archive, + false, v -> toggleArchived(archived)); + + final ParticipantData other = getOtherParticipant(); + final View spamRow = findViewById(R.id.home_profile_option_spam); + if (other == null) { + spamRow.setVisibility(View.GONE); + } else { + spamRow.setVisibility(View.VISIBLE); + final boolean blocked = other.isBlocked(); + bindOption(R.id.home_profile_option_spam, R.drawable.ic_home_menu_spam, + blocked ? R.string.home_profile_option_unblock + : R.string.home_profile_option_spam, + !blocked, v -> toggleBlocked(other, blocked)); + } + + bindOption(R.id.home_profile_option_delete, R.drawable.ic_home_menu_trash, + R.string.home_profile_option_delete, true, v -> confirmDelete()); + } + + private void updateParticipants() { + final int count = mParticipants.size(); + mParticipantsHeader.setText(count == 1 + ? getString(R.string.home_profile_participants_one) + : getString(R.string.home_profile_participants_many, count)); + + final TextView addPeople = findViewById(R.id.home_profile_participants_add); + if (addPeople != null) { + final boolean isGroup = count > 1; + addPeople.setVisibility(isGroup ? View.VISIBLE : View.GONE); + addPeople.setOnClickListener(isGroup ? v -> Toast.makeText(this, + R.string.home_popover_coming_soon, Toast.LENGTH_SHORT).show() : null); + } + + mParticipantsContainer.removeAllViews(); + final LayoutInflater inflater = LayoutInflater.from(this); + for (final ParticipantData participant : mParticipants) { + final View row = inflater.inflate(R.layout.home_profile_participant_row, + mParticipantsContainer, false); + final ContactIconView avatar = row.findViewById(R.id.home_profile_participant_avatar); + avatar.setImageClickHandlerDisabled(true); + avatar.setImageResourceUri(AvatarUriUtil.createAvatarUri(participant), + participant.getContactId(), participant.getLookupKey(), + participant.getNormalizedDestination()); + ((TextView) row.findViewById(R.id.home_profile_participant_name)) + .setText(participant.getDisplayName(true)); + ((TextView) row.findViewById(R.id.home_profile_participant_details)) + .setText(participant.getDisplayDestination()); + row.setOnClickListener(v -> ContactUtil.openContactDetail(this, + participant.getContactId(), participant.getLookupKey(), + participant.getNormalizedDestination())); + row.findViewById(R.id.home_profile_participant_menu) + .setOnClickListener(v -> showParticipantMenu(v, participant)); + mParticipantsContainer.addView(row); + } + } + + private void bindQuickAction(@IdRes final int rootId, @DrawableRes final int iconRes, + @StringRes final int labelRes, final View.OnClickListener listener) { + final View root = findViewById(rootId); + final ImageView icon = root.findViewById(R.id.home_profile_quick_action_icon); + icon.setImageResource(iconRes); + // The shared icon set mixes white and black artwork, so always force the tint. + icon.setImageTintList(getColorStateList(R.color.home_text_primary)); + ((TextView) root.findViewById(R.id.home_profile_quick_action_label)).setText(labelRes); + root.findViewById(R.id.home_profile_quick_action_button).setOnClickListener(listener); + } + + private void bindOption(@IdRes final int rootId, @DrawableRes final int iconRes, + @StringRes final int labelRes, final boolean destructive, + final View.OnClickListener listener) { + final View root = findViewById(rootId); + final ImageView icon = root.findViewById(R.id.home_profile_option_icon); + final TextView label = root.findViewById(R.id.home_profile_option_label); + icon.setImageResource(iconRes); + icon.setImageTintList(getColorStateList(destructive + ? R.color.home_destructive : R.color.home_text_primary)); + label.setText(labelRes); + label.setTextColor(getColor(destructive + ? R.color.home_destructive : R.color.home_text_primary)); + root.setOnClickListener(listener); + } + + private ParticipantData getOtherParticipant() { + return mBinding.isBound() ? mBinding.getData().getOtherParticipant() : null; + } + + private String getCallablePhoneNumber() { + if (!PhoneUtils.getDefault().isVoiceCapable()) { + return null; + } + final ParticipantData other = getOtherParticipant(); + if (other == null || other.isEmail()) { + return null; + } + final String number = other.getSendDestination(); + return TextUtils.isEmpty(number) ? null : number; + } + + private void placeCall() { + final String number = getCallablePhoneNumber(); + if (number == null) { + return; + } + final Display display = getWindowManager().getDefaultDisplay(); + UIIntents.get().launchPhoneCallActivity(this, number, + new Point(display.getWidth() / 2, display.getHeight() / 2)); + } + + private void addContact() { + final ParticipantData other = getOtherParticipant(); + if (other == null) { + return; + } + new AddContactsConfirmationDialog(this, AvatarUriUtil.createAvatarUri(other), + other.getNormalizedDestination()).show(); + } + + private void showParticipantMenu(final View anchor, final ParticipantData participant) { + // Anchored to the trailing edge so the popup stays on screen. + final PopupMenu menu = new PopupMenu(this, anchor, Gravity.END); + menu.getMenu().add(R.string.home_profile_participant_copy) + .setOnMenuItemClickListener(item -> { + new CopyContactDetailDialog(this, + participant.getDisplayDestination()).show(); + return true; + }); + if (TextUtils.isEmpty(participant.getLookupKey())) { + menu.getMenu().add(R.string.home_profile_participant_add) + .setOnMenuItemClickListener(item -> { + new AddContactsConfirmationDialog(this, + AvatarUriUtil.createAvatarUri(participant), + participant.getNormalizedDestination()).show(); + return true; + }); + } + menu.show(); + } + + private void showRenameDialog() { + final View content = LayoutInflater.from(this) + .inflate(R.layout.home_profile_rename_dialog, null); + 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 String name = field.getText().toString().trim(); + // An empty name would hide the conversation everywhere it is listed. + if (!TextUtils.isEmpty(name)) { + RenameConversationAction.renameConversation(mConversationId, name); + } + }) + .show(); + } + + private void openNotificationSettings() { + final List names = new ArrayList<>(mParticipants.size()); + for (final ParticipantData participant : mParticipants) { + names.add(participant.getDisplayName(true)); + } + NotificationsUtil.createNotificationChannelGroup(this, + NotificationsUtil.CONVERSATION_GROUP_NAME, + R.string.notification_channel_messages_title); + NotificationsUtil.createNotificationChannel(this, mConversationId, + String.join(", ", names), NotificationManager.IMPORTANCE_HIGH, + NotificationsUtil.CONVERSATION_GROUP_NAME); + final Intent intent = new Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS); + intent.putExtra(Settings.EXTRA_APP_PACKAGE, getPackageName()); + startActivity(intent); + } + + private void toggleArchived(final boolean archived) { + if (archived) { + UpdateConversationArchiveStatusAction.unarchiveConversation(mConversationId); + UiUtils.showToast(R.string.home_profile_unarchived_toast); + } else { + UpdateConversationArchiveStatusAction.archiveConversation(mConversationId); + UiUtils.showToast(R.string.home_profile_archived_toast); + } + } + + private void toggleBlocked(final ParticipantData other, final boolean blocked) { + if (blocked) { + mBinding.getData().setDestinationBlocked(mBinding, false); + return; + } + new AlertDialog.Builder(this, R.style.Home_Dialog) + .setTitle(getString(R.string.block_confirmation_title, + other.getDisplayDestination())) + .setMessage(R.string.block_confirmation_message) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(android.R.string.ok, (dialog, which) -> { + mBinding.getData().setDestinationBlocked(mBinding, true); + closeConversation(); + }) + .show(); + } + + 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(); + }) + .show(); + } + + /** Tells the conversation behind this screen to close as well. */ + private void closeConversation() { + setResult(ConversationActivity.FINISH_RESULT_CODE); + finish(); + } + + @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/HomeSearchActivity.java b/app/src/main/java/com/android/messaging/ui/home/HomeSearchActivity.java new file mode 100644 index 0000000..d3e5a12 --- /dev/null +++ b/app/src/main/java/com/android/messaging/ui/home/HomeSearchActivity.java @@ -0,0 +1,147 @@ +/* + * 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.os.Bundle; +import android.text.Editable; +import android.text.TextUtils; +import android.text.TextWatcher; +import android.view.View; +import android.widget.EditText; +import android.widget.ImageButton; +import android.widget.ImageView; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.DrawableRes; +import androidx.annotation.IdRes; +import androidx.annotation.StringRes; + +import com.android.messaging.R; +import com.android.messaging.ui.BugleActionBarActivity; +import com.android.messaging.util.ImeUtil; + +/** Home-style message search entry point with quick filter tiles. */ +public class HomeSearchActivity extends BugleActionBarActivity { + + private EditText mSearchField; + private ImageButton mClearButton; + + @Override + protected void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.home_search_activity); + if (getSupportActionBar() != null) { + getSupportActionBar().hide(); + } + + HomeSubHeader.bind(this, R.string.home_search_title, v -> finish()); + + mSearchField = findViewById(R.id.home_search_field); + mClearButton = findViewById(R.id.home_search_clear); + if (mClearButton != null) { + mClearButton.setOnClickListener(v -> mSearchField.setText("")); + } + if (mSearchField != null) { + mSearchField.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) { + updateClearButton(); + } + }); + mSearchField.setOnEditorActionListener((v, actionId, event) -> { + submitSearch(); + return true; + }); + mSearchField.requestFocus(); + mSearchField.post(() -> ImeUtil.get().showImeKeyboard(this, mSearchField)); + } + updateClearButton(); + + bindFilter(R.id.home_search_filter_unread, R.drawable.ic_home_menu_unread, + R.string.home_search_filter_unread); + bindFilter(R.id.home_search_filter_saved, R.drawable.ic_home_menu_person, + R.string.home_search_filter_saved); + bindFilter(R.id.home_search_filter_unknown, R.drawable.ic_home_menu_spam, + R.string.home_search_filter_unknown); + bindFilter(R.id.home_search_filter_starred, R.drawable.ic_home_star_line, + R.string.home_search_filter_starred); + bindFilter(R.id.home_search_filter_images, R.drawable.ic_home_image_line, + R.string.home_search_filter_images); + bindFilter(R.id.home_search_filter_videos, R.drawable.ic_home_video_line, + R.string.home_search_filter_videos); + bindFilter(R.id.home_search_filter_places, R.drawable.ic_home_place_line, + R.string.home_search_filter_places); + bindFilter(R.id.home_search_filter_links, R.drawable.ic_home_link_line, + R.string.home_search_filter_links); + } + + private void bindFilter(@IdRes final int tileId, @DrawableRes final int iconRes, + @StringRes final int labelRes) { + final View tile = findViewById(tileId); + if (tile == null) { + return; + } + final ImageView icon = tile.findViewById(R.id.home_search_filter_icon); + final TextView label = tile.findViewById(R.id.home_search_filter_label); + if (icon != null) { + icon.setImageResource(iconRes); + } + if (label != null) { + label.setText(labelRes); + } + tile.setOnClickListener(v -> Toast.makeText(this, + getString(R.string.home_search_coming_soon, getString(labelRes)), + Toast.LENGTH_SHORT).show()); + } + + private void updateClearButton() { + if (mClearButton == null || mSearchField == null) { + return; + } + mClearButton.setVisibility( + TextUtils.isEmpty(mSearchField.getText()) ? View.GONE : View.VISIBLE); + } + + private void submitSearch() { + if (mSearchField == null || TextUtils.isEmpty(mSearchField.getText())) { + return; + } + ImeUtil.get().hideImeKeyboard(this, mSearchField); + Toast.makeText(this, getString(R.string.home_search_coming_soon, + mSearchField.getText().toString()), Toast.LENGTH_SHORT).show(); + } + + @Override + public void finish() { + if (mSearchField != null) { + ImeUtil.get().hideImeKeyboard(this, mSearchField); + } + 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/HomeSpamActivity.java b/app/src/main/java/com/android/messaging/ui/home/HomeSpamActivity.java new file mode 100644 index 0000000..48ce7c4 --- /dev/null +++ b/app/src/main/java/com/android/messaging/ui/home/HomeSpamActivity.java @@ -0,0 +1,52 @@ +/* + * 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.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 { + + @Override + protected void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.home_spam_activity); + if (getSupportActionBar() != null) { + getSupportActionBar().hide(); + } + + final ImageButton back = findViewById(R.id.home_sub_header_back); + if (back != null) { + back.setOnClickListener(v -> finish()); + } + final TextView title = findViewById(R.id.home_sub_header_title); + if (title != null) { + title.setText(R.string.home_spam_title); + } + } + + @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/HomeSpamFragment.java b/app/src/main/java/com/android/messaging/ui/home/HomeSpamFragment.java new file mode 100644 index 0000000..9949c0f --- /dev/null +++ b/app/src/main/java/com/android/messaging/ui/home/HomeSpamFragment.java @@ -0,0 +1,110 @@ +/* + * 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.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ListView; +import android.widget.TextView; +import androidx.cursoradapter.widget.CursorAdapter; +import androidx.fragment.app.Fragment; +import androidx.loader.app.LoaderManager; + +import com.android.messaging.R; +import com.android.messaging.datamodel.DataModel; +import com.android.messaging.datamodel.binding.Binding; +import com.android.messaging.datamodel.binding.BindingBase; +import com.android.messaging.datamodel.data.BlockedParticipantsData; +import com.android.messaging.datamodel.data.BlockedParticipantsData.BlockedParticipantsDataListener; +import com.android.messaging.ui.BlockedParticipantListItemView; +import com.android.messaging.util.Assert; + +/** Home UI list of spam-registered (blocked) senders with unblock on tap. */ +public class HomeSpamFragment extends Fragment implements BlockedParticipantsDataListener { + + private ListView mListView; + private TextView mEmptyView; + private BlockedParticipantListAdapter mAdapter; + 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); + 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 TextView) { + mEmptyView = (TextView) activityRoot; + } + return root; + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + mEmptyView = null; + mListView = null; + } + + @Override + public void onDestroy() { + super.onDestroy(); + mBinding.unbind(); + } + + @Override + public void onBlockedParticipantsCursorUpdated(final Cursor cursor) { + mAdapter.swapCursor(cursor); + final boolean empty = mAdapter.getCount() == 0; + if (mListView != null) { + mListView.setVisibility(empty ? View.GONE : View.VISIBLE); + } + if (mEmptyView != null) { + mEmptyView.setVisibility(empty ? View.VISIBLE : View.GONE); + } + } + + private class BlockedParticipantListAdapter extends CursorAdapter { + + BlockedParticipantListAdapter(final Context context, final Cursor cursor) { + super(context, cursor, 0); + } + + @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); + } + + @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)); + } + } +} diff --git a/app/src/main/java/com/android/messaging/ui/home/HomeSubHeader.java b/app/src/main/java/com/android/messaging/ui/home/HomeSubHeader.java new file mode 100644 index 0000000..3c04bb7 --- /dev/null +++ b/app/src/main/java/com/android/messaging/ui/home/HomeSubHeader.java @@ -0,0 +1,58 @@ +/* + * 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.app.Activity; +import android.view.View; +import android.widget.ImageButton; +import android.widget.TextView; + +import androidx.annotation.StringRes; + +import com.android.messaging.R; + +/** Binds the shared {@code home_sub_header} back control and title on sub-screens. */ +public final class HomeSubHeader { + + private HomeSubHeader() { + } + + public static void bind(final Activity activity, @StringRes final int titleResId, + final View.OnClickListener onBack) { + bind(activity, activity.getString(titleResId), onBack); + } + + public static void bind(final Activity activity, final CharSequence title, + final View.OnClickListener onBack) { + final ImageButton back = activity.findViewById(R.id.home_sub_header_back); + if (back != null) { + back.setOnClickListener(onBack); + } + final TextView headerTitle = activity.findViewById(R.id.home_sub_header_title); + if (headerTitle != null && title != null) { + headerTitle.setText(title); + } + } + + public static void bindBackOnly(final Activity activity, + final View.OnClickListener onBack) { + final ImageButton back = activity.findViewById(R.id.home_sub_header_back); + if (back != null) { + back.setOnClickListener(onBack); + } + } +} 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 new file mode 100644 index 0000000..e6d9f46 --- /dev/null +++ b/app/src/main/java/com/android/messaging/ui/home/HomeUiMotion.java @@ -0,0 +1,823 @@ +/* + * 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.animation.ValueAnimator; +import android.view.View; +import android.view.ViewGroup; +import android.view.View.MeasureSpec; +import android.view.animation.PathInterpolator; +import android.widget.ImageButton; + +import androidx.interpolator.view.animation.FastOutSlowInInterpolator; + +import com.android.messaging.R; + +import java.util.concurrent.atomic.AtomicBoolean; + +/** Lightweight motion for Looply home chrome (popovers, selection mode, dialogs). */ +public final class HomeUiMotion { + + private static final int POPOVER_IN_MS = 280; + private static final int POPOVER_OUT_MS = 200; + private static final int DOCK_SWAP_MS = 220; + private static final int PROFILE_PANEL_MS = 320; + private static final int UNKNOWN_SENDER_BANNER_MS = 340; + private static final int LIST_PROFILE_POPUP_MS = 360; + + private static final PathInterpolator EASE = new PathInterpolator(0.25f, 0.1f, 0.25f, 1f); + private static final FastOutSlowInInterpolator EASE_MATERIAL = + new FastOutSlowInInterpolator(); + + private HomeUiMotion() { + } + + public static boolean enabled() { + return ValueAnimator.areAnimatorsEnabled(); + } + + public static void popoverEnter(final View content, final boolean pivotEnd) { + if (content == null) { + return; + } + if (!enabled()) { + resetPopoverTransform(content); + return; + } + content.setAlpha(0f); + content.setScaleX(0.96f); + content.setScaleY(0.96f); + beginLayer(content); + content.post(() -> { + content.setPivotX(pivotEnd ? content.getWidth() * 0.9f : content.getWidth() * 0.1f); + content.setPivotY(0f); + content.animate().cancel(); + content.animate() + .alpha(1f) + .scaleX(1f) + .scaleY(1f) + .setDuration(POPOVER_IN_MS) + .setInterpolator(EASE) + .withEndAction(() -> endLayer(content)) + .start(); + }); + } + + public static void popoverExit(final View content, final Runnable endAction) { + if (content == null) { + if (endAction != null) { + endAction.run(); + } + return; + } + if (!enabled()) { + if (endAction != null) { + endAction.run(); + } + return; + } + beginLayer(content); + content.animate().cancel(); + content.animate() + .alpha(0f) + .scaleX(0.97f) + .scaleY(0.97f) + .setDuration(POPOVER_OUT_MS) + .setInterpolator(EASE) + .withEndAction(() -> { + endLayer(content); + resetPopoverTransform(content); + if (endAction != null) { + endAction.run(); + } + }) + .start(); + } + + /** + * Swaps two headers that share a vertical container, so they are never both laid out. + */ + public static void replaceHeaderInstant(final View outgoing, final View incoming) { + if (incoming != null) { + incoming.animate().cancel(); + incoming.setVisibility(View.VISIBLE); + incoming.setAlpha(1f); + incoming.setTranslationY(0f); + } + if (outgoing != null) { + outgoing.animate().cancel(); + outgoing.setVisibility(View.GONE); + outgoing.setAlpha(1f); + outgoing.setTranslationY(0f); + } + } + + /** + * Slides the in-conversation profile sheet down over the compact header (and reverses on close). + */ + public static void conversationProfilePanel(final View compactHeader, final View expandedPanel, + final View chevron, final boolean expand, final Runnable onEnd) { + final Runnable finish = once(onEnd); + if (compactHeader == null && expandedPanel == null) { + finish.run(); + return; + } + if (compactHeader != null) { + compactHeader.animate().cancel(); + } + if (expandedPanel != null) { + expandedPanel.animate().cancel(); + } + if (chevron != null) { + chevron.animate().cancel(); + } + + if (!enabled()) { + if (expand) { + replaceHeaderInstant(compactHeader, expandedPanel); + if (chevron != null) { + chevron.setRotation(180f); + } + } else { + replaceHeaderInstant(expandedPanel, compactHeader); + if (chevron != null) { + chevron.setRotation(0f); + } + } + finish.run(); + return; + } + + if (expand) { + if (expandedPanel != null) { + expandedPanel.setVisibility(View.VISIBLE); + expandedPanel.setAlpha(0f); + expandedPanel.setTranslationY(-24f); + beginLayer(expandedPanel); + } + if (compactHeader != null) { + beginLayer(compactHeader); + } + if (chevron != null) { + chevron.animate() + .rotation(180f) + .setDuration(PROFILE_PANEL_MS) + .setInterpolator(EASE_MATERIAL) + .start(); + } + if (compactHeader != null) { + compactHeader.animate() + .alpha(0f) + .translationY(-12f) + .setDuration(PROFILE_PANEL_MS) + .setInterpolator(EASE_MATERIAL) + .withEndAction(() -> { + compactHeader.setVisibility(View.GONE); + compactHeader.setAlpha(1f); + compactHeader.setTranslationY(0f); + endLayer(compactHeader); + }) + .start(); + } + if (expandedPanel != null) { + expandedPanel.post(() -> expandedPanel.animate() + .alpha(1f) + .translationY(0f) + .setDuration(PROFILE_PANEL_MS) + .setInterpolator(EASE_MATERIAL) + .withEndAction(() -> { + endLayer(expandedPanel); + finish.run(); + }) + .start()); + } else { + finish.run(); + } + } else { + final boolean hasCompactHeader = compactHeader != null; + if (expandedPanel != null) { + beginLayer(expandedPanel); + expandedPanel.animate() + .alpha(0f) + .translationY(-24f) + .setDuration(PROFILE_PANEL_MS) + .setInterpolator(EASE_MATERIAL) + .withEndAction(() -> { + expandedPanel.setVisibility(View.GONE); + expandedPanel.setAlpha(1f); + expandedPanel.setTranslationY(0f); + endLayer(expandedPanel); + if (!hasCompactHeader) { + finish.run(); + } + }) + .start(); + } + if (chevron != null) { + chevron.animate() + .rotation(0f) + .setDuration(PROFILE_PANEL_MS) + .setInterpolator(EASE_MATERIAL) + .start(); + } + if (compactHeader != null) { + compactHeader.setVisibility(View.VISIBLE); + compactHeader.setAlpha(0f); + compactHeader.setTranslationY(-12f); + beginLayer(compactHeader); + compactHeader.animate() + .alpha(1f) + .translationY(0f) + .setDuration(PROFILE_PANEL_MS) + .setInterpolator(EASE_MATERIAL) + .withEndAction(() -> { + endLayer(compactHeader); + finish.run(); + }) + .start(); + } else if (expandedPanel == null) { + finish.run(); + } + } + } + + /** 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) { + if (mainNav == null && selectionBar == null) { + if (onEnd != null) { + onEnd.run(); + } + return; + } + if (!enabled()) { + applyBottomDockStateInstant(mainNav, selectionBar, selectionActive); + if (onEnd != null) { + onEnd.run(); + } + return; + } + if (mainNav != null) { + mainNav.animate().cancel(); + } + if (selectionBar != null) { + selectionBar.animate().cancel(); + } + + final Runnable finish = once(onEnd); + + if (selectionActive) { + if (mainNav != null && mainNav.getVisibility() == View.VISIBLE) { + mainNav.animate() + .alpha(0f) + .setDuration(DOCK_SWAP_MS) + .setInterpolator(EASE_MATERIAL) + .withEndAction(() -> { + mainNav.setVisibility(View.GONE); + mainNav.setAlpha(1f); + mainNav.setTranslationY(0f); + }) + .start(); + } else if (mainNav != null) { + mainNav.setVisibility(View.GONE); + } + if (selectionBar != null) { + selectionBar.setVisibility(View.VISIBLE); + selectionBar.setAlpha(0f); + selectionBar.setTranslationY(0f); + selectionBar.animate() + .alpha(1f) + .setDuration(DOCK_SWAP_MS) + .setInterpolator(EASE_MATERIAL) + .withEndAction(finish) + .start(); + } else { + finish.run(); + } + } else { + if (selectionBar != null && selectionBar.getVisibility() == View.VISIBLE) { + selectionBar.animate() + .alpha(0f) + .setDuration(DOCK_SWAP_MS) + .setInterpolator(EASE_MATERIAL) + .withEndAction(() -> { + selectionBar.setVisibility(View.GONE); + selectionBar.setAlpha(1f); + selectionBar.setTranslationY(0f); + }) + .start(); + } else if (selectionBar != null) { + selectionBar.setVisibility(View.GONE); + } + if (mainNav != null) { + mainNav.setVisibility(View.VISIBLE); + mainNav.setAlpha(0f); + mainNav.setTranslationY(0f); + mainNav.animate() + .alpha(1f) + .setDuration(DOCK_SWAP_MS) + .setInterpolator(EASE_MATERIAL) + .withEndAction(finish) + .start(); + } else { + finish.run(); + } + } + } + + /** Slides the unknown-sender save banner down into the message list (Google Messages). */ + /** Home list avatar popup: expands from the tapped avatar anchor. */ + public static void listProfilePopupEnter(final View scrim, final View content, + final View anchor) { + if (content == null) { + return; + } + if (!enabled()) { + if (scrim != null) { + scrim.setAlpha(1f); + } + content.setAlpha(1f); + content.setTranslationY(0f); + content.setScaleX(1f); + content.setScaleY(1f); + return; + } + if (scrim != null) { + scrim.setAlpha(0f); + scrim.animate().alpha(1f).setDuration(LIST_PROFILE_POPUP_MS) + .setInterpolator(EASE_MATERIAL).start(); + } + content.animate().cancel(); + content.setTranslationY(0f); + content.setAlpha(0f); + content.setScaleX(0.92f); + content.setScaleY(0.92f); + applyListProfilePopupPivot(content, anchor); + beginLayer(content); + content.animate() + .alpha(1f) + .scaleX(1f) + .scaleY(1f) + .setDuration(LIST_PROFILE_POPUP_MS) + .setInterpolator(EASE_MATERIAL) + .withEndAction(() -> endLayer(content)) + .start(); + } + + private static void applyListProfilePopupPivot(final View content, final View anchor) { + if (anchor == null) { + content.setPivotX(content.getWidth() * 0.5f); + content.setPivotY(0f); + return; + } + final int[] anchorOnScreen = new int[2]; + final int[] contentOnScreen = new int[2]; + anchor.getLocationOnScreen(anchorOnScreen); + content.getLocationOnScreen(contentOnScreen); + content.setPivotX(anchorOnScreen[0] + anchor.getWidth() * 0.5f - contentOnScreen[0]); + content.setPivotY(anchorOnScreen[1] + anchor.getHeight() * 0.5f - contentOnScreen[1]); + } + + public static void listProfilePopupExit(final View scrim, final View content, + final Runnable endAction) { + if (content == null) { + if (endAction != null) { + endAction.run(); + } + return; + } + if (!enabled()) { + if (endAction != null) { + endAction.run(); + } + return; + } + content.animate().cancel(); + if (scrim != null) { + scrim.animate().alpha(0f).setDuration(LIST_PROFILE_POPUP_MS * 2 / 3) + .setInterpolator(EASE_MATERIAL).start(); + } + beginLayer(content); + content.animate() + .alpha(0f) + .translationY(-24f) + .scaleX(0.96f) + .scaleY(0.96f) + .setDuration(LIST_PROFILE_POPUP_MS * 2 / 3) + .setInterpolator(EASE_MATERIAL) + .withEndAction(() -> { + content.setTranslationY(0f); + content.setScaleX(1f); + content.setScaleY(1f); + content.setAlpha(1f); + endLayer(content); + if (endAction != null) { + endAction.run(); + } + }) + .start(); + } + + public static void unknownSenderBannerEnter(final View banner) { + if (banner == null) { + return; + } + if (!enabled()) { + banner.setVisibility(View.VISIBLE); + banner.setAlpha(1f); + banner.setTranslationY(0f); + return; + } + banner.animate().cancel(); + banner.setVisibility(View.VISIBLE); + banner.setAlpha(0f); + banner.post(() -> { + final int height = banner.getHeight(); + final float startY = -(height > 0 ? height : banner.getResources() + .getDimensionPixelSize(R.dimen.home_unknown_sender_banner_slide)); + banner.setTranslationY(startY); + beginLayer(banner); + banner.animate() + .alpha(1f) + .translationY(0f) + .setDuration(UNKNOWN_SENDER_BANNER_MS) + .setInterpolator(EASE_MATERIAL) + .withEndAction(() -> endLayer(banner)) + .start(); + }); + } + + public static void unknownSenderBannerHide(final View banner, final Runnable endAction) { + if (banner == null) { + if (endAction != null) { + endAction.run(); + } + return; + } + if (!enabled() || banner.getVisibility() != View.VISIBLE) { + if (endAction != null) { + endAction.run(); + } + return; + } + banner.animate().cancel(); + beginLayer(banner); + final int height = banner.getHeight(); + final float endY = -(height > 0 ? height : banner.getResources() + .getDimensionPixelSize(R.dimen.home_unknown_sender_banner_slide)); + banner.animate() + .alpha(0f) + .translationY(endY) + .setDuration(UNKNOWN_SENDER_BANNER_MS * 2 / 3) + .setInterpolator(EASE_MATERIAL) + .withEndAction(() -> { + banner.setVisibility(View.GONE); + banner.setAlpha(1f); + banner.setTranslationY(0f); + endLayer(banner); + if (endAction != null) { + endAction.run(); + } + }) + .start(); + } + + public static void dialogEnter(final View content) { + if (content == null) { + return; + } + if (!enabled()) { + content.setAlpha(1f); + content.setScaleX(1f); + content.setScaleY(1f); + return; + } + content.setAlpha(0f); + content.setScaleX(0.94f); + content.setScaleY(0.94f); + beginLayer(content); + content.animate() + .alpha(1f) + .scaleX(1f) + .scaleY(1f) + .setDuration(300) + .setInterpolator(EASE) + .withEndAction(() -> endLayer(content)) + .start(); + } + + private static void applyBottomDockStateInstant(final View mainNav, final View selectionBar, + final boolean selectionActive) { + if (mainNav != null) { + mainNav.setVisibility(selectionActive ? View.GONE : View.VISIBLE); + mainNav.setAlpha(1f); + mainNav.setTranslationY(0f); + } + if (selectionBar != null) { + selectionBar.setVisibility(selectionActive ? View.VISIBLE : View.GONE); + selectionBar.setAlpha(1f); + selectionBar.setTranslationY(0f); + } + } + + public static void popoverResetInstant(final View content) { + if (content == null) { + return; + } + content.animate().cancel(); + resetPopoverTransform(content); + content.setLayerType(View.LAYER_TYPE_NONE, null); + } + + private static void resetPopoverTransform(final View content) { + content.setAlpha(1f); + content.setScaleX(1f); + content.setScaleY(1f); + } + + private static void beginLayer(final View view) { + view.setLayerType(View.LAYER_TYPE_HARDWARE, null); + } + + private static void endLayer(final View view) { + view.setLayerType(View.LAYER_TYPE_NONE, null); + } + + private static Runnable once(final Runnable delegate) { + if (delegate == null) { + return () -> { }; + } + final AtomicBoolean fired = new AtomicBoolean(false); + return () -> { + if (fired.compareAndSet(false, true)) { + delegate.run(); + } + }; + } + + private static final int COMPOSE_ATTACH_SHEET_MS = 320; + private static ValueAnimator sComposeAttachHeightAnimator; + + /** Expands sheet height so the compose bar is pushed up with the grid (reference Messages UI). */ + public static void composeAttachmentSheetOpen(final View sheet, + final ImageButton attachButton) { + if (sheet == null) { + return; + } + cancelComposeAttachHeightAnimator(); + sheet.animate().cancel(); + if (attachButton != null) { + attachButton.animate().cancel(); + } + if (!enabled()) { + setComposeAttachSheetExpanded(sheet, true); + applyComposeAttachCloseIcon(attachButton); + return; + } + sheet.setVisibility(View.VISIBLE); + sheet.post(() -> { + final int targetHeight = measureComposeAttachSheetHeight(sheet); + animateComposeAttachSheetHeight(sheet, 0, targetHeight, () -> { + setComposeAttachSheetExpanded(sheet, true); + }); + }); + animateComposeAttachOpen(attachButton); + } + + /** Collapses sheet height and restores the + icon. */ + public static void composeAttachmentSheetClose(final View sheet, final ImageButton attachButton, + final Runnable endAction) { + if (sheet == null) { + if (endAction != null) { + endAction.run(); + } + return; + } + cancelComposeAttachHeightAnimator(); + sheet.animate().cancel(); + if (attachButton != null) { + attachButton.animate().cancel(); + } + if (!enabled()) { + setComposeAttachSheetExpanded(sheet, false); + applyComposeAttachOpenIcon(attachButton); + if (endAction != null) { + endAction.run(); + } + return; + } + final int startHeight = sheet.getLayoutParams().height > 0 ? sheet.getLayoutParams().height + : sheet.getHeight(); + animateComposeAttachSheetHeight(sheet, startHeight, 0, once(() -> { + setComposeAttachSheetExpanded(sheet, false); + if (endAction != null) { + endAction.run(); + } + })); + animateComposeAttachClose(attachButton); + } + + private static void cancelComposeAttachHeightAnimator() { + if (sComposeAttachHeightAnimator != null) { + sComposeAttachHeightAnimator.cancel(); + sComposeAttachHeightAnimator = null; + } + } + + private static int measureComposeAttachSheetHeight(final View sheet) { + final ViewGroup.LayoutParams params = sheet.getLayoutParams(); + final int savedHeight = params.height; + final int savedVisibility = sheet.getVisibility(); + params.height = ViewGroup.LayoutParams.WRAP_CONTENT; + sheet.setVisibility(View.INVISIBLE); + final ViewGroup parent = (ViewGroup) sheet.getParent(); + final int widthSpec = MeasureSpec.makeMeasureSpec( + parent != null && parent.getWidth() > 0 ? parent.getWidth() + : sheet.getResources().getDisplayMetrics().widthPixels, + MeasureSpec.EXACTLY); + sheet.measure(widthSpec, MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)); + final int measured = sheet.getMeasuredHeight(); + params.height = savedHeight; + sheet.setVisibility(savedVisibility); + sheet.setLayoutParams(params); + return measured; + } + + private static void animateComposeAttachSheetHeight(final View sheet, final int from, + final int to, final Runnable endAction) { + final ViewGroup.LayoutParams params = sheet.getLayoutParams(); + params.height = from; + sheet.setLayoutParams(params); + sheet.setVisibility(View.VISIBLE); + sComposeAttachHeightAnimator = ValueAnimator.ofInt(from, to); + sComposeAttachHeightAnimator.setDuration(COMPOSE_ATTACH_SHEET_MS); + sComposeAttachHeightAnimator.setInterpolator(EASE_MATERIAL); + sComposeAttachHeightAnimator.addUpdateListener(animation -> { + params.height = (Integer) animation.getAnimatedValue(); + sheet.setLayoutParams(params); + }); + sComposeAttachHeightAnimator.addListener(new android.animation.AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(final android.animation.Animator animation) { + sComposeAttachHeightAnimator = null; + if (endAction != null) { + endAction.run(); + } + } + }); + sComposeAttachHeightAnimator.start(); + } + + private static void setComposeAttachSheetExpanded(final View sheet, final boolean expanded) { + final ViewGroup.LayoutParams params = sheet.getLayoutParams(); + if (expanded) { + sheet.setVisibility(View.VISIBLE); + params.height = ViewGroup.LayoutParams.WRAP_CONTENT; + } else { + sheet.setVisibility(View.GONE); + params.height = 0; + } + sheet.setLayoutParams(params); + sheet.setTranslationY(0f); + sheet.setAlpha(1f); + } + + private static void animateComposeAttachOpen(final ImageButton attachButton) { + if (attachButton == null) { + return; + } + applyComposeAttachOpenIcon(attachButton); + beginLayer(attachButton); + attachButton.animate() + .rotation(90f) + .setDuration(COMPOSE_ATTACH_SHEET_MS) + .setInterpolator(EASE_MATERIAL) + .withEndAction(() -> { + applyComposeAttachCloseIcon(attachButton); + endLayer(attachButton); + }) + .start(); + } + + private static void animateComposeAttachClose(final ImageButton attachButton) { + if (attachButton == null) { + return; + } + applyComposeAttachCloseIcon(attachButton); + attachButton.setRotation(90f); + beginLayer(attachButton); + attachButton.animate() + .rotation(0f) + .setDuration(COMPOSE_ATTACH_SHEET_MS) + .setInterpolator(EASE_MATERIAL) + .withEndAction(() -> { + applyComposeAttachOpenIcon(attachButton); + endLayer(attachButton); + }) + .start(); + } + + private static void applyComposeAttachOpenIcon(final ImageButton attachButton) { + if (attachButton == null) { + return; + } + attachButton.setImageResource(R.drawable.ic_home_compose_plus); + attachButton.setRotation(0f); + } + + private static void applyComposeAttachCloseIcon(final ImageButton attachButton) { + if (attachButton == null) { + return; + } + attachButton.setImageResource(R.drawable.ic_home_close); + attachButton.setRotation(0f); + } + + private static final int GALLERY_SHEET_MS = 280; + + /** Slides the in-conversation gallery sheet up over a fading scrim. */ + public static void gallerySheetOpen(final View scrim, final View sheet) { + if (sheet == null) { + return; + } + if (scrim != null) { + scrim.animate().cancel(); + } + sheet.animate().cancel(); + final int height = sheet.getHeight() > 0 ? sheet.getHeight() + : sheet.getResources().getDisplayMetrics().heightPixels; + if (!enabled()) { + if (scrim != null) { + scrim.setAlpha(1f); + } + sheet.setTranslationY(0f); + return; + } + if (scrim != null) { + scrim.setAlpha(0f); + } + sheet.setTranslationY(height); + if (scrim != null) { + scrim.animate().alpha(1f).setDuration(GALLERY_SHEET_MS) + .setInterpolator(EASE_MATERIAL).start(); + } + beginLayer(sheet); + sheet.animate() + .translationY(0f) + .setDuration(GALLERY_SHEET_MS) + .setInterpolator(EASE_MATERIAL) + .withEndAction(() -> endLayer(sheet)) + .start(); + } + + /** Slides the gallery sheet down and fades the scrim; conversation stays visible. */ + public static void gallerySheetClose(final View scrim, final View sheet, + final Runnable endAction) { + final Runnable finish = once(endAction); + if (sheet == null) { + if (scrim != null) { + scrim.setAlpha(0f); + } + finish.run(); + return; + } + if (scrim != null) { + scrim.animate().cancel(); + } + sheet.animate().cancel(); + if (!enabled()) { + finish.run(); + return; + } + final int height = sheet.getHeight() > 0 ? sheet.getHeight() + : sheet.getResources().getDisplayMetrics().heightPixels; + if (scrim != null) { + scrim.animate().alpha(0f).setDuration(GALLERY_SHEET_MS) + .setInterpolator(EASE_MATERIAL).start(); + } + beginLayer(sheet); + sheet.animate() + .translationY(height) + .setDuration(GALLERY_SHEET_MS) + .setInterpolator(EASE_MATERIAL) + .withEndAction(() -> { + endLayer(sheet); + finish.run(); + }) + .start(); + } +} diff --git a/app/src/main/java/com/android/messaging/ui/home/UnknownSenderPromptStore.java b/app/src/main/java/com/android/messaging/ui/home/UnknownSenderPromptStore.java new file mode 100644 index 0000000..3f35839 --- /dev/null +++ b/app/src/main/java/com/android/messaging/ui/home/UnknownSenderPromptStore.java @@ -0,0 +1,57 @@ +/* + * 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.content.SharedPreferences; +import android.text.TextUtils; + +/** Remembers when the user dismissed the unknown-sender banner for a conversation. */ +public final class UnknownSenderPromptStore { + + private static final String PREFS_NAME = "home_unknown_sender_prompt"; + private static final String KEY_PREFIX = "dismissed_"; + + private UnknownSenderPromptStore() { + } + + public static boolean isDismissed(final Context context, final String conversationId) { + if (TextUtils.isEmpty(conversationId)) { + return true; + } + return prefs(context).getBoolean(KEY_PREFIX + conversationId, false); + } + + public static void setDismissed(final Context context, final String conversationId) { + if (TextUtils.isEmpty(conversationId)) { + return; + } + prefs(context).edit().putBoolean(KEY_PREFIX + conversationId, true).apply(); + } + + public static void clearDismissed(final Context context, final String conversationId) { + if (TextUtils.isEmpty(conversationId)) { + return; + } + prefs(context).edit().remove(KEY_PREFIX + conversationId).apply(); + } + + private static SharedPreferences prefs(final Context context) { + return context.getApplicationContext().getSharedPreferences(PREFS_NAME, + Context.MODE_PRIVATE); + } +} diff --git a/app/src/main/java/com/android/messaging/ui/mediapicker/CameraMediaChooser.java b/app/src/main/java/com/android/messaging/ui/mediapicker/CameraMediaChooser.java index a941adc..1e36bb8 100644 --- a/app/src/main/java/com/android/messaging/ui/mediapicker/CameraMediaChooser.java +++ b/app/src/main/java/com/android/messaging/ui/mediapicker/CameraMediaChooser.java @@ -177,4 +177,5 @@ class CameraMediaChooser extends MediaChooser { private static boolean hasCameraPermission() { return OsUtil.hasPermission(Manifest.permission.CAMERA); } + } diff --git a/app/src/main/java/com/android/messaging/ui/mediapicker/ContactMediaChooser.java b/app/src/main/java/com/android/messaging/ui/mediapicker/ContactMediaChooser.java index cf052f2..d7e6e07 100644 --- a/app/src/main/java/com/android/messaging/ui/mediapicker/ContactMediaChooser.java +++ b/app/src/main/java/com/android/messaging/ui/mediapicker/ContactMediaChooser.java @@ -112,17 +112,17 @@ class ContactMediaChooser extends MediaChooser { container /* root */, false /* attachToRoot */); mEnabledView = view.findViewById(R.id.mediapicker_enabled); - mEnabledView.setOnClickListener(v -> { - // Launch an external picker to pick a contact as attachment. - final Intent intent = new Intent(Intent.ACTION_PICK, Contacts.CONTENT_URI); - - try { - mPickerLauncher.launch(intent); - } catch (final ActivityNotFoundException ex) { - LogUtil.w(LogUtil.BUGLE_TAG, "Couldn't find activity:", ex); - UiUtils.showToastAtBottom(R.string.activity_not_found_message); - } - }); + mEnabledView.setOnClickListener(v -> launchPicker()); return view; } + + void launchPicker() { + final Intent intent = new Intent(Intent.ACTION_PICK, Contacts.CONTENT_URI); + try { + mPickerLauncher.launch(intent); + } catch (final ActivityNotFoundException ex) { + LogUtil.w(LogUtil.BUGLE_TAG, "Couldn't find activity:", ex); + UiUtils.showToastAtBottom(R.string.activity_not_found_message); + } + } } diff --git a/app/src/main/java/com/android/messaging/ui/mediapicker/DocumentImagePicker.java b/app/src/main/java/com/android/messaging/ui/mediapicker/DocumentImagePicker.java index 305fdd4..4e0cc73 100644 --- a/app/src/main/java/com/android/messaging/ui/mediapicker/DocumentImagePicker.java +++ b/app/src/main/java/com/android/messaging/ui/mediapicker/DocumentImagePicker.java @@ -16,27 +16,24 @@ */ package com.android.messaging.ui.mediapicker; -import android.content.Intent; +import android.app.Activity; import android.net.Uri; -import android.os.Bundle; import android.os.Handler; import android.os.Looper; import androidx.activity.result.ActivityResultLauncher; -import androidx.activity.result.PickVisualMediaRequest; import androidx.activity.result.contract.ActivityResultContracts; import androidx.fragment.app.Fragment; import com.android.messaging.Factory; import com.android.messaging.datamodel.MediaScratchFileProvider; import com.android.messaging.datamodel.data.PendingAttachmentData; -import com.android.messaging.ui.UIIntents; -import com.android.messaging.util.BugleGservicesKeys; -import com.android.messaging.util.LogUtil; +import com.android.messaging.ui.home.HomeGallerySheet; import com.android.messaging.util.FileUtil; import com.android.messaging.util.ImageUtils; +import com.android.messaging.util.LogUtil; +import com.android.messaging.util.OsUtil; -import java.util.ArrayList; import java.util.List; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -67,9 +64,7 @@ public class DocumentImagePicker { // The listener on the picker events. private final SelectionListener mListener; - private static final String EXTRA_PHOTO_URL = "photo_url"; - - private final ActivityResultLauncher mPickMultipleMedia; + private final ActivityResultLauncher mPermissionLauncher; /** * Creates a new instance of DocumentImagePicker. @@ -81,24 +76,24 @@ public class DocumentImagePicker { mFragment = fragment; mListener = listener; - mPickMultipleMedia = mFragment.registerForActivityResult( - new ActivityResultContracts.PickMultipleVisualMedia( - BugleGservicesKeys.MMS_ATTACHMENT_LIMIT_DEFAULT), uris -> { - // Callback is invoked after the user selects media items or closes the - // photo picker. - if (!uris.isEmpty()) { - onDocumentsPicked(uris); - } - }); + mPermissionLauncher = mFragment.registerForActivityResult( + new ActivityResultContracts.RequestMultiplePermissions(), + result -> showGallerySheet()); } /** * Intent out to open an image/video from document picker. */ public void launchPicker() { - mPickMultipleMedia.launch(new PickVisualMediaRequest.Builder() - .setMediaType(ActivityResultContracts.PickVisualMedia.ImageAndVideo.INSTANCE) - .build()); + final Activity activity = mFragment.getActivity(); + if (activity == null) { + return; + } + if (!OsUtil.hasMediaPermissions()) { + mPermissionLauncher.launch(OsUtil.getMediaPermissions()); + return; + } + showGallerySheet(); } public void onDocumentsPicked(List uris) { @@ -107,6 +102,14 @@ public class DocumentImagePicker { } } + private void showGallerySheet() { + final Activity activity = mFragment.getActivity(); + if (activity == null || activity.isFinishing()) { + return; + } + HomeGallerySheet.show(activity, this::onDocumentsPicked, this::launchPicker); + } + private void prepareDocumentForAttachment(final Uri documentUri) { // Notify our listener with a PendingAttachmentData containing the metadata. // Asynchronously get the content type for the picked image since diff --git a/app/src/main/java/com/android/messaging/ui/mediapicker/GalleryMediaChooser.java b/app/src/main/java/com/android/messaging/ui/mediapicker/GalleryMediaChooser.java index 9dbc2ed..4a34cdc 100644 --- a/app/src/main/java/com/android/messaging/ui/mediapicker/GalleryMediaChooser.java +++ b/app/src/main/java/com/android/messaging/ui/mediapicker/GalleryMediaChooser.java @@ -65,13 +65,14 @@ class GalleryMediaChooser extends MediaChooser { false /* attachToRoot */); final View enabledView = view.findViewById(R.id.mediapicker_enabled); - enabledView.setOnClickListener(v -> { - // Launch an external picker to pick item from document picker as attachment. - mDocumentImagePicker.launchPicker(); - }); + enabledView.setOnClickListener(v -> launchPicker()); return view; } + void launchPicker() { + mDocumentImagePicker.launchPicker(); + } + @Override int getActionBarTitleResId() { return R.string.mediapicker_gallery_title; diff --git a/app/src/main/java/com/android/messaging/ui/mediapicker/MediaPicker.java b/app/src/main/java/com/android/messaging/ui/mediapicker/MediaPicker.java index c362398..79fcd3d 100644 --- a/app/src/main/java/com/android/messaging/ui/mediapicker/MediaPicker.java +++ b/app/src/main/java/com/android/messaging/ui/mediapicker/MediaPicker.java @@ -180,8 +180,16 @@ public class MediaPicker extends Fragment implements DraftMessageSubscriptionDat mOpen = false; } + private static final int OPEN_MODE_INVALID = -1; + private static final int OPEN_MODE_DEFAULT = 0; + private static final int OPEN_MODE_CAMERA = 1; + private static final int OPEN_MODE_GALLERY = 2; + private static final int OPEN_MODE_AUDIO = 3; + private static final int OPEN_MODE_CONTACT = 4; + private boolean mIsAttached; private int mStartingMediaTypeOnAttach = MEDA_TYPE_INVALID; + private int mDeferredOpenMode = OPEN_MODE_INVALID; private boolean mAnimateOnAttach; @Override @@ -189,7 +197,16 @@ public class MediaPicker extends Fragment implements DraftMessageSubscriptionDat super.onAttach(context); mIsAttached = true; setSupportedMediaTypes(MEDIA_TYPE_ALL); - if (mStartingMediaTypeOnAttach != MEDA_TYPE_INVALID) { + if (mDeferredOpenMode == OPEN_MODE_GALLERY) { + launchGalleryPickerWithoutPanel(); + mDeferredOpenMode = OPEN_MODE_INVALID; + } else if (mDeferredOpenMode == OPEN_MODE_CONTACT) { + launchContactPickerWithoutPanel(); + mDeferredOpenMode = OPEN_MODE_INVALID; + } else if (mDeferredOpenMode != OPEN_MODE_INVALID && mDeferredOpenMode != OPEN_MODE_DEFAULT) { + performOpenMode(mDeferredOpenMode, mAnimateOnAttach); + mDeferredOpenMode = OPEN_MODE_INVALID; + } else if (mStartingMediaTypeOnAttach != MEDA_TYPE_INVALID) { // open() was previously called. Do the pending open now. doOpen(mStartingMediaTypeOnAttach, mAnimateOnAttach); } @@ -211,7 +228,6 @@ public class MediaPicker extends Fragment implements DraftMessageSubscriptionDat false); mMediaPickerPanel.setMediaPicker(this); mTabStrip = mMediaPickerPanel.findViewById(R.id.mediapicker_tabstrip); - mTabStrip.setBackgroundColor(mThemeColor); for (final MediaChooser chooser : mChoosers) { chooser.onCreateTabButton(inflater, mTabStrip); final ImageButton tabButton = chooser.getTabButton(); @@ -249,11 +265,20 @@ public class MediaPicker extends Fragment implements DraftMessageSubscriptionDat }); // Camera initialization is expensive, so don't realize offscreen pages if not needed. mViewPager.setOffscreenPageLimit(0); - mViewPager.setAdapter(mPagerAdapter); + mViewPager.setAdapter(null); + mViewPager.setVisibility(View.GONE); + mMediaPickerPanel.setViewPagerSuppressed(true); + mMediaPickerPanel.setVisibility(View.GONE); final boolean isTouchExplorationEnabled = AccessibilityUtil.isTouchExplorationEnabled( getActivity()); mMediaPickerPanel.setFullScreenOnly(isTouchExplorationEnabled); - mMediaPickerPanel.setExpanded(mOpen, true, mEnabledChoosers.indexOf(mSelectedChooser)); + if (mOpen) { + setHostContainerVisible(true); + mMediaPickerPanel.setViewPagerSuppressed(false); + mMediaPickerPanel.setExpanded(true, false, mEnabledChoosers.indexOf(mSelectedChooser)); + } else { + setHostContainerVisible(false); + } return mMediaPickerPanel; } @@ -262,11 +287,9 @@ public class MediaPicker extends Fragment implements DraftMessageSubscriptionDat super.onViewCreated(view, savedInstanceState); for (final MediaChooser chooser : mChoosers) { - final boolean enabled = (chooser.getSupportedMediaTypes() & mSupportedMediaTypes) != - MEDIA_TYPE_NONE; final ImageButton tabButton = chooser.getTabButton(); if (tabButton != null) { - tabButton.setVisibility(enabled ? View.VISIBLE : View.GONE); + tabButton.setVisibility(View.GONE); } } } @@ -291,10 +314,6 @@ public class MediaPicker extends Fragment implements DraftMessageSubscriptionDat */ public void setConversationThemeColor(final int themeColor) { mThemeColor = themeColor; - if (mTabStrip != null) { - mTabStrip.setBackgroundColor(mThemeColor); - } - for (final MediaChooser chooser : mEnabledChoosers) { chooser.setThemeColor(mThemeColor); } @@ -331,6 +350,7 @@ public class MediaPicker extends Fragment implements DraftMessageSubscriptionDat */ public void open(final int startingMediaType, final boolean animate) { mOpen = true; + mDeferredOpenMode = OPEN_MODE_DEFAULT; if (mIsAttached) { doOpen(startingMediaType, animate); } else { @@ -338,27 +358,160 @@ public class MediaPicker extends Fragment implements DraftMessageSubscriptionDat // we defer doing work as it may require an attached fragment (eg. calling // Fragment#requestPermission) mStartingMediaTypeOnAttach = startingMediaType; + mDeferredOpenMode = OPEN_MODE_INVALID; mAnimateOnAttach = animate; } } + /** Opens the media picker on the camera chooser. */ + public void openCamera(final boolean animate) { + openWithMode(OPEN_MODE_CAMERA, animate); + } + + /** Opens the system gallery picker without showing the in-app chooser panel. */ + public void openGallery(final boolean animate) { + if (mIsAttached) { + launchGalleryPickerWithoutPanel(); + } else { + mDeferredOpenMode = OPEN_MODE_GALLERY; + mAnimateOnAttach = animate; + } + } + + private void hideInAppPanel() { + mOpen = false; + if (mMediaPickerPanel != null) { + mMediaPickerPanel.setViewPagerSuppressed(true); + mMediaPickerPanel.setExpanded(false, false, MediaPickerPanel.PAGE_NOT_SET); + mMediaPickerPanel.setVisibility(View.GONE); + if (mViewPager != null) { + mViewPager.setAdapter(null); + mViewPager.setVisibility(View.GONE); + } + } + setHostContainerVisible(false); + } + + private void launchGalleryPickerWithoutPanel() { + hideInAppPanel(); + for (final MediaChooser chooser : mEnabledChoosers) { + if (chooser instanceof GalleryMediaChooser) { + ((GalleryMediaChooser) chooser).launchPicker(); + return; + } + } + } + + private void launchContactPickerWithoutPanel() { + hideInAppPanel(); + for (final MediaChooser chooser : mEnabledChoosers) { + if (chooser instanceof ContactMediaChooser) { + ((ContactMediaChooser) chooser).launchPicker(); + return; + } + } + } + + /** Opens the media picker on the audio chooser. */ + public void openAudio(final boolean animate) { + openWithMode(OPEN_MODE_AUDIO, animate); + } + + /** Opens the system contact picker without showing the in-app chooser panel. */ + public void openContact(final boolean animate) { + if (mIsAttached) { + launchContactPickerWithoutPanel(); + } else { + mDeferredOpenMode = OPEN_MODE_CONTACT; + mAnimateOnAttach = animate; + } + } + + private void openWithMode(final int openMode, final boolean animate) { + mOpen = true; + mDeferredOpenMode = openMode; + if (mIsAttached) { + performOpenMode(openMode, animate); + mDeferredOpenMode = OPEN_MODE_INVALID; + } else { + mStartingMediaTypeOnAttach = MEDA_TYPE_INVALID; + mAnimateOnAttach = animate; + } + } + + private void performOpenMode(final int openMode, final boolean animate) { + mSelectedChooser = null; + if (openMode == OPEN_MODE_CAMERA) { + selectFirstChooserOfType(CameraMediaChooser.class); + } else if (openMode == OPEN_MODE_GALLERY) { + selectFirstChooserOfType(GalleryMediaChooser.class); + } else if (openMode == OPEN_MODE_AUDIO) { + selectFirstChooserOfType(AudioMediaChooser.class); + } else if (openMode == OPEN_MODE_CONTACT) { + selectFirstChooserOfType(ContactMediaChooser.class); + } else { + doOpen(MEDIA_TYPE_DEFAULT, animate); + return; + } + if (mSelectedChooser == null && !mEnabledChoosers.isEmpty()) { + selectChooser(mEnabledChoosers.get(0)); + } + expandMediaPickerPanel(animate); + } + + private void selectFirstChooserOfType(final Class chooserType) { + for (final MediaChooser chooser : mEnabledChoosers) { + if (chooserType.isInstance(chooser)) { + selectChooser(chooser); + return; + } + } + } + + private void setHostContainerVisible(final boolean visible) { + final View container; + if (mMediaPickerPanel != null && mMediaPickerPanel.getParent() instanceof View + && ((View) mMediaPickerPanel.getParent()).getId() == R.id.mediapicker_container) { + container = (View) mMediaPickerPanel.getParent(); + } else if (getActivity() != null) { + container = getActivity().findViewById(R.id.mediapicker_container); + } else { + container = null; + } + if (container != null) { + container.setVisibility(visible ? View.VISIBLE : View.GONE); + } + } + + private void expandMediaPickerPanel(final boolean animate) { + if (mMediaPickerPanel == null) { + return; + } + setHostContainerVisible(true); + mMediaPickerPanel.setVisibility(View.VISIBLE); + mMediaPickerPanel.setViewPagerSuppressed(false); + if (mViewPager != null && mPagerAdapter != null) { + mViewPager.setAdapter(mPagerAdapter); + } + final boolean isTouchExplorationEnabled = AccessibilityUtil.isTouchExplorationEnabled( + Factory.get().getApplicationContext()); + mMediaPickerPanel.setFullScreenOnly(isTouchExplorationEnabled); + final int startingPage = mSelectedChooser == null ? MediaPickerPanel.PAGE_NOT_SET + : mEnabledChoosers.indexOf(mSelectedChooser); + mMediaPickerPanel.setExpanded(true, animate, startingPage); + } + private void doOpen(int startingMediaType, final boolean animate) { final boolean isTouchExplorationEnabled = AccessibilityUtil.isTouchExplorationEnabled( // getActivity() will be null at this point Factory.get().getApplicationContext()); - // If no specific starting type is specified (i.e. MEDIA_TYPE_DEFAULT), try to get the - // last opened chooser index from shared prefs. if (startingMediaType == MEDIA_TYPE_DEFAULT) { final int selectedChooserIndex = mBinding.getData().getSelectedChooserIndex(); if (selectedChooserIndex >= 0 && selectedChooserIndex < mEnabledChoosers.size()) { selectChooser(mEnabledChoosers.get(selectedChooserIndex)); - } else { - // This is the first time the picker is being used - if (isTouchExplorationEnabled) { - // Accessibility defaults to audio attachment mode. - startingMediaType = MEDIA_TYPE_AUDIO; - } + } else if (isTouchExplorationEnabled) { + startingMediaType = MEDIA_TYPE_AUDIO; } } @@ -372,16 +525,11 @@ public class MediaPicker extends Fragment implements DraftMessageSubscriptionDat } } - if (mSelectedChooser == null) { - // Fall back to the first chooser. + if (mSelectedChooser == null && !mEnabledChoosers.isEmpty()) { selectChooser(mEnabledChoosers.get(0)); } - if (mMediaPickerPanel != null) { - mMediaPickerPanel.setFullScreenOnly(isTouchExplorationEnabled); - mMediaPickerPanel.setExpanded(true, animate, - mEnabledChoosers.indexOf(mSelectedChooser)); - } + expandMediaPickerPanel(animate); } /** @return True if the media picker is open */ @@ -413,7 +561,7 @@ public class MediaPicker extends Fragment implements DraftMessageSubscriptionDat } final ImageButton tabButton = chooser.getTabButton(); if (tabButton != null) { - tabButton.setVisibility(enabled ? View.VISIBLE : View.GONE); + tabButton.setVisibility(View.GONE); } } @@ -443,7 +591,12 @@ public class MediaPicker extends Fragment implements DraftMessageSubscriptionDat public void dismiss(final boolean animate) { mOpen = false; if (mMediaPickerPanel != null) { + mMediaPickerPanel.setViewPagerSuppressed(true); mMediaPickerPanel.setExpanded(false, animate, MediaPickerPanel.PAGE_NOT_SET); + if (!animate) { + mMediaPickerPanel.setVisibility(View.GONE); + setHostContainerVisible(false); + } } mSelectedChooser = null; } diff --git a/app/src/main/java/com/android/messaging/ui/mediapicker/MediaPickerPanel.java b/app/src/main/java/com/android/messaging/ui/mediapicker/MediaPickerPanel.java index 8fbd8a2..928e4a5 100644 --- a/app/src/main/java/com/android/messaging/ui/mediapicker/MediaPickerPanel.java +++ b/app/src/main/java/com/android/messaging/ui/mediapicker/MediaPickerPanel.java @@ -58,6 +58,9 @@ public class MediaPickerPanel extends ViewGroup { */ private boolean mExpanded; + /** When true, only the attachment grid is shown (Google Messages menu sheet). */ + private boolean mViewPagerSuppressed; + /** * The current desired height of the MediaPicker. This property may be animated and the * measure pass uses it to determine what size the components are. @@ -154,13 +157,32 @@ public class MediaPickerPanel extends ViewGroup { // take the max here as on some devices the mDefaultViewPagerHeight may be too big in // landscape mode after animation. final int tabAdjustedDesiredHeight = desiredHeight - tabStripHeight; - final int viewPagerHeight = - tabAdjustedDesiredHeight <= 1 ? mDefaultViewPagerHeight : tabAdjustedDesiredHeight; - + final int viewPagerHeight; + if (mViewPagerSuppressed || !mExpanded) { + viewPagerHeight = 0; + mViewPager.setVisibility(GONE); + } else { + viewPagerHeight = + tabAdjustedDesiredHeight <= 1 ? mDefaultViewPagerHeight : tabAdjustedDesiredHeight; + mViewPager.setVisibility(VISIBLE); + } int viewPagerHeightMeasureSpec = MeasureSpec.makeMeasureSpec( viewPagerHeight, MeasureSpec.EXACTLY); measureChild(mViewPager, widthMeasureSpec, viewPagerHeightMeasureSpec); - setMeasuredDimension(mViewPager.getMeasuredWidth(), desiredHeight); + final int totalHeight = (mViewPagerSuppressed || !mExpanded) ? 0 : desiredHeight; + setMeasuredDimension(Math.max(mViewPager.getMeasuredWidth(), getSuggestedMinimumWidth()), + totalHeight); + } + + void setViewPagerSuppressed(final boolean suppressed) { + if (mViewPagerSuppressed == suppressed) { + return; + } + mViewPagerSuppressed = suppressed; + if (suppressed) { + mViewPager.setVisibility(GONE); + } + requestLayout(); } @Override @@ -224,6 +246,11 @@ public class MediaPickerPanel extends ViewGroup { } private void setupViewPager(final int startingPage) { + if (mViewPagerSuppressed) { + mViewPager.setVisibility(GONE); + mViewPager.setAdapter(null); + return; + } mViewPager.setVisibility(View.VISIBLE); if (startingPage >= 0 && startingPage < mMediaPicker.getPagerAdapter().getCount()) { mViewPager.setAdapter(mMediaPicker.getPagerAdapter()); @@ -250,13 +277,16 @@ public class MediaPickerPanel extends ViewGroup { } mFullScreen = false; mExpanded = expanded; - mHandler.post(() -> setDesiredHeight(getDesiredHeight(), animate)); if (expanded) { + setVisibility(VISIBLE); setupViewPager(startingPage); mMediaPicker.dispatchOpened(); } else { + mViewPager.setAdapter(null); + mViewPager.setVisibility(GONE); mMediaPicker.dispatchDismissed(); } + mHandler.post(() -> setDesiredHeight(getDesiredHeight(), animate)); // Call setFullScreenView() when we are in landscape mode so it can go full screen as // soon as it is expanded. @@ -292,9 +322,31 @@ public class MediaPickerPanel extends ViewGroup { }; animation.setDuration(UiUtils.MEDIAPICKER_TRANSITION_DURATION); animation.setInterpolator(UiUtils.EASE_OUT_INTERPOLATOR); + animation.setAnimationListener(new Animation.AnimationListener() { + @Override + public void onAnimationStart(final Animation a) { + } + + @Override + public void onAnimationRepeat(final Animation a) { + } + + @Override + public void onAnimationEnd(final Animation a) { + if (!mExpanded) { + setVisibility(GONE); + if (getParent() instanceof View) { + ((View) getParent()).setVisibility(GONE); + } + } + } + }); startAnimation(animation); } else { mCurrentDesiredHeight = height; + if (!mExpanded) { + setVisibility(GONE); + } } requestLayout(); } @@ -305,6 +357,9 @@ public class MediaPickerPanel extends ViewGroup { private int measureHeight() { final int measureSpec = MeasureSpec.makeMeasureSpec(Integer.MAX_VALUE, MeasureSpec.AT_MOST); measureChild(mTabStrip, measureSpec, measureSpec); + if (mViewPagerSuppressed) { + return mTabStrip.getMeasuredHeight(); + } return mDefaultViewPagerHeight + mTabStrip.getMeasuredHeight(); } diff --git a/app/src/main/java/com/android/messaging/util/ContactUtil.java b/app/src/main/java/com/android/messaging/util/ContactUtil.java index d6993b8..b5f223b 100644 --- a/app/src/main/java/com/android/messaging/util/ContactUtil.java +++ b/app/src/main/java/com/android/messaging/util/ContactUtil.java @@ -31,6 +31,8 @@ import android.provider.ContactsContract.DisplayNameSources; import android.provider.ContactsContract.PhoneLookup; import android.provider.ContactsContract.Profile; import android.text.TextUtils; +import android.content.ActivityNotFoundException; +import android.content.Intent; import android.view.View; import com.android.ex.chips.RecipientEntry; @@ -189,6 +191,122 @@ public class ContactUtil { * @param avatarUri Uri to the avatar image if available * @param normalizedDestination The normalized phone number or email */ + /** + * Opens the platform contact detail screen (Google Contacts full-page UI when available). + * + * @return true if an activity was started + */ + public static boolean openContactDetail(final Context context, final long contactId, + final String contactLookupKey) { + return openContactDetail(context, contactId, contactLookupKey, null); + } + + /** + * Opens contact detail for a 1:1 participant, including numbers not yet linked in our DB. + * Resolves address-book matches via {@link #lookupDestination} when lookup key is missing. + * For numbers not in the address book, delegates to the contacts app tel/mailto detail UI. + * + * @return true if an activity was started + */ + public static boolean openContactDetail(final Context context, final long contactId, + final String contactLookupKey, final String normalizedDestination) { + if (context == null) { + return false; + } + Uri lookupUri = null; + if (contactId > ParticipantData.PARTICIPANT_CONTACT_ID_NOT_RESOLVED + && !TextUtils.isEmpty(contactLookupKey)) { + lookupUri = Contacts.getLookupUri(contactId, contactLookupKey); + } + if (lookupUri == null && !TextUtils.isEmpty(normalizedDestination)) { + lookupUri = resolveLookupUriFromDestination(context, normalizedDestination); + } + if (lookupUri != null) { + return startContactDetailActivity(context, lookupUri); + } + if (!TextUtils.isEmpty(normalizedDestination) + && !TextUtils.equals(normalizedDestination, + ParticipantData.getUnknownSenderDestination())) { + return openContactDetailForUnsavedDestination(context, normalizedDestination); + } + return false; + } + + /** Whether the participant is stored in the device address book (saved contact). */ + public static boolean isParticipantInAddressBook(final Context context, final long contactId, + final String contactLookupKey, final String normalizedDestination) { + if (contactId > ParticipantData.PARTICIPANT_CONTACT_ID_NOT_RESOLVED + && !TextUtils.isEmpty(contactLookupKey)) { + return true; + } + if (context == null || TextUtils.isEmpty(normalizedDestination) + || !hasReadContactsPermission()) { + return false; + } + Cursor cursor = null; + try { + cursor = lookupDestination(context, normalizedDestination).performSynchronousQuery(); + return cursor != null && cursor.getCount() > 0; + } finally { + if (cursor != null) { + cursor.close(); + } + } + } + + private static Uri resolveLookupUriFromDestination(final Context context, + final String normalizedDestination) { + if (!hasReadContactsPermission()) { + return null; + } + Cursor cursor = null; + try { + cursor = lookupDestination(context, normalizedDestination).performSynchronousQuery(); + if (cursor == null || !cursor.moveToFirst()) { + return null; + } + final long resolvedContactId = cursor.getLong(INDEX_CONTACT_ID); + final String resolvedLookupKey = cursor.getString(INDEX_LOOKUP_KEY); + if (resolvedContactId <= ParticipantData.PARTICIPANT_CONTACT_ID_NOT_RESOLVED + || TextUtils.isEmpty(resolvedLookupKey)) { + return null; + } + return Contacts.getLookupUri(resolvedContactId, resolvedLookupKey); + } finally { + if (cursor != null) { + cursor.close(); + } + } + } + + private static boolean startContactDetailActivity(final Context context, final Uri lookupUri) { + final Intent intent = new Intent(Intent.ACTION_VIEW, lookupUri); + try { + context.startActivity(intent); + return true; + } catch (final ActivityNotFoundException e) { + return false; + } + } + + private static boolean openContactDetailForUnsavedDestination(final Context context, + final String normalizedDestination) { + final Intent intent = new Intent(Intent.ACTION_VIEW); + if (MmsSmsUtils.isEmailAddress(normalizedDestination)) { + intent.setDataAndType(Uri.parse("mailto:" + normalizedDestination), + Contacts.CONTENT_ITEM_TYPE); + } else { + intent.setDataAndType(Uri.fromParts("tel", normalizedDestination, null), + Contacts.CONTENT_ITEM_TYPE); + } + try { + context.startActivity(intent); + return true; + } catch (final ActivityNotFoundException e) { + return false; + } + } + public static void showOrAddContact(final View view, final long contactId, final String contactLookupKey, final Uri avatarUri, final String normalizedDestination) { diff --git a/app/src/main/java/com/android/messaging/util/OsUtil.java b/app/src/main/java/com/android/messaging/util/OsUtil.java index 98c7e73..43cbb58 100644 --- a/app/src/main/java/com/android/messaging/util/OsUtil.java +++ b/app/src/main/java/com/android/messaging/util/OsUtil.java @@ -123,6 +123,18 @@ public class OsUtil { return OsUtil.hasPermission(Manifest.permission.RECORD_AUDIO); } + public static String[] getMediaPermissions() { + return new String[] { + Manifest.permission.READ_MEDIA_IMAGES, + Manifest.permission.READ_MEDIA_VIDEO, + }; + } + + public static boolean hasMediaPermissions() { + return hasPermission(Manifest.permission.READ_MEDIA_IMAGES) + || hasPermission(Manifest.permission.READ_MEDIA_VIDEO); + } + /** * Returns array with the set of permissions that have not been granted from the given set. * The array will be empty if the app has all of the specified permissions. Note that calling diff --git a/app/src/main/res/anim/home_activity_close.xml b/app/src/main/res/anim/home_activity_close.xml new file mode 100644 index 0000000..e09200d --- /dev/null +++ b/app/src/main/res/anim/home_activity_close.xml @@ -0,0 +1,14 @@ + + + + + + + diff --git a/app/src/main/res/anim/home_activity_hold.xml b/app/src/main/res/anim/home_activity_hold.xml new file mode 100644 index 0000000..eac2d94 --- /dev/null +++ b/app/src/main/res/anim/home_activity_hold.xml @@ -0,0 +1,5 @@ + + diff --git a/app/src/main/res/anim/home_activity_open.xml b/app/src/main/res/anim/home_activity_open.xml new file mode 100644 index 0000000..88b5764 --- /dev/null +++ b/app/src/main/res/anim/home_activity_open.xml @@ -0,0 +1,14 @@ + + + + + + + diff --git a/app/src/main/res/anim/home_sheet_close.xml b/app/src/main/res/anim/home_sheet_close.xml new file mode 100644 index 0000000..9af7ae9 --- /dev/null +++ b/app/src/main/res/anim/home_sheet_close.xml @@ -0,0 +1,6 @@ + + diff --git a/app/src/main/res/anim/home_sheet_open.xml b/app/src/main/res/anim/home_sheet_open.xml new file mode 100644 index 0000000..ab93349 --- /dev/null +++ b/app/src/main/res/anim/home_sheet_open.xml @@ -0,0 +1,6 @@ + + diff --git a/app/src/main/res/drawable/home_bg_alert_dialog.xml b/app/src/main/res/drawable/home_bg_alert_dialog.xml new file mode 100644 index 0000000..8463ce6 --- /dev/null +++ b/app/src/main/res/drawable/home_bg_alert_dialog.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/drawable/home_bg_bottom_sheet.xml b/app/src/main/res/drawable/home_bg_bottom_sheet.xml new file mode 100644 index 0000000..b0751c1 --- /dev/null +++ b/app/src/main/res/drawable/home_bg_bottom_sheet.xml @@ -0,0 +1,8 @@ + + + + + diff --git a/app/src/main/res/drawable/home_bg_compose_attach_card.xml b/app/src/main/res/drawable/home_bg_compose_attach_card.xml new file mode 100644 index 0000000..f41ac68 --- /dev/null +++ b/app/src/main/res/drawable/home_bg_compose_attach_card.xml @@ -0,0 +1,8 @@ + + + + + diff --git a/app/src/main/res/drawable/home_bg_compose_attach_circle.xml b/app/src/main/res/drawable/home_bg_compose_attach_circle.xml new file mode 100644 index 0000000..035a3d9 --- /dev/null +++ b/app/src/main/res/drawable/home_bg_compose_attach_circle.xml @@ -0,0 +1,5 @@ + + + + diff --git a/app/src/main/res/drawable/home_bg_compose_chip.xml b/app/src/main/res/drawable/home_bg_compose_chip.xml new file mode 100644 index 0000000..fc896b1 --- /dev/null +++ b/app/src/main/res/drawable/home_bg_compose_chip.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/drawable/home_bg_compose_input_strip.xml b/app/src/main/res/drawable/home_bg_compose_input_strip.xml new file mode 100644 index 0000000..1549d36 --- /dev/null +++ b/app/src/main/res/drawable/home_bg_compose_input_strip.xml @@ -0,0 +1,8 @@ + + + + + diff --git a/app/src/main/res/drawable/home_bg_compose_pill.xml b/app/src/main/res/drawable/home_bg_compose_pill.xml new file mode 100644 index 0000000..0d86e84 --- /dev/null +++ b/app/src/main/res/drawable/home_bg_compose_pill.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/drawable/home_bg_compose_send_circle.xml b/app/src/main/res/drawable/home_bg_compose_send_circle.xml new file mode 100644 index 0000000..1a2e39f --- /dev/null +++ b/app/src/main/res/drawable/home_bg_compose_send_circle.xml @@ -0,0 +1,9 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/home_bg_compose_voice_circle.xml b/app/src/main/res/drawable/home_bg_compose_voice_circle.xml new file mode 100644 index 0000000..ca2263b --- /dev/null +++ b/app/src/main/res/drawable/home_bg_compose_voice_circle.xml @@ -0,0 +1,9 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/home_bg_contact_selected.xml b/app/src/main/res/drawable/home_bg_contact_selected.xml new file mode 100644 index 0000000..c751d3b --- /dev/null +++ b/app/src/main/res/drawable/home_bg_contact_selected.xml @@ -0,0 +1,13 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/home_bg_conversation_sheet.xml b/app/src/main/res/drawable/home_bg_conversation_sheet.xml new file mode 100644 index 0000000..1549d36 --- /dev/null +++ b/app/src/main/res/drawable/home_bg_conversation_sheet.xml @@ -0,0 +1,8 @@ + + + + + diff --git a/app/src/main/res/drawable/home_bg_delete_icon_circle.xml b/app/src/main/res/drawable/home_bg_delete_icon_circle.xml new file mode 100644 index 0000000..3bc821c --- /dev/null +++ b/app/src/main/res/drawable/home_bg_delete_icon_circle.xml @@ -0,0 +1,5 @@ + + + + diff --git a/app/src/main/res/drawable/home_bg_gallery_handle.xml b/app/src/main/res/drawable/home_bg_gallery_handle.xml new file mode 100644 index 0000000..ba0ab54 --- /dev/null +++ b/app/src/main/res/drawable/home_bg_gallery_handle.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/drawable/home_bg_input_field.xml b/app/src/main/res/drawable/home_bg_input_field.xml new file mode 100644 index 0000000..687373a --- /dev/null +++ b/app/src/main/res/drawable/home_bg_input_field.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/drawable/home_bg_list_profile_actions.xml b/app/src/main/res/drawable/home_bg_list_profile_actions.xml new file mode 100644 index 0000000..d99c83e --- /dev/null +++ b/app/src/main/res/drawable/home_bg_list_profile_actions.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/drawable/home_bg_list_profile_card.xml b/app/src/main/res/drawable/home_bg_list_profile_card.xml new file mode 100644 index 0000000..ff57160 --- /dev/null +++ b/app/src/main/res/drawable/home_bg_list_profile_card.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/drawable/home_bg_mediapicker_sheet.xml b/app/src/main/res/drawable/home_bg_mediapicker_sheet.xml new file mode 100644 index 0000000..f43960a --- /dev/null +++ b/app/src/main/res/drawable/home_bg_mediapicker_sheet.xml @@ -0,0 +1,10 @@ + + + + + diff --git a/app/src/main/res/drawable/home_bg_mediapicker_tile.xml b/app/src/main/res/drawable/home_bg_mediapicker_tile.xml new file mode 100644 index 0000000..cb07fe9 --- /dev/null +++ b/app/src/main/res/drawable/home_bg_mediapicker_tile.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/drawable/home_bg_outline_pill.xml b/app/src/main/res/drawable/home_bg_outline_pill.xml new file mode 100644 index 0000000..fe0bba5 --- /dev/null +++ b/app/src/main/res/drawable/home_bg_outline_pill.xml @@ -0,0 +1,13 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable/home_bg_primary_pill.xml b/app/src/main/res/drawable/home_bg_primary_pill.xml new file mode 100644 index 0000000..7b90b0d --- /dev/null +++ b/app/src/main/res/drawable/home_bg_primary_pill.xml @@ -0,0 +1,10 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/home_bg_profile_card.xml b/app/src/main/res/drawable/home_bg_profile_card.xml new file mode 100644 index 0000000..750432b --- /dev/null +++ b/app/src/main/res/drawable/home_bg_profile_card.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/drawable/home_bg_profile_row.xml b/app/src/main/res/drawable/home_bg_profile_row.xml new file mode 100644 index 0000000..952db2c --- /dev/null +++ b/app/src/main/res/drawable/home_bg_profile_row.xml @@ -0,0 +1,10 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/home_bg_sheet_action.xml b/app/src/main/res/drawable/home_bg_sheet_action.xml new file mode 100644 index 0000000..1e5dde8 --- /dev/null +++ b/app/src/main/res/drawable/home_bg_sheet_action.xml @@ -0,0 +1,10 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/home_bg_sheet_drag_handle.xml b/app/src/main/res/drawable/home_bg_sheet_drag_handle.xml new file mode 100644 index 0000000..7e25171 --- /dev/null +++ b/app/src/main/res/drawable/home_bg_sheet_drag_handle.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/drawable/home_bg_unknown_sender_banner.xml b/app/src/main/res/drawable/home_bg_unknown_sender_banner.xml new file mode 100644 index 0000000..18a4b79 --- /dev/null +++ b/app/src/main/res/drawable/home_bg_unknown_sender_banner.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/app/src/main/res/drawable/home_bg_unknown_sender_icon.xml b/app/src/main/res/drawable/home_bg_unknown_sender_icon.xml new file mode 100644 index 0000000..7e6a71f --- /dev/null +++ b/app/src/main/res/drawable/home_bg_unknown_sender_icon.xml @@ -0,0 +1,5 @@ + + + + diff --git a/app/src/main/res/drawable/ic_home_arrow_forward.xml b/app/src/main/res/drawable/ic_home_arrow_forward.xml new file mode 100644 index 0000000..7d19e01 --- /dev/null +++ b/app/src/main/res/drawable/ic_home_arrow_forward.xml @@ -0,0 +1,11 @@ + + + + diff --git a/app/src/main/res/drawable/ic_home_bell.xml b/app/src/main/res/drawable/ic_home_bell.xml new file mode 100644 index 0000000..e5e2ae5 --- /dev/null +++ b/app/src/main/res/drawable/ic_home_bell.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_home_camera_line.xml b/app/src/main/res/drawable/ic_home_camera_line.xml new file mode 100644 index 0000000..16134ca --- /dev/null +++ b/app/src/main/res/drawable/ic_home_camera_line.xml @@ -0,0 +1,18 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_home_check_on_accent.xml b/app/src/main/res/drawable/ic_home_check_on_accent.xml new file mode 100644 index 0000000..b78932e --- /dev/null +++ b/app/src/main/res/drawable/ic_home_check_on_accent.xml @@ -0,0 +1,11 @@ + + + + diff --git a/app/src/main/res/drawable/ic_home_chevron_up.xml b/app/src/main/res/drawable/ic_home_chevron_up.xml new file mode 100644 index 0000000..f5b952e --- /dev/null +++ b/app/src/main/res/drawable/ic_home_chevron_up.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_home_close.xml b/app/src/main/res/drawable/ic_home_close.xml new file mode 100644 index 0000000..39c0e89 --- /dev/null +++ b/app/src/main/res/drawable/ic_home_close.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_home_compose_plus.xml b/app/src/main/res/drawable/ic_home_compose_plus.xml new file mode 100644 index 0000000..a03bde6 --- /dev/null +++ b/app/src/main/res/drawable/ic_home_compose_plus.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_home_edit.xml b/app/src/main/res/drawable/ic_home_edit.xml new file mode 100644 index 0000000..59a943f --- /dev/null +++ b/app/src/main/res/drawable/ic_home_edit.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_home_emoji.xml b/app/src/main/res/drawable/ic_home_emoji.xml new file mode 100644 index 0000000..15b9527 --- /dev/null +++ b/app/src/main/res/drawable/ic_home_emoji.xml @@ -0,0 +1,6 @@ + + + + diff --git a/app/src/main/res/drawable/ic_home_group_add.xml b/app/src/main/res/drawable/ic_home_group_add.xml new file mode 100644 index 0000000..a2ef873 --- /dev/null +++ b/app/src/main/res/drawable/ic_home_group_add.xml @@ -0,0 +1,11 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_home_image_line.xml b/app/src/main/res/drawable/ic_home_image_line.xml new file mode 100644 index 0000000..d463b4f --- /dev/null +++ b/app/src/main/res/drawable/ic_home_image_line.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_home_link_line.xml b/app/src/main/res/drawable/ic_home_link_line.xml new file mode 100644 index 0000000..b5b5deb --- /dev/null +++ b/app/src/main/res/drawable/ic_home_link_line.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_home_location_live.xml b/app/src/main/res/drawable/ic_home_location_live.xml new file mode 100644 index 0000000..8120888 --- /dev/null +++ b/app/src/main/res/drawable/ic_home_location_live.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_home_place_line.xml b/app/src/main/res/drawable/ic_home_place_line.xml new file mode 100644 index 0000000..3ef4354 --- /dev/null +++ b/app/src/main/res/drawable/ic_home_place_line.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_home_star_line.xml b/app/src/main/res/drawable/ic_home_star_line.xml new file mode 100644 index 0000000..5c4c341 --- /dev/null +++ b/app/src/main/res/drawable/ic_home_star_line.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_home_video_line.xml b/app/src/main/res/drawable/ic_home_video_line.xml new file mode 100644 index 0000000..78e4d91 --- /dev/null +++ b/app/src/main/res/drawable/ic_home_video_line.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_home_voice_wave.xml b/app/src/main/res/drawable/ic_home_voice_wave.xml new file mode 100644 index 0000000..717725f --- /dev/null +++ b/app/src/main/res/drawable/ic_home_voice_wave.xml @@ -0,0 +1,25 @@ + + + + + + + + + diff --git a/app/src/main/res/layout/action_bar_conversation_name.xml b/app/src/main/res/layout/action_bar_conversation_name.xml index 586cf83..c222458 100644 --- a/app/src/main/res/layout/action_bar_conversation_name.xml +++ b/app/src/main/res/layout/action_bar_conversation_name.xml @@ -1,31 +1,41 @@ - - + + android:layout_gravity="fill_horizontal|center_vertical" + android:gravity="center_vertical" + android:orientation="horizontal"> - + + + + - \ No newline at end of file + android:layout_marginStart="@dimen/looply_space_md" + android:ellipsize="end" + android:includeFontPadding="false" + android:maxLines="1" + android:textColor="@color/home_text_primary" + android:textSize="17sp" + android:textStyle="bold" /> + + diff --git a/app/src/main/res/layout/compose_message_view.xml b/app/src/main/res/layout/compose_message_view.xml index 4ebb7fe..a5852fa 100644 --- a/app/src/main/res/layout/compose_message_view.xml +++ b/app/src/main/res/layout/compose_message_view.xml @@ -1,192 +1,569 @@ + + + + + + + + + android:layout_height="0dp" /> + + + + + + + + + + - - - + + + + + + + + + + + + + - - - + + + + + + + + + + + + + + + + android:background="@android:color/transparent" + + android:gravity="center_vertical" + + android:hint="@string/home_conversation_compose_hint" + + android:imeOptions="actionSend|flagNoEnterAction|flagNoExtractUi" + + android:includeFontPadding="false" + + android:inputType="textShortMessage|textAutoCorrect|textCapSentences|textMultiLine" + + android:maxLines="4" + + android:minHeight="0dp" + + android:paddingStart="@dimen/looply_space_xs" + + android:paddingEnd="@dimen/looply_space_xs" + + android:textColor="@color/home_text_primary" + + android:textColorHint="@color/home_text_secondary" + + android:textSize="16sp" /> + + + + + + + + + + + + android:singleLine="true" + + android:visibility="gone" /> + - + + + + + + + + + - - + + + + + + + + + + + + + + + + + + + - - + + + + + + + + + + + + + + + + - + + diff --git a/app/src/main/res/layout/conversation_fragment.xml b/app/src/main/res/layout/conversation_fragment.xml index 75b9763..f4a14fd 100644 --- a/app/src/main/res/layout/conversation_fragment.xml +++ b/app/src/main/res/layout/conversation_fragment.xml @@ -15,54 +15,95 @@ See the License for the specific language governing permissions and limitations under the License. --> - - + + + + + - - + + + android:background="@drawable/home_bg_conversation_sheet" + android:clipChildren="false" + android:clipToPadding="false" + android:orientation="vertical"> + + + + + + + + + + - - - + android:layout_height="wrap_content" + android:background="@color/home_bg" + android:clipChildren="false" + android:clipToPadding="false"> + + + + + diff --git a/app/src/main/res/layout/conversation_list_fragment.xml b/app/src/main/res/layout/conversation_list_fragment.xml index bc36ab2..4f0626b 100644 --- a/app/src/main/res/layout/conversation_list_fragment.xml +++ b/app/src/main/res/layout/conversation_list_fragment.xml @@ -33,91 +33,103 @@ android:background="@color/home_bg" android:clipChildren="false" android:clipToPadding="false" - android:elevation="8dp" - android:orientation="vertical" - android:outlineAmbientShadowColor="@color/home_pill_shadow_ambient" - android:outlineSpotShadowColor="@color/home_pill_shadow_spot"> + android:orientation="vertical"> - - + - - - - - - - + android:paddingBottom="5dp"> - + android:gravity="center_vertical" + android:orientation="horizontal"> + + + + + + + + + + - + - - - - - + android:layout_gravity="end|center_vertical" + android:background="@android:color/transparent" + android:contentDescription="@string/home_selection_done" + android:padding="0dp" + android:scaleType="fitCenter" + app:srcCompat="@drawable/ic_checkmark_circle_primary" /> + @@ -177,12 +186,6 @@ android:clipChildren="true" android:clipToPadding="false"> - - + + - - - - - diff --git a/app/src/main/res/layout/home_back_button.xml b/app/src/main/res/layout/home_back_button.xml new file mode 100644 index 0000000..2e6bff1 --- /dev/null +++ b/app/src/main/res/layout/home_back_button.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/layout/home_compose_activity.xml b/app/src/main/res/layout/home_compose_activity.xml new file mode 100644 index 0000000..038818c --- /dev/null +++ b/app/src/main/res/layout/home_compose_activity.xml @@ -0,0 +1,164 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/home_compose_chip.xml b/app/src/main/res/layout/home_compose_chip.xml new file mode 100644 index 0000000..142efe7 --- /dev/null +++ b/app/src/main/res/layout/home_compose_chip.xml @@ -0,0 +1,30 @@ + + + + + + + diff --git a/app/src/main/res/layout/home_contacts_fragment.xml b/app/src/main/res/layout/home_contacts_fragment.xml index 80b9170..3ac5b00 100644 --- a/app/src/main/res/layout/home_contacts_fragment.xml +++ b/app/src/main/res/layout/home_contacts_fragment.xml @@ -1,180 +1,44 @@ - + android:orientation="vertical"> - + android:layout_height="wrap_content" + android:visibility="gone" /> - + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + android:layout_gravity="center_horizontal|top" + android:layout_marginTop="48dp" + android:gravity="center" + android:paddingStart="32dp" + android:paddingEnd="32dp" + android:text="@string/home_compose_empty" + android:textColor="@color/home_text_secondary" + android:textSize="15sp" + android:visibility="gone" /> + + diff --git a/app/src/main/res/layout/home_contacts_list_item.xml b/app/src/main/res/layout/home_contacts_list_item.xml index 4d86ea8..0e3d335 100644 --- a/app/src/main/res/layout/home_contacts_list_item.xml +++ b/app/src/main/res/layout/home_contacts_list_item.xml @@ -19,18 +19,26 @@ android:layout_height="0dp" android:visibility="gone" /> - + - + + + + + + + + + + + diff --git a/app/src/main/res/layout/home_conversation_header.xml b/app/src/main/res/layout/home_conversation_header.xml new file mode 100644 index 0000000..ea6a3b0 --- /dev/null +++ b/app/src/main/res/layout/home_conversation_header.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/layout/home_conversation_message_action_bar.xml b/app/src/main/res/layout/home_conversation_message_action_bar.xml new file mode 100644 index 0000000..fd15868 --- /dev/null +++ b/app/src/main/res/layout/home_conversation_message_action_bar.xml @@ -0,0 +1,173 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/home_conversation_message_selection_header.xml b/app/src/main/res/layout/home_conversation_message_selection_header.xml new file mode 100644 index 0000000..b1da029 --- /dev/null +++ b/app/src/main/res/layout/home_conversation_message_selection_header.xml @@ -0,0 +1,39 @@ + + + + + + + diff --git a/app/src/main/res/layout/home_conversation_profile_expanded.xml b/app/src/main/res/layout/home_conversation_profile_expanded.xml new file mode 100644 index 0000000..8a16ad1 --- /dev/null +++ b/app/src/main/res/layout/home_conversation_profile_expanded.xml @@ -0,0 +1,146 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/home_conversation_search_activity.xml b/app/src/main/res/layout/home_conversation_search_activity.xml new file mode 100644 index 0000000..e1cde40 --- /dev/null +++ b/app/src/main/res/layout/home_conversation_search_activity.xml @@ -0,0 +1,95 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/home_conversation_search_item.xml b/app/src/main/res/layout/home_conversation_search_item.xml new file mode 100644 index 0000000..a4b9851 --- /dev/null +++ b/app/src/main/res/layout/home_conversation_search_item.xml @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/home_conversation_unknown_sender_banner.xml b/app/src/main/res/layout/home_conversation_unknown_sender_banner.xml new file mode 100644 index 0000000..385f017 --- /dev/null +++ b/app/src/main/res/layout/home_conversation_unknown_sender_banner.xml @@ -0,0 +1,121 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/home_delete_alert_dialog.xml b/app/src/main/res/layout/home_delete_alert_dialog.xml new file mode 100644 index 0000000..ea60000 --- /dev/null +++ b/app/src/main/res/layout/home_delete_alert_dialog.xml @@ -0,0 +1,44 @@ + + + + + + + + + diff --git a/app/src/main/res/layout/home_gallery_item.xml b/app/src/main/res/layout/home_gallery_item.xml new file mode 100644 index 0000000..b3bd795 --- /dev/null +++ b/app/src/main/res/layout/home_gallery_item.xml @@ -0,0 +1,25 @@ + + + + + + + diff --git a/app/src/main/res/layout/home_gallery_sheet.xml b/app/src/main/res/layout/home_gallery_sheet.xml new file mode 100644 index 0000000..ef8d497 --- /dev/null +++ b/app/src/main/res/layout/home_gallery_sheet.xml @@ -0,0 +1,103 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/home_group_name_activity.xml b/app/src/main/res/layout/home_group_name_activity.xml new file mode 100644 index 0000000..ef3505a --- /dev/null +++ b/app/src/main/res/layout/home_group_name_activity.xml @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/home_list_profile_popup.xml b/app/src/main/res/layout/home_list_profile_popup.xml new file mode 100644 index 0000000..a3d57a1 --- /dev/null +++ b/app/src/main/res/layout/home_list_profile_popup.xml @@ -0,0 +1,103 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/home_mediapicker_attachment_sheet.xml b/app/src/main/res/layout/home_mediapicker_attachment_sheet.xml new file mode 100644 index 0000000..d805f8a --- /dev/null +++ b/app/src/main/res/layout/home_mediapicker_attachment_sheet.xml @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/home_mediapicker_grid_item.xml b/app/src/main/res/layout/home_mediapicker_grid_item.xml new file mode 100644 index 0000000..8f7d4e6 --- /dev/null +++ b/app/src/main/res/layout/home_mediapicker_grid_item.xml @@ -0,0 +1,39 @@ + + + + + + + + + + diff --git a/app/src/main/res/layout/home_mediapicker_sheet.xml b/app/src/main/res/layout/home_mediapicker_sheet.xml new file mode 100644 index 0000000..6b1fb75 --- /dev/null +++ b/app/src/main/res/layout/home_mediapicker_sheet.xml @@ -0,0 +1,12 @@ + + + + + + diff --git a/app/src/main/res/layout/home_popover_filter.xml b/app/src/main/res/layout/home_popover_filter.xml index c29238b..2093664 100644 --- a/app/src/main/res/layout/home_popover_filter.xml +++ b/app/src/main/res/layout/home_popover_filter.xml @@ -144,18 +144,29 @@ android:id="@+id/popover_work" style="@style/Home.PopoverItem" android:layout_width="match_parent" - android:layout_height="44dp"> + android:layout_height="44dp" + android:clipChildren="false" + android:clipToPadding="false"> - + + + + + + android:layout_height="44dp" + android:clipChildren="false" + android:clipToPadding="false"> - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/home_profile_option_row.xml b/app/src/main/res/layout/home_profile_option_row.xml new file mode 100644 index 0000000..fe9ddfc --- /dev/null +++ b/app/src/main/res/layout/home_profile_option_row.xml @@ -0,0 +1,32 @@ + + + + + + + + diff --git a/app/src/main/res/layout/home_profile_participant_row.xml b/app/src/main/res/layout/home_profile_participant_row.xml new file mode 100644 index 0000000..bb2bdef --- /dev/null +++ b/app/src/main/res/layout/home_profile_participant_row.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/home_profile_quick_action.xml b/app/src/main/res/layout/home_profile_quick_action.xml new file mode 100644 index 0000000..f11fe52 --- /dev/null +++ b/app/src/main/res/layout/home_profile_quick_action.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + diff --git a/app/src/main/res/layout/home_profile_rename_dialog.xml b/app/src/main/res/layout/home_profile_rename_dialog.xml new file mode 100644 index 0000000..6ccc59e --- /dev/null +++ b/app/src/main/res/layout/home_profile_rename_dialog.xml @@ -0,0 +1,32 @@ + + + + + + + diff --git a/app/src/main/res/layout/home_search_activity.xml b/app/src/main/res/layout/home_search_activity.xml new file mode 100644 index 0000000..4dfd8b2 --- /dev/null +++ b/app/src/main/res/layout/home_search_activity.xml @@ -0,0 +1,148 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/home_search_filter_item.xml b/app/src/main/res/layout/home_search_filter_item.xml new file mode 100644 index 0000000..2671b46 --- /dev/null +++ b/app/src/main/res/layout/home_search_filter_item.xml @@ -0,0 +1,33 @@ + + + + + + + + diff --git a/app/src/main/res/layout/home_spam_activity.xml b/app/src/main/res/layout/home_spam_activity.xml new file mode 100644 index 0000000..819b431 --- /dev/null +++ b/app/src/main/res/layout/home_spam_activity.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/home_sub_header.xml b/app/src/main/res/layout/home_sub_header.xml new file mode 100644 index 0000000..da3c269 --- /dev/null +++ b/app/src/main/res/layout/home_sub_header.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + diff --git a/app/src/main/res/layout/mediapicker_fragment.xml b/app/src/main/res/layout/mediapicker_fragment.xml index 2e414bd..66caed4 100644 --- a/app/src/main/res/layout/mediapicker_fragment.xml +++ b/app/src/main/res/layout/mediapicker_fragment.xml @@ -1,34 +1,84 @@ + + + + + android:layout_height="wrap_content" + + android:clipChildren="true" + + android:visibility="gone"> + + + + android:minHeight="0dp" + + android:visibility="gone" /> + + + + + + + android:layout_height="wrap_content" + + android:clipChildren="false" + + android:clipToPadding="false" + + android:gravity="center_horizontal" + + android:orientation="vertical" + + android:visibility="gone" /> + + diff --git a/app/src/main/res/layout/people_and_options_activity.xml b/app/src/main/res/layout/people_and_options_activity.xml deleted file mode 100644 index e17df23..0000000 --- a/app/src/main/res/layout/people_and_options_activity.xml +++ /dev/null @@ -1,24 +0,0 @@ - - - diff --git a/app/src/main/res/layout/people_and_options_fragment.xml b/app/src/main/res/layout/people_and_options_fragment.xml deleted file mode 100644 index be61710..0000000 --- a/app/src/main/res/layout/people_and_options_fragment.xml +++ /dev/null @@ -1,23 +0,0 @@ - - - \ No newline at end of file diff --git a/app/src/main/res/layout/people_and_options_section_header.xml b/app/src/main/res/layout/people_and_options_section_header.xml deleted file mode 100644 index 44b10c1..0000000 --- a/app/src/main/res/layout/people_and_options_section_header.xml +++ /dev/null @@ -1,38 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/people_options_item_view.xml b/app/src/main/res/layout/people_options_item_view.xml deleted file mode 100644 index 8895037..0000000 --- a/app/src/main/res/layout/people_options_item_view.xml +++ /dev/null @@ -1,44 +0,0 @@ - - - - - - - - - diff --git a/app/src/main/res/menu/conversation_menu.xml b/app/src/main/res/menu/conversation_menu.xml index 57e561b..dbf01d1 100644 --- a/app/src/main/res/menu/conversation_menu.xml +++ b/app/src/main/res/menu/conversation_menu.xml @@ -30,13 +30,19 @@ android:id="@+id/action_add_contact" android:icon="@drawable/ic_person_add_light" android:orderInCategory="90" - android:title="@string/action_add_contact" + android:title="@string/home_profile_action_add_people" android:showAsAction="never"/> + + + diff --git a/app/src/main/res/values/home_colors.xml b/app/src/main/res/values/home_colors.xml index a138635..354e290 100644 --- a/app/src/main/res/values/home_colors.xml +++ b/app/src/main/res/values/home_colors.xml @@ -2,6 +2,8 @@ #FFFFFF + + #F2F2F7 #F2F2F7 #E5E5EA #E8FFFFFF @@ -21,6 +23,9 @@ #000000 #8E8E93 #AEAEB2 + + #6C6C70 + #52000000 #1C1C1E @color/looply_primary_soft @@ -32,4 +37,15 @@ #E8E8ED #6C6C70 #FFD60A + #FF3B30 + + #E7F8ED + #146C2E + + @color/home_search_field_bg + @color/home_surface_elevated + @color/looply_primary_soft + #335A31F4 + @color/looply_primary_muted + @color/home_bg diff --git a/app/src/main/res/values/home_styles.xml b/app/src/main/res/values/home_styles.xml index 87581fc..b9055da 100644 --- a/app/src/main/res/values/home_styles.xml +++ b/app/src/main/res/values/home_styles.xml @@ -1,6 +1,28 @@ + + + + + + + + + + + + + + + + + - - - -