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 <cursoragent@cursor.com>
This commit is contained in:
2026-09-25 01:23:10 +09:00
co-authored by Cursor
parent 349eeb7025
commit 8e5d88183c
144 changed files with 9272 additions and 1708 deletions
+49 -4
View File
@@ -44,6 +44,8 @@
<uses-permission android:name="android.permission.DOWNLOAD_WITHOUT_NOTIFICATION" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<!-- Optional features -->
<uses-feature
@@ -155,6 +157,39 @@
android:screenOrientation="user"
android:theme="@style/BugleTheme" />
<activity
android:name=".ui.home.HomeSpamActivity"
android:configChanges="orientation|screenSize|keyboardHidden"
android:label="@string/home_spam_title"
android:parentActivityName="com.android.messaging.ui.conversationlist.ConversationListActivity"
android:screenOrientation="user"
android:theme="@style/Home.SubScreen" />
<activity
android:name=".ui.home.HomeSearchActivity"
android:configChanges="orientation|screenSize|keyboardHidden"
android:label="@string/home_search_field_hint"
android:parentActivityName="com.android.messaging.ui.conversationlist.ConversationListActivity"
android:screenOrientation="user"
android:theme="@style/Home.SubScreen"
android:windowSoftInputMode="adjustResize|stateVisible" />
<activity
android:name=".ui.home.HomeComposeActivity"
android:configChanges="orientation|screenSize|keyboardHidden"
android:label="@string/home_compose_title"
android:parentActivityName="com.android.messaging.ui.conversationlist.ConversationListActivity"
android:theme="@style/Home.BottomSheet"
android:windowSoftInputMode="adjustResize|stateHidden" />
<activity
android:name=".ui.home.HomeGroupNameActivity"
android:configChanges="orientation|screenSize|keyboardHidden"
android:label="@string/home_group_name_title"
android:parentActivityName="com.android.messaging.ui.conversationlist.ConversationListActivity"
android:theme="@style/Home.BottomSheet"
android:windowSoftInputMode="adjustResize|stateHidden" />
<!-- Full-screen photo viewer -->
<activity
android:name=".ui.photoviewer.BuglePhotoViewActivity"
@@ -223,14 +258,24 @@
</intent-filter>
</activity>
<!-- People & Options -->
<!-- Conversation profile -->
<activity
android:name=".ui.conversationsettings.PeopleAndOptionsActivity"
android:name=".ui.home.HomeProfileActivity"
android:configChanges="orientation|screenSize|keyboardHidden"
android:label="@string/people_and_options_activity_title"
android:label="@string/home_profile_title"
android:parentActivityName="com.android.messaging.ui.conversation.ConversationActivity"
android:screenOrientation="user"
android:theme="@style/BugleTheme" />
android:theme="@style/Home.SubScreen" />
<!-- Conversation search -->
<activity
android:name=".ui.home.HomeConversationSearchActivity"
android:configChanges="orientation|screenSize|keyboardHidden"
android:label="@string/home_conversation_search_hint"
android:parentActivityName="com.android.messaging.ui.conversation.ConversationActivity"
android:screenOrientation="user"
android:theme="@style/Home.SubScreen"
android:windowSoftInputMode="adjustResize|stateVisible" />
<!-- License -->
<activity
@@ -28,6 +28,8 @@ import com.android.messaging.datamodel.action.ActionService;
import com.android.messaging.datamodel.action.BackgroundWorker;
import com.android.messaging.datamodel.data.BlockedParticipantsData;
import com.android.messaging.datamodel.data.BlockedParticipantsData.BlockedParticipantsDataListener;
import com.android.messaging.datamodel.data.ConversationSearchData;
import com.android.messaging.datamodel.data.ConversationSearchData.ConversationSearchDataListener;
import com.android.messaging.datamodel.data.ContactListItemData;
import com.android.messaging.datamodel.data.ContactPickerData;
import com.android.messaging.datamodel.data.ContactPickerData.ContactPickerDataListener;
@@ -42,9 +44,8 @@ import com.android.messaging.datamodel.data.MediaPickerData;
import com.android.messaging.datamodel.data.MessagePartData;
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.ConversationProfileData;
import com.android.messaging.datamodel.data.ConversationProfileData.ConversationProfileDataListener;
import com.android.messaging.datamodel.data.SettingsData;
import com.android.messaging.datamodel.data.SettingsData.SettingsDataListener;
import com.android.messaging.datamodel.data.SubscriptionListData;
@@ -83,10 +84,9 @@ public abstract class DataModel {
public abstract LaunchConversationData createLaunchConversationData(
LaunchConversationDataListener listener);
public abstract PeopleOptionsItemData createPeopleOptionsItemData(final Context context);
public abstract PeopleAndOptionsData createPeopleAndOptionsData(final String conversationId,
final Context context, final PeopleAndOptionsDataListener listener);
public abstract ConversationProfileData createConversationProfileData(
final String conversationId, final Context context,
final ConversationProfileDataListener listener);
public abstract VCardContactItemData createVCardContactItemData(final Context context,
final MessagePartData data);
@@ -100,6 +100,9 @@ public abstract class DataModel {
public abstract BlockedParticipantsData createBlockedParticipantsData(Context context,
BlockedParticipantsDataListener listener);
public abstract ConversationSearchData createConversationSearchData(Context context,
String conversationId, ConversationSearchDataListener listener);
public abstract SubscriptionListData createSubscriptonListData(Context context);
public abstract SettingsData createSettingsData(Context context, SettingsDataListener listener);
@@ -28,6 +28,8 @@ import com.android.messaging.datamodel.action.FixupMessageStatusOnStartupAction;
import com.android.messaging.datamodel.action.ProcessPendingMessagesAction;
import com.android.messaging.datamodel.data.BlockedParticipantsData;
import com.android.messaging.datamodel.data.BlockedParticipantsData.BlockedParticipantsDataListener;
import com.android.messaging.datamodel.data.ConversationSearchData;
import com.android.messaging.datamodel.data.ConversationSearchData.ConversationSearchDataListener;
import com.android.messaging.datamodel.data.ContactListItemData;
import com.android.messaging.datamodel.data.ContactPickerData;
import com.android.messaging.datamodel.data.ContactPickerData.ContactPickerDataListener;
@@ -42,9 +44,8 @@ import com.android.messaging.datamodel.data.MediaPickerData;
import com.android.messaging.datamodel.data.MessagePartData;
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.ConversationProfileData;
import com.android.messaging.datamodel.data.ConversationProfileData.ConversationProfileDataListener;
import com.android.messaging.datamodel.data.SettingsData;
import com.android.messaging.datamodel.data.SettingsData.SettingsDataListener;
import com.android.messaging.datamodel.data.SubscriptionListData;
@@ -107,6 +108,12 @@ public class DataModelImpl extends DataModel {
return new BlockedParticipantsData(context, listener);
}
@Override
public ConversationSearchData createConversationSearchData(final Context context,
final String conversationId, final ConversationSearchDataListener listener) {
return new ConversationSearchData(context, conversationId, listener);
}
@Override
public MediaPickerData createMediaPickerData(final Context context) {
return new MediaPickerData(context);
@@ -119,14 +126,9 @@ public class DataModelImpl extends DataModel {
}
@Override
public PeopleOptionsItemData createPeopleOptionsItemData(final Context context) {
return new PeopleOptionsItemData(context);
}
@Override
public PeopleAndOptionsData createPeopleAndOptionsData(final String conversationId,
final Context context, final PeopleAndOptionsDataListener listener) {
return new PeopleAndOptionsData(conversationId, context, listener);
public ConversationProfileData createConversationProfileData(final String conversationId,
final Context context, final ConversationProfileDataListener listener) {
return new ConversationProfileData(conversationId, context, listener);
}
@Override
@@ -70,6 +70,11 @@ public class MessagingContentProvider extends ContentProvider {
public static final Uri CONVERSATION_MESSAGES_URI = Uri.parse(CONTENT_AUTHORITY +
MESSAGES_QUERY + "/conversation");
public static final Uri CONVERSATION_MESSAGES_SEARCH_URI = Uri.parse(CONTENT_AUTHORITY +
MESSAGES_QUERY + "/search");
private static final String SEARCH_QUERY_PARAMETER = "q";
// Conversation participants query
private static final String PARTICIPANTS_QUERY = "participants";
@@ -146,6 +151,7 @@ public class MessagingContentProvider extends ContentProvider {
private static final int CONVERSATION_QUERY_CODE = 20;
private static final int CONVERSATION_MESSAGES_QUERY_CODE = 30;
private static final int CONVERSATION_MESSAGES_SEARCH_QUERY_CODE = 35;
private static final int CONVERSATION_PARTICIPANTS_QUERY_CODE = 40;
private static final int CONVERSATION_IMAGES_QUERY_CODE = 50;
private static final int DRAFT_IMAGES_QUERY_CODE = 60;
@@ -158,6 +164,8 @@ public class MessagingContentProvider extends ContentProvider {
sURIMatcher.addURI(AUTHORITY, CONVERSATIONS_QUERY + "/*", CONVERSATION_QUERY_CODE);
sURIMatcher.addURI(AUTHORITY, MESSAGES_QUERY + "/conversation/*",
CONVERSATION_MESSAGES_QUERY_CODE);
sURIMatcher.addURI(AUTHORITY, MESSAGES_QUERY + "/search/*",
CONVERSATION_MESSAGES_SEARCH_QUERY_CODE);
sURIMatcher.addURI(AUTHORITY, PARTICIPANTS_QUERY + "/conversation/*",
CONVERSATION_PARTICIPANTS_QUERY_CODE);
sURIMatcher.addURI(AUTHORITY, PARTICIPANTS_QUERY, PARTICIPANTS_QUERY_CODE);
@@ -176,6 +184,17 @@ public class MessagingContentProvider extends ContentProvider {
return builder.build();
}
/**
* Build a uri for the messages of a conversation whose text matches {@code searchText}.
*/
public static Uri buildConversationMessagesSearchUri(final String conversationId,
final String searchText) {
return CONVERSATION_MESSAGES_SEARCH_URI.buildUpon()
.appendPath(conversationId)
.appendQueryParameter(SEARCH_QUERY_PARAMETER, searchText)
.build();
}
public static void notifyMessagesChanged(final String conversationId) {
final Uri uri = buildConversationMessagesUri(conversationId);
final Context context = Factory.get().getApplicationContext();
@@ -319,6 +338,19 @@ public class MessagingContentProvider extends ContentProvider {
} else {
throw new IllegalArgumentException("Malformed URI " + uri);
}
case CONVERSATION_MESSAGES_SEARCH_QUERY_CODE:
if (uri.getPathSegments().size() == 3) {
// Like the query above this is a raw query, so selection/sorting is invalid.
if (selection == null && selectionArgs == null && sortOrder == null) {
return queryConversationMessagesSearch(uri.getPathSegments().get(2),
uri.getQueryParameter(SEARCH_QUERY_PARAMETER));
} else {
throw new IllegalArgumentException(
"Cannot set selection or sort order with this query");
}
} else {
throw new IllegalArgumentException("Malformed URI " + uri);
}
case CONVERSATION_IMAGES_QUERY_CODE:
queryBuilder.setTables(ConversationImagePartsView.getViewName());
if (uri.getPathSegments().size() == 2) {
@@ -366,6 +398,24 @@ public class MessagingContentProvider extends ContentProvider {
return cursor;
}
private Cursor queryConversationMessagesSearch(final String conversationId,
final String searchText) {
final String[] queryArgs = { conversationId, "%" + escapeLikePattern(searchText) + "%" };
final Cursor cursor = getDatabaseWrapper().rawQuery(
ConversationMessageData.getConversationMessagesSearchQuerySql(), queryArgs);
// Follow the plain message uri so that incoming messages refresh the results as well.
cursor.setNotificationUri(getContext().getContentResolver(),
buildConversationMessagesUri(conversationId));
return cursor;
}
private static String escapeLikePattern(final String text) {
if (TextUtils.isEmpty(text)) {
return "";
}
return text.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_");
}
@Override
public String getType(@NonNull final Uri uri) {
final StringBuilder sb = new
@@ -0,0 +1,146 @@
/*
* Copyright (C) 2015 The Android Open Source Project
* Copyright (C) 2024-2025 The LineageOS Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.messaging.datamodel.action;
import android.content.ContentValues;
import android.database.Cursor;
import android.database.sqlite.SQLiteDoneException;
import android.database.sqlite.SQLiteStatement;
import android.net.Uri;
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;
import com.android.messaging.datamodel.DatabaseHelper.MessageColumns;
import com.android.messaging.datamodel.DatabaseWrapper;
import com.android.messaging.datamodel.MessagingContentProvider;
import com.android.messaging.Factory;
import com.android.messaging.util.Assert;
/**
* Marks the latest message in a conversation as unread (iOS-style thread unread).
*/
public class MarkAsUnreadAction extends Action implements Parcelable {
private static final String KEY_CONVERSATION_ID = "conversation_id";
public static void markAsUnread(final String conversationId) {
final MarkAsUnreadAction action = new MarkAsUnreadAction(conversationId);
action.start();
}
private MarkAsUnreadAction(final String conversationId) {
actionParameters.putString(KEY_CONVERSATION_ID, conversationId);
}
@Override
protected Object executeAction() {
final String conversationId = actionParameters.getString(KEY_CONVERSATION_ID);
if (TextUtils.isEmpty(conversationId)) {
return null;
}
final DatabaseWrapper db = DataModel.get().getDatabase();
db.beginTransaction();
try {
String latestMessageId;
try {
final SQLiteStatement latestMessageIdSql =
BugleDatabaseOperations.getQueryMessagesLatestMessageStatement(
db, conversationId);
latestMessageId = latestMessageIdSql.simpleQueryForString();
} catch (final SQLiteDoneException e) {
return null;
}
if (TextUtils.isEmpty(latestMessageId)) {
return null;
}
final ContentValues values = new ContentValues();
values.put(MessageColumns.READ, 0);
values.put(MessageColumns.SEEN, 0);
final int count = db.update(DatabaseHelper.MESSAGES_TABLE, values,
MessageColumns._ID + "=? AND (" + MessageColumns.READ + " != 0 OR "
+ MessageColumns.SEEN + " != 0)",
new String[] { latestMessageId });
if (count > 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<MarkAsUnreadAction> 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);
}
}
@@ -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<RenameConversationAction> 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);
}
}
@@ -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<ContactPickerData> 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();
@@ -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 "
@@ -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,77 +38,82 @@ 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<Cursor> {
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<ParticipantData> 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<Cursor> 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)) {
if (!isBound(bindingId)) {
LogUtil.w(LogUtil.BUGLE_TAG, "Loader created after unbinding conversation profile");
return null;
}
switch (id) {
case CONVERSATION_OPTIONS_LOADER: {
case CONVERSATION_METADATA_LOADER: {
final Uri uri =
MessagingContentProvider.buildConversationMetadataUri(mConversationId);
return new BoundCursorLoader(bindingId, mContext, uri,
new String[]{}, null, null, null);
ConversationListItemData.PROJECTION, null, null, null);
}
case PARTICIPANT_LOADER: {
final Uri uri =
MessagingContentProvider
.buildConversationParticipantsUri(mConversationId);
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");
}
Assert.fail("Unknown loader id for the conversation profile!");
return null;
}
}
/**
* {@inheritDoc}
*/
@Override
public void onLoadFinished(@NonNull final Loader<Cursor> loader, final Cursor data) {
final BoundCursorLoader cursorLoader = (BoundCursorLoader) loader;
if (isBound(cursorLoader.getBindingId())) {
if (!isBound(cursorLoader.getBindingId()) || mListener == null) {
LogUtil.w(LogUtil.BUGLE_TAG, "Loader finished after unbinding conversation profile");
return;
}
switch (loader.getId()) {
case CONVERSATION_OPTIONS_LOADER:
mListener.onOptionsCursorUpdated(this, data);
case CONVERSATION_METADATA_LOADER: {
ConversationListItemData conversation = null;
if (data != null && data.moveToFirst()) {
conversation = new ConversationListItemData();
conversation.bind(data);
}
mListener.onConversationMetadataUpdated(this, conversation);
break;
}
case PARTICIPANT_LOADER:
mParticipantData.bind(data);
@@ -117,25 +122,20 @@ public class PeopleAndOptionsData extends BindableData implements
break;
default:
Assert.fail("Unknown loader id for PeopleAndOptionsFragment!");
Assert.fail("Unknown loader id for the conversation profile!");
break;
}
} else {
LogUtil.w(LogUtil.BUGLE_TAG,
"Loader finished after unbinding PeopleAndOptionsFragment");
}
}
/**
* {@inheritDoc}
*/
@Override
public void onLoaderReset(@NonNull final Loader<Cursor> loader) {
final BoundCursorLoader cursorLoader = (BoundCursorLoader) loader;
if (isBound(cursorLoader.getBindingId())) {
if (!isBound(cursorLoader.getBindingId()) || mListener == null) {
return;
}
switch (loader.getId()) {
case CONVERSATION_OPTIONS_LOADER:
mListener.onOptionsCursorUpdated(this, null);
case CONVERSATION_METADATA_LOADER:
mListener.onConversationMetadataUpdated(this, null);
break;
case PARTICIPANT_LOADER:
@@ -143,20 +143,17 @@ public class PeopleAndOptionsData extends BindableData implements
break;
default:
Assert.fail("Unknown loader id for PeopleAndOptionsFragment!");
Assert.fail("Unknown loader id for the conversation profile!");
break;
}
} else {
LogUtil.w(LogUtil.BUGLE_TAG, "Loader reset after unbinding PeopleAndOptionsFragment");
}
}
public void init(final LoaderManager loaderManager,
final BindingBase<PeopleAndOptionsData> binding) {
final BindingBase<ConversationProfileData> 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<PeopleAndOptionsData> binding,
public void setDestinationBlocked(final BindingBase<ConversationProfileData> 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;
}
@@ -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<Cursor> {
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<ConversationSearchData> 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<Cursor> 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<Cursor> 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<Cursor> 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;
}
}
}
@@ -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;
}
}
@@ -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));
}
}
@@ -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
*/
@@ -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
@@ -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<DraftMessageData> 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));
}
}
@@ -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().
@@ -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<MessagePartData> 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<Intent> 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<MessagePartData> 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);
titleView.setContentDescription(vocalizedConversationName);
if (getActivity() != null) {
getActivity().setTitle(conversationName);
} else {
}
} 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()) {
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();
} else {
actionBar.show();
}
}
updateHomeConversationHeader();
}
@Override
@@ -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
});
}
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();
}
@@ -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<SelectedConversation> 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,9 +167,29 @@ public abstract class AbstractConversationListActivity extends BugleActionBarAct
return isInConversationListSelectMode();
}
private void showHomeDeleteAlert(
final Collection<SelectedConversation> conversations) {
if (!ensureDefaultSmsAppForDelete()) {
return;
}
HomeDeleteAlertDialog.show(this, conversations.size(),
new HomeDeleteAlertDialog.Listener() {
@Override
public void onActionBarDelete(final Collection<SelectedConversation> conversations) {
if (!PhoneUtils.getDefault().isDefaultSmsApp()) {
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;
@@ -150,6 +204,60 @@ public abstract class AbstractConversationListActivity extends BugleActionBarAct
getString(R.string.requires_default_sms_change_button)),
null /* interactions */,
null /* placement */);
return false;
}
private void performDeleteConversations(
final Collection<SelectedConversation> conversations) {
performDeleteConversations(conversations, false /* reportSpam */);
}
private void performDeleteConversations(
final Collection<SelectedConversation> 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<SelectedConversation> 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<SelectedConversation> conversations) {
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();
}
}
@@ -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<Object> 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);
}
@@ -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();
final Activity activity = getActivity();
if (activity != null) {
activity.overridePendingTransition(enterAnim, R.anim.home_activity_hold);
}
private void ensureHomeContactsFragment() {
if (mHomeContactsHost == null
|| getChildFragmentManager().findFragmentById(R.id.home_contacts_host) != null) {
return;
}
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,9 +1482,11 @@ public class ConversationListFragment extends Fragment implements ConversationLi
mHomePinnedEditMode = enabled;
updateHomeEditButtonForPinnedMode();
updateSearchBarForPinnedEditMode();
if (refreshList) {
refreshPinnedStrip();
updateUi();
}
}
private void updateSearchBarForPinnedEditMode() {
if (mSearchBar == null) {
@@ -1171,8 +1533,8 @@ public class ConversationListFragment extends Fragment implements ConversationLi
final Cursor cursor = mAdapter.getCursor();
final boolean editMode = mHomePinnedEditMode && !isSelectionMode();
final List<String> 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;
}
}
@@ -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<SnackBarInteraction> 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);
}
}
}
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();
}
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) {
@@ -104,9 +104,9 @@ public class ConversationListSwipeHelper implements OnItemTouchListener {
mInitialY = event.getY();
final View viewAtPoint = mRecyclerView.findChildViewUnder(mInitialX, mInitialY);
if (viewAtPoint instanceof ConversationListItemView) {
final ConversationListItemView child = (ConversationListItemView) viewAtPoint;
if (viewAtPoint instanceof ConversationListItemView &&
child != null && child.isSwipeAnimatable()) {
if (child.isSwipeAnimatable()) {
// Begin detecting swipe on the target for the rest of the gesture.
mListItemView = child;
if (mListItemView.isAnimating()) {
@@ -115,6 +115,9 @@ public class ConversationListSwipeHelper implements OnItemTouchListener {
} else {
mListItemView = null;
}
} else {
mListItemView = null;
}
}
break;
case MotionEvent.ACTION_MOVE:
@@ -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);
}
}
@@ -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<ParticipantData> mOtherParticipants;
private final Binding<PeopleAndOptionsData> 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<ParticipantData> 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<String> 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<ParticipantData> {
public PeopleListAdapter(final Context context) {
super(context, R.layout.people_list_item_view, new ArrayList<>());
}
public void updateParticipants(final List<ParticipantData> 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;
}
}
}
@@ -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());
}
}
@@ -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<RecipientEntry> 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<ParticipantData> 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);
}
}
@@ -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<RecipientEntry> selected);
}
private final Binding<ContactPickerData> mBinding = BindingBase.createBinding(this);
private final LinkedHashMap<String, RecipientEntry> 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<ParticipantData> getSelectedParticipants() {
final ArrayList<ParticipantData> 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();
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<ParticipantData> 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);
}
}
@@ -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<ConversationSearchData> 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;
}
}
}
@@ -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);
}
}
@@ -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<Uri> 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<MediaItem> 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<MediaItem> 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<MediaItem> queryMedia(final Activity activity) {
final List<MediaItem> 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<String> 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<Holder> {
interface Listener {
void onItemClick(Uri uri);
}
private final Listener mListener;
private final List<MediaItem> mItems = new ArrayList<>();
private int mThumbSize;
Adapter(final Listener listener) {
mListener = listener;
}
void setItems(final List<MediaItem> 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);
}
}
}
@@ -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<ParticipantData> participants) {
final Intent intent = new Intent(context, HomeGroupNameActivity.class);
intent.putParcelableArrayListExtra(EXTRA_PARTICIPANTS, participants);
return intent;
}
private ArrayList<ParticipantData> 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);
}
}
@@ -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<String> pinnedIds, final boolean editMode, final boolean selectionMode,
final Listener listener) {
final List<String> 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,12 +148,30 @@ 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++;
column++;
@@ -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();
@@ -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<ConversationProfileData> mBinding = BindingBase.createBinding(this);
private String mConversationId;
private ConversationListItemData mConversation;
private List<ParticipantData> 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<ParticipantData> 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<String> 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<String> 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);
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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<BlockedParticipantsData> 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));
}
}
}
@@ -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);
}
}
}
@@ -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();
}
}
@@ -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);
}
}
@@ -177,4 +177,5 @@ class CameraMediaChooser extends MediaChooser {
private static boolean hasCameraPermission() {
return OsUtil.hasPermission(Manifest.permission.CAMERA);
}
}
@@ -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);
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);
}
});
return view;
}
}
@@ -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<PickVisualMediaRequest> mPickMultipleMedia;
private final ActivityResultLauncher<String[]> 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<Uri> 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
@@ -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;
@@ -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,29 +358,162 @@ 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.
} else if (isTouchExplorationEnabled) {
startingMediaType = MEDIA_TYPE_AUDIO;
}
}
}
if (mSelectedChooser == null) {
for (final MediaChooser chooser : mEnabledChoosers) {
@@ -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;
}
@@ -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 =
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();
}
@@ -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) {
@@ -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
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android"
android:duration="260"
android:interpolator="@android:interpolator/fast_out_slow_in"
android:shareInterpolator="true">
<alpha
android:fromAlpha="1"
android:toAlpha="0" />
<translate
android:fromYDelta="0"
android:toYDelta="8%" />
</set>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<alpha xmlns:android="http://schemas.android.com/apk/res/android"
android:duration="300"
android:fromAlpha="1"
android:toAlpha="1" />
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android"
android:duration="300"
android:interpolator="@android:interpolator/fast_out_slow_in"
android:shareInterpolator="true">
<alpha
android:fromAlpha="0"
android:toAlpha="1" />
<translate
android:fromYDelta="12%"
android:toYDelta="0" />
</set>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<translate xmlns:android="http://schemas.android.com/apk/res/android"
android:duration="260"
android:fromYDelta="0"
android:interpolator="@android:interpolator/fast_out_linear_in"
android:toYDelta="100%p" />
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<translate xmlns:android="http://schemas.android.com/apk/res/android"
android:duration="300"
android:fromYDelta="100%p"
android:interpolator="@android:interpolator/fast_out_slow_in"
android:toYDelta="0" />
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/home_surface" />
<corners android:radius="@dimen/looply_radius_lg" />
</shape>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/home_bg" />
<corners
android:topLeftRadius="@dimen/looply_radius_lg"
android:topRightRadius="@dimen/looply_radius_lg" />
</shape>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/home_mediapicker_sheet_bg" />
<corners
android:topLeftRadius="@dimen/looply_radius_xl"
android:topRightRadius="@dimen/looply_radius_xl" />
</shape>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="@color/home_compose_attach_circle_bg" />
</shape>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/home_chip_bg" />
<corners android:radius="@dimen/looply_radius_pill" />
</shape>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/home_bg" />
<corners
android:topLeftRadius="@dimen/looply_radius_xl"
android:topRightRadius="@dimen/looply_radius_xl" />
</shape>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="@dimen/looply_radius_pill" />
<solid android:color="@color/home_compose_pill_bg" />
</shape>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
android:color="@color/home_surface_elevated">
<item>
<shape android:shape="oval">
<solid android:color="@color/home_accent" />
</shape>
</item>
</ripple>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
android:color="@color/home_compose_voice_ripple">
<item>
<shape android:shape="oval">
<solid android:color="@color/home_compose_voice_bg" />
</shape>
</item>
</ripple>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape android:shape="oval">
<solid android:color="@color/home_accent" />
</shape>
</item>
<item
android:drawable="@drawable/ic_home_check_on_accent"
android:gravity="center"
android:width="@dimen/home_contact_selected_check_size"
android:height="@dimen/home_contact_selected_check_size" />
</layer-list>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/home_bg" />
<corners
android:topLeftRadius="@dimen/looply_radius_xl"
android:topRightRadius="@dimen/looply_radius_xl" />
</shape>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="#1AFF3B30" />
</shape>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/home_text_tertiary" />
<corners android:radius="@dimen/looply_radius_pill" />
</shape>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="@dimen/looply_radius_md" />
<solid android:color="@color/home_search_field_bg" />
</shape>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="@dimen/looply_radius_pill" />
<solid android:color="@color/home_bg" />
</shape>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="@dimen/looply_radius_xl" />
<solid android:color="@color/home_surface" />
</shape>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners
android:topLeftRadius="@dimen/looply_radius_xl"
android:topRightRadius="@dimen/looply_radius_xl"
android:bottomLeftRadius="0dp"
android:bottomRightRadius="0dp" />
<solid android:color="@color/home_mediapicker_sheet_bg" />
</shape>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="@dimen/looply_radius_pill" />
<solid android:color="@color/home_mediapicker_tile_bg" />
</shape>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
android:color="@color/home_surface_elevated">
<item>
<shape android:shape="rectangle">
<solid android:color="@color/home_bg" />
<stroke
android:width="1dp"
android:color="@color/home_button_stroke" />
<corners android:radius="@dimen/looply_radius_pill" />
</shape>
</item>
</ripple>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
android:color="@color/home_surface_elevated">
<item>
<shape android:shape="rectangle">
<solid android:color="@color/home_accent" />
<corners android:radius="@dimen/looply_radius_pill" />
</shape>
</item>
</ripple>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="@dimen/looply_radius_lg" />
<solid android:color="@color/home_surface" />
</shape>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
android:color="@color/home_surface_elevated">
<item>
<shape android:shape="rectangle">
<corners android:radius="@dimen/looply_radius_md" />
<solid android:color="@color/home_bg" />
</shape>
</item>
</ripple>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<ripple xmlns:android="http://schemas.android.com/apk/res/android"
android:color="@color/home_surface_elevated">
<item>
<shape android:shape="rectangle">
<corners android:radius="@dimen/looply_radius_md" />
<solid android:color="@color/home_surface" />
</shape>
</item>
</ripple>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="@dimen/looply_radius_pill" />
<solid android:color="@color/home_surface_elevated" />
</shape>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="@dimen/looply_radius_xl" />
<solid android:color="@color/home_unknown_sender_banner_bg" />
</shape>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="@color/home_unknown_sender_accent" />
</shape>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="20dp"
android:height="20dp"
android:tint="@color/looply_text_on_primary"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFF"
android:pathData="M12,4l-1.41,1.41L16.17,11H4v2h12.17l-5.58,5.59L12,20l8,-8z" />
</vector>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<!-- notifications outline -->
<path android:fillColor="#000000"
android:pathData="M12,22c1.1,0 2,-0.9 2,-2h-4c0,1.1 0.9,2 2,2zM18,16v-5c0,-3.07 -1.64,-5.64 -4.5,-6.32V4c0,-0.83 -0.67,-1.5 -1.5,-1.5S10.5,3.17 10.5,4v0.68C7.63,5.36 6,7.92 6,11v5l-2,2v1h16v-1l-2,-2zM16,17H8v-6c0,-2.48 1.51,-4.5 4,-4.5s4,2.02 4,4.5v6z"/>
</vector>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/transparent"
android:pathData="M4,8h3l1.5,-2h7L17,8h3a1,1 0,0 1,1 1v10a1,1 0,0 1,-1 1H4a1,1 0,0 1,-1 -1V9a1,1 0,0 1,1 -1z"
android:strokeWidth="1.5"
android:strokeColor="#000000"
android:strokeLineJoin="round" />
<path
android:fillColor="@android:color/transparent"
android:pathData="M12,17a3.5,3.5 0,1 0,0 -7a3.5,3.5 0,0 0,0 7z"
android:strokeWidth="1.5"
android:strokeColor="#000000" />
</vector>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="14dp"
android:height="14dp"
android:tint="@color/looply_text_on_primary"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFF"
android:pathData="M9,16.17L4.83,12l-1.42,1.41L9,19 21,7l-1.41,-1.41L9,16.17z" />
</vector>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#8E8E93"
android:pathData="M7.41,15.41L12,10.83l4.59,4.58L18,14l-6,-6 -6,6 1.41,1.41z" />
</vector>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@color/home_text_secondary"
android:pathData="M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z" />
</vector>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/transparent"
android:pathData="M12,5v14M5,12h14"
android:strokeWidth="1.8"
android:strokeColor="#000000"
android:strokeLineCap="round" />
</vector>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<!-- pencil outline -->
<path android:fillColor="#000000"
android:pathData="M3,17.25V21h3.75L17.81,9.94l-3.75,-3.75L3,17.25zM5.92,19H5v-0.92l9.06,-9.06 0.92,0.92L5.92,19zM20.71,5.63l-2.34,-2.34c-0.39,-0.39 -1.02,-0.39 -1.41,0l-1.83,1.83 3.75,3.75 1.83,-1.83c0.39,-0.39 0.39,-1.02 0,-1.41z"/>
</vector>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#000000"
android:pathData="M11.99,2C6.47,2 2,6.48 2,12s4.47,10 9.99,10C17.52,22 22,17.52 22,12S17.52,2 11.99,2zM12,20c-4.42,0 -8,-3.58 -8,-8s3.58,-8 8,-8 8,3.58 8,8 -3.58,8 -8,8zM15.5,11c0.83,0 1.5,-0.67 1.5,-1.5S16.33,8 15.5,8 14,8.67 14,9.5s0.67,1.5 1.5,1.5zM8.5,11c0.83,0 1.5,-0.67 1.5,-1.5S9.33,8 8.5,8 7,8.67 7,9.5 7.67,11 8.5,11zM12,17.5c2.33,0 4.31,-1.46 5.11,-3.5H6.89c0.8,2.04 2.78,3.5 5.11,3.5z"/>
</vector>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<!-- group add outline -->
<path android:fillColor="#000000"
android:pathData="M8,10H5V7H3v3H0v2h3v3h2v-3h3v-2z"/>
<path android:fillColor="#000000"
android:pathData="M18,11c1.66,0 2.99,-1.34 2.99,-3S19.66,5 18,5c-0.32,0 -0.63,0.05 -0.91,0.14 0.57,0.81 0.9,1.79 0.9,2.86s-0.34,2.04 -0.9,2.86c0.28,0.09 0.59,0.14 0.91,0.14zM12.5,11c1.66,0 2.99,-1.34 2.99,-3S14.16,5 12.5,5C10.84,5 9.5,6.34 9.5,8s1.34,3 3,3zM12.5,7c0.55,0 1,0.45 1,1s-0.45,1 -1,1 -1,-0.45 -1,-1 0.45,-1 1,-1z"/>
<path android:fillColor="#000000"
android:pathData="M12.5,12.5c-2,0 -6,1 -6,3V18h12v-2.5c0,-2 -4,-3 -6,-3zM8.5,16c0.22,-0.35 1.74,-1.5 4,-1.5s3.78,1.15 4,1.5h-8zM19.62,12.68c0.98,0.69 1.88,1.56 1.88,2.82V18H24v-2.5c0,-1.54 -2.51,-2.48 -4.38,-2.82z"/>
</vector>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<!-- image outline -->
<path android:fillColor="#000000"
android:pathData="M19,5v14H5V5h14m0,-2H5C3.9,3 3,3.9 3,5v14c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2V5c0,-1.1 -0.9,-2 -2,-2z"/>
<path android:fillColor="#000000"
android:pathData="M14.14,11.86l-3,3.87L9,13.14 6,17h12l-3.86,-5.14z"/>
</vector>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<!-- link outline -->
<path android:fillColor="#000000"
android:pathData="M3.9,12c0,-1.71 1.39,-3.1 3.1,-3.1h4V7H7c-2.76,0 -5,2.24 -5,5s2.24,5 5,5h4v-1.9H7c-1.71,0 -3.1,-1.39 -3.1,-3.1zM8,13h8v-2H8v2zM17,7h-4v1.9h4c1.71,0 3.1,1.39 3.1,3.1s-1.39,3.1 -3.1,3.1h-4V17h4c2.76,0 5,-2.24 5,-5s-2.24,-5 -5,-5z"/>
</vector>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#000000"
android:pathData="M12,8c-2.21,0 -4,1.79 -4,4s1.79,4 4,4 4,-1.79 4,-4 -1.79,-4 -4,-4zM20.94,11c-0.46,-4.17 -3.77,-7.48 -7.94,-7.94V1h-2v2.06C6.83,3.52 3.52,6.83 3.06,11H1v2h2.06c0.46,4.17 3.77,7.48 7.94,7.94V23h2v-2.06c4.17,-0.46 7.48,-3.77 7.94,-7.94H23v-2h-2.06zM12,19c-3.87,0 -7,-3.13 -7,-7s3.13,-7 7,-7 7,3.13 7,7 -3.13,7 -7,7z" />
</vector>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<!-- place outline -->
<path android:fillColor="#000000"
android:pathData="M12,2C8.13,2 5,5.13 5,9c0,5.25 7,13 7,13s7,-7.75 7,-13c0,-3.87 -3.13,-7 -7,-7zM7,9c0,-2.76 2.24,-5 5,-5s5,2.24 5,5c0,2.88 -2.88,7.19 -5,9.88C9.92,16.21 7,11.85 7,9z"/>
<path android:fillColor="#000000"
android:pathData="M12,11.5c1.38,0 2.5,-1.12 2.5,-2.5S13.38,6.5 12,6.5 9.5,7.62 9.5,9s1.12,2.5 2.5,2.5z"/>
</vector>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<!-- starred outline -->
<path android:fillColor="#000000"
android:pathData="M22,9.24l-7.19,-0.62L12,2 9.19,8.63 2,9.24l5.46,4.73L5.82,21 12,17.27 18.18,21l-1.63,-7.03L22,9.24zM12,15.4l-3.76,2.27 1,-4.28 -3.32,-2.88 4.38,-0.38L12,6.1l1.71,4.04 4.38,0.38 -3.32,2.88 1,4.28L12,15.4z"/>
</vector>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<!-- movie outline -->
<path android:fillColor="#000000"
android:pathData="M18,4l2,4h-3l-2,-4h-2l2,4h-3l-2,-4H8l2,4H7L5,4H4C2.9,4 2.01,4.9 2.01,6L2,18c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2V4h-4zM4,18V6h0.15l1,2H20v10H4z"/>
</vector>
@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="56"
android:viewportHeight="56">
<path
android:fillColor="#000000"
android:pathData="M24.5898,49.5742C25.5508,49.5742 26.2539,48.8477 26.2539,47.9336L26.2539,8.0664C26.2539,7.1524 25.5508,6.4258 24.5898,6.4258C23.6524,6.4258 22.9492,7.1524 22.9492,8.0664L22.9492,47.9336C22.9492,48.8477 23.6524,49.5742 24.5898,49.5742Z" />
<path
android:fillColor="#000000"
android:pathData="M38.2305,44.8867C39.168,44.8867 39.8711,44.1367 39.8711,43.2227L39.8711,12.7773C39.8711,11.8633 39.168,11.1133 38.2305,11.1133C37.293,11.1133 36.5664,11.8633 36.5664,12.7773L36.5664,43.2227C36.5664,44.1367 37.293,44.8867 38.2305,44.8867Z" />
<path
android:fillColor="#000000"
android:pathData="M17.793,41.0898C18.7305,41.0898 19.4336,40.3633 19.4336,39.4492L19.4336,16.5508C19.4336,15.6367 18.7305,14.9102 17.793,14.9102C16.832,14.9102 16.1289,15.6367 16.1289,16.5508L16.1289,39.4492C16.1289,40.3633 16.832,41.0898 17.793,41.0898Z" />
<path
android:fillColor="#000000"
android:pathData="M31.4102,38.5586C32.3476,38.5586 33.0742,37.832 33.0742,36.918L33.0742,19.082C33.0742,18.168 32.3476,17.4414 31.4102,17.4414C30.4727,17.4414 29.7695,18.168 29.7695,19.082L29.7695,36.918C29.7695,37.832 30.4727,38.5586 31.4102,38.5586Z" />
<path
android:fillColor="#000000"
android:pathData="M45.0508,34.3633C45.9883,34.3633 46.6914,33.6133 46.6914,32.6992L46.6914,23.3008C46.6914,22.3867 45.9883,21.6367 45.0508,21.6367C44.0898,21.6367 43.3867,22.3867 43.3867,23.3008L43.3867,32.6992C43.3867,33.6133 44.0898,34.3633 45.0508,34.3633Z" />
<path
android:fillColor="#000000"
android:pathData="M10.9727,32.5117C11.9102,32.5117 12.6133,31.7851 12.6133,30.8711L12.6133,25.1289C12.6133,24.2149 11.9102,23.4883 10.9727,23.4883C10.0117,23.4883 9.3086,24.2149 9.3086,25.1289L9.3086,30.8711C9.3086,31.7851 10.0117,32.5117 10.9727,32.5117Z" />
</vector>
@@ -1,31 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright (C) 2015 The Android Open Source Project
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<RelativeLayout
xmlns:android="http://schemas.android.com/apk/res/android"
<!-- Tappable avatar + title opens conversation details (Google Messages style). -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/conversation_title_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="fill_horizontal" >
android:layout_gravity="fill_horizontal|center_vertical"
android:gravity="center_vertical"
android:orientation="horizontal">
<LinearLayout
android:id="@+id/conversation_toolbar_identity"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingTop="4dp"
android:paddingBottom="4dp">
<com.android.messaging.ui.ContactIconView
android:id="@+id/conversation_toolbar_avatar"
android:layout_width="@dimen/home_conversation_toolbar_avatar_size"
android:layout_height="@dimen/home_conversation_toolbar_avatar_size"
android:contentDescription="@null"
app:contactIconSize="small" />
<TextView
android:id="@+id/conversation_title"
style="@style/ConversationActionBarTitleTextStyle"
android:layout_width="match_parent"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerVertical="true" />
</RelativeLayout>
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" />
</LinearLayout>
</LinearLayout>
+454 -77
View File
@@ -1,192 +1,569 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
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.
-->
<com.android.messaging.ui.conversation.ComposeMessageView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/message_compose_view_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical" >
<!-- Note it may seem off to have the layout_weight="1" on the compose bar and attachment view
when it's the media picker that expands, but this is correct because weight also works when
there's not enough space. Any views with positive weights will be shrunk to make space.
When the media picker goes full screen, it fills the parent, leaving no room for the compose
bar -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
android:clipChildren="false"
android:clipToPadding="false"
android:orientation="vertical">
<View
android:id="@+id/top_margin"
android:layout_width="match_parent"
android:layout_height="16dp" />
android:layout_height="0dp" />
<include layout="@layout/attachment_preview"
android:id="@+id/attachment_draft_view"
style="@style/ComposeMessageViewDraftViewStyle"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginBottom="4dp"
android:layout_weight="1"
android:visibility="gone" />
<RelativeLayout
android:id="@+id/subject_view"
style="@style/SubjectViewStyle"
android:paddingTop="@dimen/compose_message_subject_top_padding"
android:paddingBottom="@dimen/compose_message_subject_bottom_padding"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="gone"
android:clipChildren="false"
android:clipToPadding="false"
android:orientation="horizontal">
<com.android.messaging.ui.PlainTextEditText
android:background="@drawable/subject_editor_bubble"
android:id="@+id/compose_subject_text"
style="@style/ConversationComposeSubjectText"
android:hint="@string/compose_message_view_subject_hint_text"
android:layout_height="wrap_content"
android:layout_width="match_parent"
android:imeOptions="flagNoExtractUi" />
<ImageButton
android:id="@+id/delete_subject_button"
style="@style/AttachmentPreviewCloseButtonStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@null"
android:src="@drawable/ic_remove_light"
android:translationY="@dimen/compose_message_subject_cancel_top_offset"
android:contentDescription="@string/delete_subject_content_description" />
</RelativeLayout>
<!-- Holds all views that create the actual compose box -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:baselineAligned="false">
<!-- Contains compose message bubble and character counter for SMS or attachments size
for MMS which should be left aligned -->
<!-- Compose bar stays above the icon grid; the grid grows below and lifts the input. -->
<LinearLayout
android:layout_width="0dp"
android:layout_weight="1"
android:id="@+id/home_compose_bottom_stack"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clipChildren="false"
android:clipToOutline="false"
android:orientation="vertical">
<!-- Contains media button and compose message bubble whose centers should be
vertically aligned -->
<!-- Google Messages–style compose row: pill (+ | text | emoji | gallery) + voice/send -->
<LinearLayout
android:id="@+id/home_conversation_compose_bar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<ImageButton
android:id="@+id/attach_media_button"
style="@style/ComposeMessageViewAttachMediaButtonStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:src="@drawable/ic_attachment_dark"
android:minHeight="48dp"
android:minWidth="48dp"
android:paddingTop="@dimen/compose_message_attachment_padding_topBottom"
android:paddingBottom="@dimen/compose_message_attachment_padding_topBottom"
android:contentDescription="@string/attachMediaButtonContentDescription" />
<com.android.messaging.ui.PlainTextEditText
android:id="@+id/compose_message_text"
style="@style/ConversationComposeSendText"
android:layout_height="wrap_content"
android:layout_width="0dp"
android:layout_weight="1"
android:layout_gravity="center"
android:background="@drawable/msg_bubble_input_rounded"
android:hint="@string/compose_message_view_hint_text"
android:imeOptions="actionSend|flagNoEnterAction|flagNoExtractUi" />
android:background="@color/home_bg"
android:baselineAligned="false"
android:clipChildren="false"
android:clipToPadding="false"
android:gravity="bottom"
android:orientation="horizontal"
android:paddingStart="@dimen/looply_space_md"
android:paddingTop="@dimen/looply_space_sm"
android:paddingEnd="@dimen/looply_space_md"
android:paddingBottom="@dimen/looply_space_md">
</LinearLayout>
<TextView
android:id="@+id/message_body_size"
style="@style/ComposeMessageViewMessageBodySizeStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:singleLine="true"
android:maxLines="1"
android:visibility="invisible" />
</LinearLayout>
<LinearLayout
android:orientation="vertical"
android:layout_width="wrap_content"
android:layout_height="wrap_content">
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_gravity="bottom"
android:layout_weight="1"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="@dimen/home_conversation_compose_pill_height"
android:background="@drawable/home_bg_compose_pill"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingStart="@dimen/looply_space_xs"
android:paddingEnd="@dimen/looply_space_xs">
<FrameLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
style="@style/ComposeMessageViewFrameLayoutStyle"
android:layout_gravity="top" >
<com.android.messaging.ui.conversation.SimIconView
android:id="@+id/self_send_icon"
android:layout_width="@dimen/conversation_message_contact_icon_size"
android:layout_height="@dimen/conversation_message_contact_icon_size"
android:importantForAccessibility="no"
app:contactIconSize="normal"
app:reveal="true"
android:clickable="true" />
android:layout_width="@dimen/home_compose_attach_size"
android:layout_height="@dimen/home_compose_attach_size"
android:layout_marginStart="@dimen/looply_space_xs">
<ImageButton
android:id="@+id/send_message_button"
android:layout_width="@dimen/conversation_message_contact_icon_size"
android:layout_height="@dimen/conversation_message_contact_icon_size"
android:src="@drawable/ic_send_light"
android:background="@drawable/send_arrow_background"
android:contentDescription="@string/sendButtonContentDescription"
android:visibility="gone" />
android:id="@+id/attach_media_button"
android:layout_width="@dimen/home_compose_attach_size"
android:layout_height="@dimen/home_compose_attach_size"
android:background="@drawable/home_bg_compose_attach_circle"
android:contentDescription="@string/attachMediaButtonContentDescription"
android:padding="@dimen/home_compose_attach_icon_padding"
android:scaleType="centerInside"
app:srcCompat="@drawable/ic_home_compose_plus"
app:tint="@color/home_accent" />
</FrameLayout>
<com.android.messaging.ui.PlainTextEditText
android:id="@+id/compose_message_text"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
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" />
<ImageButton
android:id="@+id/home_compose_emoji_button"
android:layout_width="36dp"
android:layout_height="36dp"
android:background="@android:color/transparent"
android:contentDescription="@string/home_compose_emoji_content_description"
android:padding="6dp"
android:scaleType="centerInside"
app:srcCompat="@drawable/ic_home_emoji"
app:tint="@color/home_text_primary" />
<ImageButton
android:id="@+id/home_compose_gallery_button"
android:layout_width="36dp"
android:layout_height="36dp"
android:layout_marginEnd="@dimen/looply_space_xs"
android:background="@android:color/transparent"
android:contentDescription="@string/home_compose_gallery_content_description"
android:padding="6dp"
android:scaleType="centerInside"
app:srcCompat="@drawable/ic_home_image_line"
app:tint="@color/home_text_primary" />
</LinearLayout>
<TextView
android:id="@+id/mms_indicator"
style="@style/MmsIndicatorStyle"
android:id="@+id/message_body_size"
style="@style/ComposeMessageViewMessageBodySizeStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:singleLine="true"
android:maxLines="1"
android:visibility="invisible"
android:text="@string/mms_text" />
</LinearLayout>
</LinearLayout>
android:singleLine="true"
android:visibility="gone" />
</LinearLayout>
<FrameLayout
android:id="@+id/mediapicker_container"
android:layout_width="@dimen/home_conversation_compose_voice_size"
android:layout_height="@dimen/home_conversation_compose_voice_size"
android:layout_gravity="bottom"
android:layout_marginStart="@dimen/looply_space_sm">
<ImageButton
android:id="@+id/home_conversation_voice_button"
android:layout_width="@dimen/home_conversation_compose_voice_size"
android:layout_height="@dimen/home_conversation_compose_voice_size"
android:layout_gravity="center"
android:background="@drawable/home_bg_compose_voice_circle"
android:contentDescription="@string/home_conversation_voice_content_description"
android:padding="12dp"
android:scaleType="centerInside"
app:srcCompat="@drawable/ic_home_voice_wave"
app:tint="@color/home_accent" />
<FrameLayout
android:id="@+id/home_conversation_send_column"
android:layout_width="@dimen/home_conversation_compose_voice_size"
android:layout_height="@dimen/home_conversation_compose_voice_size"
android:layout_gravity="center"
android:visibility="gone">
<FrameLayout
android:id="@+id/home_conversation_send_button_container"
android:layout_width="@dimen/home_conversation_compose_send_size"
android:layout_height="@dimen/home_conversation_compose_send_size"
android:layout_gravity="center">
<com.android.messaging.ui.conversation.SimIconView
android:id="@+id/self_send_icon"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clickable="true"
android:importantForAccessibility="no"
app:contactIconSize="small"
app:reveal="true" />
<ImageButton
android:id="@+id/send_message_button"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/home_bg_compose_send_circle"
android:contentDescription="@string/sendButtonContentDescription"
android:padding="10dp"
android:scaleType="centerInside"
android:src="@drawable/ic_send_light"
android:visibility="gone"
app:tint="@android:color/white" />
</FrameLayout>
<TextView
android:id="@+id/mms_indicator"
style="@style/MmsIndicatorStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|center_horizontal"
android:maxLines="1"
android:singleLine="true"
android:text="@string/mms_text"
android:visibility="gone" />
</FrameLayout>
</FrameLayout>
</LinearLayout>
<FrameLayout
android:id="@+id/home_compose_attachment_sheet_container"
android:layout_width="match_parent"
android:layout_height="0dp"
android:clipChildren="true"
android:visibility="gone">
<include
layout="@layout/home_mediapicker_sheet"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="0" />
android:layout_gravity="top" />
</FrameLayout>
<FrameLayout
android:id="@+id/mediapicker_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clipChildren="true"
android:visibility="gone" />
</LinearLayout>
</LinearLayout>
</com.android.messaging.ui.conversation.ComposeMessageView>
@@ -15,54 +15,95 @@
See the License for the specific language governing permissions and
limitations under the License.
-->
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/home_conversation_chrome_bg"
android:orientation="vertical"
android:weightSum="1.0">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipChildren="false"
android:clipToPadding="false"
android:orientation="vertical">
<!-- This FrameLayout will take all available height (excluding media picker)
but setting height to 0dp causes it not to shrink properly in KK, so a height of match_parent
works equally well -->
<include layout="@layout/home_conversation_header" />
<include layout="@layout/home_conversation_message_selection_header" />
<FrameLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1" >
<!-- Message list -->
android:layout_weight="1">
<LinearLayout
android:id="@+id/conversation_message_sheet"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/home_bg_conversation_sheet"
android:clipChildren="false"
android:clipToPadding="false"
android:orientation="vertical">
<include
layout="@layout/home_conversation_unknown_sender_banner"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/looply_space_md"
android:layout_marginTop="@dimen/looply_space_md"
android:layout_marginEnd="@dimen/looply_space_md" />
<FrameLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
<androidx.recyclerview.widget.RecyclerView
android:id="@android:id/list"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:listSelector="@android:color/transparent"
android:paddingTop="@dimen/action_bar_height"
android:clipToPadding="false" />
android:paddingTop="0dp" />
<include layout="@layout/home_conversation_empty" />
</FrameLayout>
</LinearLayout>
<View
android:id="@+id/conversation_compose_divider"
android:layout_width="match_parent"
android:layout_height="4dp"
android:layout_gravity="bottom"
android:background="@drawable/conversation_compose_divider_gradient"
android:alpha="0"
android:background="@drawable/conversation_compose_divider_gradient"
android:importantForAccessibility="no" />
<include layout="@layout/sim_selector_view"
<include
android:id="@+id/sim_selector"
layout="@layout/sim_selector_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone" />
</FrameLayout>
<!-- Attachments to send, compose message view, media picker. -->
<include layout="@layout/compose_message_view"
android:id="@+id/message_compose_view_container"
<FrameLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"/>
android:layout_height="wrap_content"
android:background="@color/home_bg"
android:clipChildren="false"
android:clipToPadding="false">
<include
android:id="@+id/message_compose_view_container"
layout="@layout/compose_message_view"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<include layout="@layout/home_conversation_message_action_bar" />
</FrameLayout>
</LinearLayout>
</LinearLayout>
@@ -33,25 +33,46 @@
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">
<!-- Header with pill actions (Telegram proportions) -->
<LinearLayout
<!-- Header with pill actions (Telegram proportions). The title is centered on the header
rather than on the space left by the side buttons, so it does not shift when the
selection header swaps in with differently sized buttons. -->
<FrameLayout
android:id="@+id/home_header"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clipChildren="false"
android:clipToPadding="false"
android:gravity="center_vertical"
android:minHeight="52dp"
android:orientation="horizontal"
android:minHeight="@dimen/home_header_height"
android:paddingStart="12dp"
android:paddingTop="6dp"
android:paddingEnd="12dp"
android:paddingBottom="6dp">
android:paddingBottom="5dp">
<TextView
android:id="@+id/home_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="start|center_vertical"
android:layout_marginEnd="@dimen/home_header_title_inset"
android:ellipsize="end"
android:gravity="start|center_vertical"
android:includeFontPadding="false"
android:maxLines="1"
android:text="@string/home_title"
android:textColor="@color/home_text_primary"
android:textSize="17sp"
android:textStyle="bold" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end|center_vertical"
android:clipChildren="false"
android:clipToPadding="false"
android:gravity="center_vertical"
android:orientation="horizontal">
<FrameLayout
android:layout_width="wrap_content"
@@ -91,21 +112,11 @@
app:srcCompat="@drawable/ic_checkmark_circle_primary" />
</FrameLayout>
<TextView
android:id="@+id/home_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center"
android:text="@string/home_title"
android:textColor="@color/home_text_primary"
android:textSize="17sp"
android:textStyle="bold" />
<ImageButton
android:id="@+id/home_filter_button"
android:layout_width="40dp"
android:layout_height="45dp"
android:layout_marginStart="@dimen/looply_space_sm"
android:background="@drawable/home_bg_circle"
android:clipChildren="false"
android:clipToPadding="false"
@@ -118,6 +129,7 @@
android:scaleType="fitCenter"
android:src="@drawable/ic_home_menu_lines"
android:tint="@color/home_text_primary" />
</LinearLayout>
<ImageButton
android:id="@+id/start_new_conversation_button"
@@ -125,47 +137,44 @@
android:layout_height="0dp"
android:visibility="gone"
android:contentDescription="@string/home_compose_content_description" />
</LinearLayout>
</FrameLayout>
<LinearLayout
<FrameLayout
android:id="@+id/home_selection_header"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:minHeight="52dp"
android:orientation="horizontal"
android:minHeight="@dimen/home_header_height"
android:paddingStart="12dp"
android:paddingTop="6dp"
android:paddingEnd="12dp"
android:paddingBottom="6dp"
android:paddingBottom="5dp"
android:visibility="gone">
<ImageButton
android:id="@+id/home_selection_done"
android:layout_width="36dp"
android:layout_height="36dp"
android:background="@android:color/transparent"
android:contentDescription="@string/home_selection_done"
android:padding="0dp"
android:scaleType="fitCenter"
app:srcCompat="@drawable/ic_checkmark_circle_primary" />
<TextView
android:layout_width="0dp"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center"
android:layout_gravity="start|center_vertical"
android:layout_marginEnd="@dimen/home_header_title_inset"
android:ellipsize="end"
android:gravity="start|center_vertical"
android:includeFontPadding="false"
android:maxLines="1"
android:text="@string/home_title"
android:textColor="@color/home_text_primary"
android:textSize="17sp"
android:textStyle="bold" />
<View
<ImageButton
android:id="@+id/home_selection_done"
android:layout_width="36dp"
android:layout_height="36dp"
android:importantForAccessibility="no" />
</LinearLayout>
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" />
</FrameLayout>
</LinearLayout>
@@ -177,12 +186,6 @@
android:clipChildren="true"
android:clipToPadding="false">
<FrameLayout
android:id="@+id/home_contacts_host"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone" />
<androidx.recyclerview.widget.RecyclerView
android:id="@android:id/list"
android:layout_width="match_parent"
@@ -201,6 +204,8 @@
android:layout_marginBottom="@dimen/conversation_list_empty_text_bottom_margin"
android:visibility="gone" />
<include layout="@layout/home_list_profile_popup" />
<LinearLayout
android:id="@+id/home_search_bar_container"
android:layout_width="match_parent"
@@ -347,19 +352,6 @@
android:layout_gravity="center" />
</FrameLayout>
<FrameLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1">
<include
android:id="@+id/home_nav_contacts"
layout="@layout/home_bottom_nav_tab_item"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center" />
</FrameLayout>
<FrameLayout
android:layout_width="0dp"
android:layout_height="match_parent"

Some files were not shown because too many files have changed in this diff Show More