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
@@ -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,125 +38,122 @@ import com.android.messaging.util.LogUtil;
import java.util.List;
/**
* Services data needs for PeopleAndOptionsFragment.
* Serves the conversation metadata and the participant list needed by the conversation profile
* screen.
*/
public class PeopleAndOptionsData extends BindableData implements
public class ConversationProfileData extends BindableData implements
LoaderManager.LoaderCallbacks<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)) {
switch (id) {
case CONVERSATION_OPTIONS_LOADER: {
final Uri uri =
MessagingContentProvider.buildConversationMetadataUri(mConversationId);
return new BoundCursorLoader(bindingId, mContext, uri,
new String[]{}, null, null, null);
}
case PARTICIPANT_LOADER: {
final Uri uri =
MessagingContentProvider
.buildConversationParticipantsUri(mConversationId);
return new BoundCursorLoader(bindingId, mContext, uri,
ParticipantData.ParticipantsQuery.PROJECTION, null, null, null);
}
default:
Assert.fail("Unknown loader id for PeopleAndOptionsFragment!");
break;
}
} else {
LogUtil.w(LogUtil.BUGLE_TAG, "Loader created after unbinding PeopleAndOptionsFragment");
if (!isBound(bindingId)) {
LogUtil.w(LogUtil.BUGLE_TAG, "Loader created after unbinding conversation profile");
return null;
}
switch (id) {
case CONVERSATION_METADATA_LOADER: {
final Uri uri =
MessagingContentProvider.buildConversationMetadataUri(mConversationId);
return new BoundCursorLoader(bindingId, mContext, uri,
ConversationListItemData.PROJECTION, null, null, null);
}
case PARTICIPANT_LOADER: {
final Uri uri =
MessagingContentProvider.buildConversationParticipantsUri(mConversationId);
return new BoundCursorLoader(bindingId, mContext, uri,
ParticipantData.ParticipantsQuery.PROJECTION, null, null, null);
}
default:
Assert.fail("Unknown loader id for the conversation profile!");
return null;
}
return null;
}
/**
* {@inheritDoc}
*/
@Override
public void onLoadFinished(@NonNull final Loader<Cursor> loader, final Cursor data) {
final BoundCursorLoader cursorLoader = (BoundCursorLoader) loader;
if (isBound(cursorLoader.getBindingId())) {
switch (loader.getId()) {
case CONVERSATION_OPTIONS_LOADER:
mListener.onOptionsCursorUpdated(this, data);
break;
case PARTICIPANT_LOADER:
mParticipantData.bind(data);
mListener.onParticipantsListLoaded(this,
mParticipantData.getParticipantListExcludingSelf());
break;
default:
Assert.fail("Unknown loader id for PeopleAndOptionsFragment!");
break;
if (!isBound(cursorLoader.getBindingId()) || mListener == null) {
LogUtil.w(LogUtil.BUGLE_TAG, "Loader finished after unbinding conversation profile");
return;
}
switch (loader.getId()) {
case CONVERSATION_METADATA_LOADER: {
ConversationListItemData conversation = null;
if (data != null && data.moveToFirst()) {
conversation = new ConversationListItemData();
conversation.bind(data);
}
mListener.onConversationMetadataUpdated(this, conversation);
break;
}
} else {
LogUtil.w(LogUtil.BUGLE_TAG,
"Loader finished after unbinding PeopleAndOptionsFragment");
case PARTICIPANT_LOADER:
mParticipantData.bind(data);
mListener.onParticipantsListLoaded(this,
mParticipantData.getParticipantListExcludingSelf());
break;
default:
Assert.fail("Unknown loader id for the conversation profile!");
break;
}
}
/**
* {@inheritDoc}
*/
@Override
public void onLoaderReset(@NonNull final Loader<Cursor> loader) {
final BoundCursorLoader cursorLoader = (BoundCursorLoader) loader;
if (isBound(cursorLoader.getBindingId())) {
switch (loader.getId()) {
case CONVERSATION_OPTIONS_LOADER:
mListener.onOptionsCursorUpdated(this, null);
break;
if (!isBound(cursorLoader.getBindingId()) || mListener == null) {
return;
}
switch (loader.getId()) {
case CONVERSATION_METADATA_LOADER:
mListener.onConversationMetadataUpdated(this, null);
break;
case PARTICIPANT_LOADER:
mParticipantData.bind(null);
break;
case PARTICIPANT_LOADER:
mParticipantData.bind(null);
break;
default:
Assert.fail("Unknown loader id for PeopleAndOptionsFragment!");
break;
}
} else {
LogUtil.w(LogUtil.BUGLE_TAG, "Loader reset after unbinding PeopleAndOptionsFragment");
default:
Assert.fail("Unknown loader id for the conversation profile!");
break;
}
}
public void init(final LoaderManager loaderManager,
final BindingBase<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);
getActivity().setTitle(conversationName);
} else {
titleView.setContentDescription(vocalizedConversationName);
if (getActivity() != null) {
getActivity().setTitle(conversationName);
}
} else if (getActivity() != null) {
final String appName = getString(R.string.app_name);
conversationNameView.setText(appName);
titleView.setText(appName);
getActivity().setTitle(appName);
}
// When conversation is showing and media picker is not showing, then hide the action
// bar only when we are in landscape mode, with IME open.
if (mHost.isImeOpen() && UiUtils.isLandscapeMode()) {
actionBar.hide();
} else {
actionBar.show();
}
}
final boolean hideForIme = mHost.isImeOpen() && UiUtils.isLandscapeMode();
mConversationHeader.setVisibility(hideForIme ? View.GONE : View.VISIBLE);
}
public void updateActionBar(final ActionBar actionBar) {
if (mComposeMessageView != null && mComposeMessageView.updateActionBar(actionBar)) {
if (mConversationHeader != null) {
mConversationHeader.setVisibility(View.GONE);
}
return;
}
updateActionAndStatusBarColor(actionBar);
actionBar.hide();
updateHomeConversationHeader();
}
@Override
@@ -50,6 +50,13 @@ import java.util.Collection;
* as the other components).
*/
public class ConversationInputManager implements ConversationInput.ConversationInputBase {
public static final int MEDIA_PICKER_TARGET_DEFAULT = 0;
public static final int MEDIA_PICKER_TARGET_CAMERA = 1;
public static final int MEDIA_PICKER_TARGET_GALLERY = 2;
public static final int MEDIA_PICKER_TARGET_AUDIO = 3;
public static final int MEDIA_PICKER_TARGET_CONTACT = 4;
/**
* The host component where all input components are contained. This is typically the
* conversation fragment but may be mocked in test code.
@@ -189,6 +196,18 @@ public class ConversationInputManager implements ConversationInput.ConversationI
}
public void showHideMediaPicker(final boolean show, final boolean animate) {
showHideMediaPicker(show, animate, MEDIA_PICKER_TARGET_DEFAULT);
}
public boolean isMediaPickerOpen() {
return mMediaInput.mShowing;
}
public void showHideMediaPicker(final boolean show, final boolean animate,
final int mediaPickerTarget) {
if (show) {
mMediaInput.setOpenTarget(mediaPickerTarget);
}
showHideInternal(mMediaInput, show, animate);
}
@@ -322,6 +341,11 @@ public class ConversationInputManager implements ConversationInput.ConversationI
}
private MediaPicker mMediaPicker;
private int mOpenTarget = MEDIA_PICKER_TARGET_DEFAULT;
void setOpenTarget(final int openTarget) {
mOpenTarget = openTarget;
}
@Override
public boolean show(boolean animate) {
@@ -393,7 +417,26 @@ public class ConversationInputManager implements ConversationInput.ConversationI
});
}
mMediaPicker.open(MediaPicker.MEDIA_TYPE_DEFAULT, animate);
switch (mOpenTarget) {
case MEDIA_PICKER_TARGET_CAMERA:
mMediaPicker.openCamera(animate);
break;
case MEDIA_PICKER_TARGET_GALLERY:
mMediaPicker.openGallery(animate);
// System picker only — do not keep the in-app panel marked open.
return false;
case MEDIA_PICKER_TARGET_AUDIO:
mMediaPicker.openAudio(animate);
break;
case MEDIA_PICKER_TARGET_CONTACT:
mMediaPicker.openContact(animate);
// System picker only — do not keep the in-app panel marked open.
return false;
default:
mMediaPicker.open(MediaPicker.MEDIA_TYPE_DEFAULT, animate);
break;
}
mOpenTarget = MEDIA_PICKER_TARGET_DEFAULT;
return isOpen();
}
@@ -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,23 +167,97 @@ 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 onDelete() {
performDeleteConversations(conversations);
}
@Override
public void onDeleteAndRegisterSpam() {
performDeleteAndRegisterSpam(conversations);
}
});
}
private boolean ensureDefaultSmsAppForDelete() {
if (PhoneUtils.getDefault().isDefaultSmsApp()) {
return true;
}
// TODO: figure out a good way to combine this with the implementation in
// ConversationFragment doing similar things
final Activity activity = this;
UiUtils.showSnackBarWithCustomAction(this,
getWindow().getDecorView().getRootView(),
getString(R.string.requires_default_sms_app),
SnackBar.Action.createCustomAction(() -> {
final Intent intent =
UIIntents.get().getChangeDefaultSmsAppIntent(activity);
mLauncher.launch(intent);
},
getString(R.string.requires_default_sms_change_button)),
null /* interactions */,
null /* placement */);
return false;
}
private void performDeleteConversations(
final Collection<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 (!PhoneUtils.getDefault().isDefaultSmsApp()) {
// TODO: figure out a good way to combine this with the implementation in
// ConversationFragment doing similar things
final Activity activity = this;
UiUtils.showSnackBarWithCustomAction(this,
getWindow().getDecorView().getRootView(),
getString(R.string.requires_default_sms_app),
SnackBar.Action.createCustomAction(() -> {
final Intent intent =
UIIntents.get().getChangeDefaultSmsAppIntent(activity);
mLauncher.launch(intent);
},
getString(R.string.requires_default_sms_change_button)),
null /* interactions */,
null /* placement */);
if (!ensureDefaultSmsAppForDelete()) {
return;
}
@@ -158,14 +266,7 @@ public abstract class AbstractConversationListActivity extends BugleActionBarAct
R.plurals.delete_conversations_confirmation_dialog_title,
conversations.size()))
.setPositiveButton(R.string.delete_conversation_confirmation_button,
(dialog, button) -> {
for (final SelectedConversation conversation : conversations) {
DeleteConversationAction.deleteConversation(
conversation.conversationId,
conversation.timestamp);
}
exitMultiSelectState();
})
(dialog, button) -> performDeleteConversations(conversations))
.setNegativeButton(R.string.delete_conversation_decline_button, null)
.show();
}
@@ -256,7 +357,7 @@ public abstract class AbstractConversationListActivity extends BugleActionBarAct
@Override
public void onSelectionCountChanged(final int count) {
if (useHomeSelectionChrome()) {
mConversationListFragment.updateHomeSelectionDeleteEnabled(count);
mConversationListFragment.updateHomeSelectionBar(count);
hideHomeContextualActionBar();
}
}
@@ -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();
}
private void ensureHomeContactsFragment() {
if (mHomeContactsHost == null
|| getChildFragmentManager().findFragmentById(R.id.home_contacts_host) != null) {
return;
final Activity activity = getActivity();
if (activity != null) {
activity.overridePendingTransition(enterAnim, R.anim.home_activity_hold);
}
getChildFragmentManager().beginTransaction()
.replace(R.id.home_contacts_host, new HomeContactsFragment(),
HOME_CONTACTS_FRAGMENT_TAG)
.commit();
}
private void updateHomeNavHighlight() {
final boolean messagesActive = mHomeTab == HOME_TAB_MESSAGES;
applyHomeNavTabStyle(mHomeNavMessages, mHomeNavMessagesIcon, mHomeNavMessagesLabel,
messagesActive);
applyHomeNavTabStyle(mHomeNavContacts, mHomeNavContactsIcon, mHomeNavContactsLabel,
!messagesActive);
mHomeTab == HOME_TAB_MESSAGES);
}
private void applyHomeNavTabStyle(final View tab, final ImageView icon, final TextView label,
@@ -755,7 +1043,13 @@ public class ConversationListFragment extends Fragment implements ConversationLi
}
}
public void updateHomeSelectionDeleteEnabled(final int selectedCount) {
public void updateHomeSelectionBar(final int selectedCount) {
mHomeSelectionCount = selectedCount;
if (mHomeReadAllButton != null) {
mHomeReadAllButton.setText(selectedCount > 0
? R.string.home_mark_unread
: R.string.home_read_all);
}
if (mHomeSelectionDelete == null) {
return;
}
@@ -763,6 +1057,19 @@ public class ConversationListFragment extends Fragment implements ConversationLi
mHomeSelectionDelete.setEnabled(enabled);
mHomeSelectionDelete.setAlpha(1f);
mHomeSelectionDelete.setImageAlpha(enabled ? 255 : 102);
final int tintColor = ContextCompat.getColor(requireContext(),
enabled ? R.color.home_destructive : R.color.home_nav_inactive);
mHomeSelectionDelete.setImageTintList(
android.content.res.ColorStateList.valueOf(tintColor));
}
private void onHomeReadAllOrUnreadClick() {
if (mHomeSelectionCount > 0
&& getActivity() instanceof AbstractConversationListActivity) {
((AbstractConversationListActivity) getActivity()).markSelectedConversationsUnread();
return;
}
markAllVisibleConversationsRead();
}
private void markAllVisibleConversationsRead() {
@@ -772,10 +1079,44 @@ public class ConversationListFragment extends Fragment implements ConversationLi
private PopupWindow mHomePopover;
private void dismissHomePopover() {
if (mHomePopover != null && mHomePopover.isShowing()) {
mHomePopover.dismiss();
dismissHomePopoverAndThen(null);
}
/** No exit animation — use when opening selection mode to avoid stacked motion. */
private void dismissHomePopoverImmediate() {
if (mHomePopover == null || !mHomePopover.isShowing()) {
mHomePopover = null;
return;
}
final View content = mHomePopover.getContentView();
if (content != null) {
content.animate().cancel();
HomeUiMotion.popoverResetInstant(content);
}
final PopupWindow popup = mHomePopover;
mHomePopover = null;
popup.dismiss();
}
private void dismissHomePopoverAndThen(final Runnable afterDismiss) {
if (mHomePopover == null || !mHomePopover.isShowing()) {
mHomePopover = null;
if (afterDismiss != null) {
afterDismiss.run();
}
return;
}
final PopupWindow popup = mHomePopover;
final View content = popup.getContentView();
mHomePopover = null;
HomeUiMotion.popoverExit(content, () -> {
if (popup.isShowing()) {
popup.dismiss();
}
if (afterDismiss != null) {
afterDismiss.run();
}
});
}
private PopupWindow showHomePopover(final View anchor, final int layoutResId,
@@ -804,6 +1145,7 @@ public class ConversationListFragment extends Fragment implements ConversationLi
? Math.max(0, anchor.getWidth() - content.getMeasuredWidth())
: 0;
popup.showAsDropDown(anchor, xOff, 8);
HomeUiMotion.popoverEnter(content, alignEnd);
mHomePopover = popup;
return popup;
}
@@ -817,8 +1159,7 @@ public class ConversationListFragment extends Fragment implements ConversationLi
});
content.findViewById(R.id.popover_spam).setOnClickListener(v -> {
dismissHomePopover();
launchHomeSubScreen(() -> UIIntents.get().launchBlockedParticipantsActivity(
requireActivity()));
launchHomeSubScreen(() -> UIIntents.get().launchHomeSpamActivity(requireActivity()));
});
content.findViewById(R.id.popover_recently_deleted).setOnClickListener(v -> {
dismissHomePopover();
@@ -852,14 +1193,13 @@ public class ConversationListFragment extends Fragment implements ConversationLi
final PopupWindow popup = showHomePopover(anchor, R.layout.home_popover_edit, false);
final View content = popup.getContentView();
content.findViewById(R.id.popover_select_messages).setOnClickListener(v -> {
dismissHomePopover();
dismissHomePopoverImmediate();
if (getActivity() instanceof ConversationListActivity) {
((ConversationListActivity) getActivity()).enterHomeEditMode();
}
});
content.findViewById(R.id.popover_edit_pinned).setOnClickListener(v -> {
dismissHomePopover();
setPinnedEditMode(true);
dismissHomePopoverAndThen(() -> setPinnedEditMode(true));
});
content.findViewById(R.id.popover_name_photo).setOnClickListener(v -> {
dismissHomePopover();
@@ -981,6 +1321,12 @@ public class ConversationListFragment extends Fragment implements ConversationLi
conversationView);
}
@Override
public void onConversationAvatarClicked(final ConversationListItemData data,
final View avatarView) {
showHomeListProfilePopup(data, avatarView);
}
/**
* {@inheritDoc} from ConversationListItemView.HostInterface
*/
@@ -1016,9 +1362,6 @@ public class ConversationListFragment extends Fragment implements ConversationLi
mAdapter.notifyItemChanged(adapterPosition);
}
refreshPinnedStrip();
if (!wasPinned && mRecyclerView != null) {
mRecyclerView.smoothScrollToPosition(mAdapter.getFirstConversationAdapterPosition());
}
}
@Override
@@ -1063,14 +1406,27 @@ public class ConversationListFragment extends Fragment implements ConversationLi
}
public void dismissFab() {
// To prevent clicking while animating.
dismissFab(false);
}
/** @param instant skip slide animation (home selection chrome). */
public void dismissFab(final boolean instant) {
if (mStartNewConversationButton == null) {
return;
}
mStartNewConversationButton.setEnabled(false);
mStartNewConversationButton.animate().cancel();
final MarginLayoutParams lp =
(MarginLayoutParams) mStartNewConversationButton.getLayoutParams();
final float fabWidthWithLeftRightMargin = mStartNewConversationButton.getWidth()
+ lp.leftMargin + lp.rightMargin;
final int direction = AccessibilityUtil.isLayoutRtl(mStartNewConversationButton) ? -1 : 1;
getNormalizedFabAnimator().translationX(direction * fabWidthWithLeftRightMargin);
final float offScreen = direction * fabWidthWithLeftRightMargin;
if (instant) {
mStartNewConversationButton.setTranslationX(offScreen);
return;
}
getNormalizedFabAnimator().translationX(offScreen);
}
public void showFab() {
@@ -1101,6 +1457,10 @@ public class ConversationListFragment extends Fragment implements ConversationLi
}
private void setPinnedEditMode(final boolean enabled) {
setPinnedEditMode(enabled, true);
}
private void setPinnedEditMode(final boolean enabled, final boolean refreshList) {
if (mArchiveMode || mForwardMessageMode) {
return;
}
@@ -1122,8 +1482,10 @@ public class ConversationListFragment extends Fragment implements ConversationLi
mHomePinnedEditMode = enabled;
updateHomeEditButtonForPinnedMode();
updateSearchBarForPinnedEditMode();
refreshPinnedStrip();
updateUi();
if (refreshList) {
refreshPinnedStrip();
updateUi();
}
}
private void updateSearchBarForPinnedEditMode() {
@@ -1171,8 +1533,8 @@ public class ConversationListFragment extends Fragment implements ConversationLi
final Cursor cursor = mAdapter.getCursor();
final boolean editMode = mHomePinnedEditMode && !isSelectionMode();
final List<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);
}
mCategoryChipView.setVisibility(View.VISIBLE);
mCategoryChipView.setText(mData.isEnterprise()
? R.string.home_chip_work
: R.string.home_chip_personal);
}
}
private void setConversationName() {
final boolean selectionMode =
mHostInterface != null && mHostInterface.isSelectionMode();
final boolean showUnreadDot = !mData.getIsRead() && !mData.getShowDraft();
if (mUnreadDotSlotView != null) {
mUnreadDotSlotView.setVisibility(View.VISIBLE);
@@ -241,10 +235,7 @@ public class ConversationListItemView extends FrameLayout implements OnClickList
if (mUnreadDotView != null) {
mUnreadDotView.setVisibility(showUnreadDot ? View.VISIBLE : View.INVISIBLE);
}
if (selectionMode) {
mConversationNameView.setTextColor(mListItemReadColor);
mConversationNameView.setTypeface(mListItemUnreadTypeface);
} else if (mData.getIsRead() || mData.getShowDraft()) {
if (mData.getIsRead() || mData.getShowDraft()) {
mConversationNameView.setTextColor(mListItemReadColor);
mConversationNameView.setTypeface(mListItemReadTypeface);
} else {
@@ -561,17 +552,14 @@ public class ConversationListItemView extends FrameLayout implements OnClickList
final String snippetText = getSnippetText();
final boolean selectionMode = mHostInterface.isSelectionMode();
if (selectionMode) {
maxLines = 0;
typeface = mListItemReadTypeface;
} else if (mData.getIsRead() || mData.getShowDraft()) {
if (mData.getIsRead() || mData.getShowDraft()) {
maxLines = TextUtils.isEmpty(snippetText) ? 0 : NO_UNREAD_SNIPPET_LINE_COUNT;
typeface = mListItemReadTypeface;
} else {
maxLines = TextUtils.isEmpty(snippetText) ? 0 : UNREAD_SNIPPET_LINE_COUNT;
typeface = mListItemUnreadTypeface;
}
color = getResources().getColor(R.color.home_text_secondary, getContext().getTheme());
color = getResources().getColor(R.color.home_text_snippet, getContext().getTheme());
mSnippetTextView.setMaxLines(maxLines);
mSnippetTextView.setTextColor(color);
@@ -579,14 +567,9 @@ public class ConversationListItemView extends FrameLayout implements OnClickList
mSubjectTextView.setTextColor(color);
mSubjectTextView.setTypeface(typeface, typefaceStyle);
if (selectionMode) {
mSnippetTextView.setVisibility(GONE);
mSubjectTextView.setVisibility(GONE);
} else {
mSnippetTextView.setVisibility(VISIBLE);
setSnippet();
setSubject();
}
mSnippetTextView.setVisibility(VISIBLE);
setSnippet();
setSubject();
setConversationName();
setWorkProfileIcon();
setContentDescription(buildContentDescription(resources, mData,
@@ -616,8 +599,10 @@ public class ConversationListItemView extends FrameLayout implements OnClickList
mTimestampTextView.setText(resources.getString(
R.string.conversation_list_item_view_draft_message));
} else {
mTimestampTextView.setTextColor(resources.getColor(R.color.home_text_secondary,
getContext().getTheme()));
final int timestampColor = (mData.getIsRead() || mData.getShowDraft())
? resources.getColor(R.color.home_text_secondary, getContext().getTheme())
: color;
mTimestampTextView.setTextColor(timestampColor);
mTimestampTextView.setTypeface(mListItemReadTypeface, typefaceStyle);
final String formattedTimestamp = mData.getFormattedTimestamp();
if (mData.getIsSendRequested()) {
@@ -647,9 +632,17 @@ public class ConversationListItemView extends FrameLayout implements OnClickList
mContactIconView.setImageResourceUri(iconUri, mData.getParticipantContactId(),
mData.getParticipantLookupKey(), mData.getOtherParticipantNormalizedDestination());
mContactIconView.setVisibility(contactIconVisibility);
mContactIconView.setImageClickHandlerDisabled(true);
mContactIconView.setOnLongClickListener(this);
mContactIconView.setClickable(!mHostInterface.isSelectionMode());
mContactIconView.setLongClickable(!mHostInterface.isSelectionMode());
final boolean avatarInteractive = !mHostInterface.isSelectionMode();
mContactIconView.setClickable(avatarInteractive);
mContactIconView.setLongClickable(avatarInteractive);
if (avatarInteractive) {
mContactIconView.setOnClickListener(v -> mHostInterface.onConversationAvatarClicked(
mData, mContactIconView));
} else {
mContactIconView.setOnClickListener(null);
}
mContactCheckmarkView.setVisibility(checkmarkVisiblity);
mFailedStatusIconView.setVisibility(failStatusVisiblity);
@@ -707,6 +700,29 @@ public class ConversationListItemView extends FrameLayout implements OnClickList
}
}
/** Lightweight refresh when toggling home selection chrome. */
public void rebindSelectionChrome() {
if (mHostInterface == null || mData == null) {
return;
}
final Resources resources = getContext().getResources();
final boolean selectionMode = mHostInterface.isSelectionMode();
final boolean isSelected = mHostInterface.isConversationSelected(
mData.getConversationId());
setSelected(isSelected);
updateSelectionIndicator(selectionMode, isSelected, resources);
mContactIconView.setClickable(!selectionMode);
mContactIconView.setLongClickable(!selectionMode);
if (!selectionMode) {
mContactIconView.setOnClickListener(v -> mHostInterface.onConversationAvatarClicked(
mData, mContactIconView));
} else {
mContactIconView.setOnClickListener(null);
}
updatePinButton(selectionMode, resources);
updateListDividerInset(selectionMode);
}
public boolean isSwipeAnimatable() {
return mHostInterface.isSwipeAnimatable();
}
@@ -813,7 +829,10 @@ public class ConversationListItemView extends FrameLayout implements OnClickList
}
private boolean processClick(final View v, final boolean isLongClick) {
Assert.isTrue(v == mSwipeableContainer || v == mContactIconView || v == mImagePreviewView);
if (v == mContactIconView) {
return false;
}
Assert.isTrue(v == mSwipeableContainer || v == mImagePreviewView);
Assert.notNull(mData.getName());
if (mHostInterface != null) {
@@ -104,12 +104,15 @@ public class ConversationListSwipeHelper implements OnItemTouchListener {
mInitialY = event.getY();
final View viewAtPoint = mRecyclerView.findChildViewUnder(mInitialX, mInitialY);
final ConversationListItemView child = (ConversationListItemView) viewAtPoint;
if (viewAtPoint instanceof ConversationListItemView &&
child != null && child.isSwipeAnimatable()) {
// Begin detecting swipe on the target for the rest of the gesture.
mListItemView = child;
if (mListItemView.isAnimating()) {
if (viewAtPoint instanceof ConversationListItemView) {
final ConversationListItemView child = (ConversationListItemView) viewAtPoint;
if (child.isSwipeAnimatable()) {
// Begin detecting swipe on the target for the rest of the gesture.
mListItemView = child;
if (mListItemView.isAnimating()) {
mListItemView = null;
}
} else {
mListItemView = null;
}
} else {
@@ -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();
mAdapter.changeCursor(data);
mAllContactsCursor = data;
if (TextUtils.isEmpty(mFilterText)) {
mAdapter.changeCursor(data);
updateResultViews();
}
}
@Override
public void onFilteredContactsCursorUpdated(final Cursor data) {
mBinding.ensureBound();
// A null cursor means the search was cleared, so fall back to the full list.
mAdapter.changeCursor(data != null ? data : mAllContactsCursor);
updateResultViews();
}
@Override
@@ -112,29 +180,56 @@ public class HomeContactsFragment extends Fragment implements ContactPickerDataL
@Override
public void onContactListItemClicked(final ContactListItemData item,
final ContactListItemView view) {
if (!mGroupMode) {
startConversation(item.getRecipientEntry());
return;
}
final RecipientEntry entry = item.getRecipientEntry();
final String key = selectionKey(entry);
if (mSelected.remove(key) == null) {
mSelected.put(key, entry);
}
notifyGroupSelectionChanged();
if (mAdapter != null) {
mAdapter.notifyDataSetChanged();
}
}
private static String selectionKey(final RecipientEntry entry) {
return PhoneUtils.getDefault().getCanonicalBySystemLocale(entry.getDestination());
}
private void notifyGroupSelectionChanged() {
if (getActivity() instanceof GroupSelectionHost) {
((GroupSelectionHost) getActivity()).onGroupSelectionChanged(
new ArrayList<>(mSelected.values()));
}
}
private void startConversation(final RecipientEntry entry) {
if (mMonitor != null) {
return;
}
final ArrayList<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,11 +148,29 @@ public final class HomePinnedStrip {
} else {
stopEditJiggle(avatarStack);
remove.setVisibility(View.GONE);
item.setOnClickListener(v -> {
final View.OnClickListener openProfile = v -> {
if (listener != null) {
listener.onPinnedConversationClicked(id);
listener.onPinnedProfileClicked(id, icon != null ? icon : avatarStack);
}
});
};
item.setOnClickListener(null);
if (pinnedCell != null) {
pinnedCell.setOnClickListener(openProfile);
}
if (avatarStack != null) {
avatarStack.setOnClickListener(openProfile);
}
icon.setOnClickListener(openProfile);
if (previewContainer != null) {
previewContainer.setOnClickListener(openProfile);
}
if (label != null) {
label.setOnClickListener(v -> {
if (listener != null) {
listener.onPinnedConversationOpen(id);
}
});
}
}
currentRow.addView(item);
itemIndex++;
@@ -288,7 +310,7 @@ public final class HomePinnedStrip {
private static void bindUnreadPreview(final Context context,
final ConversationListItemData row, final boolean editMode,
final boolean selectionMode, final View previewContainer, final TextView preview,
final View previewContainer, final TextView preview,
final View unreadDot) {
final boolean unread = !row.getIsRead() && !row.getShowDraft();
if (unreadDot != null) {
@@ -297,7 +319,7 @@ public final class HomePinnedStrip {
if (previewContainer != null) {
previewContainer.setTranslationX(0f);
}
if (editMode || selectionMode || previewContainer == null || preview == null) {
if (editMode || previewContainer == null || preview == null) {
if (previewContainer != null) {
previewContainer.setVisibility(View.GONE);
}
@@ -398,6 +420,12 @@ public final class HomePinnedStrip {
avatarStack.setRotation(0f);
}
/** Binds {@code out} from {@code cursor} when {@code conversationId} matches a row. */
public static boolean bindConversationListItem(final Cursor cursor,
final String conversationId, final ConversationListItemData out) {
return bindRow(cursor, conversationId, out);
}
private static boolean bindRow(final Cursor cursor, final String conversationId,
final ConversationListItemData out) {
final int saved = cursor.getPosition();
@@ -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);
try {
mPickerLauncher.launch(intent);
} catch (final ActivityNotFoundException ex) {
LogUtil.w(LogUtil.BUGLE_TAG, "Couldn't find activity:", ex);
UiUtils.showToastAtBottom(R.string.activity_not_found_message);
}
});
mEnabledView.setOnClickListener(v -> launchPicker());
return view;
}
void launchPicker() {
final Intent intent = new Intent(Intent.ACTION_PICK, Contacts.CONTENT_URI);
try {
mPickerLauncher.launch(intent);
} catch (final ActivityNotFoundException ex) {
LogUtil.w(LogUtil.BUGLE_TAG, "Couldn't find activity:", ex);
UiUtils.showToastAtBottom(R.string.activity_not_found_message);
}
}
}
@@ -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,27 +358,160 @@ public class MediaPicker extends Fragment implements DraftMessageSubscriptionDat
// we defer doing work as it may require an attached fragment (eg. calling
// Fragment#requestPermission)
mStartingMediaTypeOnAttach = startingMediaType;
mDeferredOpenMode = OPEN_MODE_INVALID;
mAnimateOnAttach = animate;
}
}
/** Opens the media picker on the camera chooser. */
public void openCamera(final boolean animate) {
openWithMode(OPEN_MODE_CAMERA, animate);
}
/** Opens the system gallery picker without showing the in-app chooser panel. */
public void openGallery(final boolean animate) {
if (mIsAttached) {
launchGalleryPickerWithoutPanel();
} else {
mDeferredOpenMode = OPEN_MODE_GALLERY;
mAnimateOnAttach = animate;
}
}
private void hideInAppPanel() {
mOpen = false;
if (mMediaPickerPanel != null) {
mMediaPickerPanel.setViewPagerSuppressed(true);
mMediaPickerPanel.setExpanded(false, false, MediaPickerPanel.PAGE_NOT_SET);
mMediaPickerPanel.setVisibility(View.GONE);
if (mViewPager != null) {
mViewPager.setAdapter(null);
mViewPager.setVisibility(View.GONE);
}
}
setHostContainerVisible(false);
}
private void launchGalleryPickerWithoutPanel() {
hideInAppPanel();
for (final MediaChooser chooser : mEnabledChoosers) {
if (chooser instanceof GalleryMediaChooser) {
((GalleryMediaChooser) chooser).launchPicker();
return;
}
}
}
private void launchContactPickerWithoutPanel() {
hideInAppPanel();
for (final MediaChooser chooser : mEnabledChoosers) {
if (chooser instanceof ContactMediaChooser) {
((ContactMediaChooser) chooser).launchPicker();
return;
}
}
}
/** Opens the media picker on the audio chooser. */
public void openAudio(final boolean animate) {
openWithMode(OPEN_MODE_AUDIO, animate);
}
/** Opens the system contact picker without showing the in-app chooser panel. */
public void openContact(final boolean animate) {
if (mIsAttached) {
launchContactPickerWithoutPanel();
} else {
mDeferredOpenMode = OPEN_MODE_CONTACT;
mAnimateOnAttach = animate;
}
}
private void openWithMode(final int openMode, final boolean animate) {
mOpen = true;
mDeferredOpenMode = openMode;
if (mIsAttached) {
performOpenMode(openMode, animate);
mDeferredOpenMode = OPEN_MODE_INVALID;
} else {
mStartingMediaTypeOnAttach = MEDA_TYPE_INVALID;
mAnimateOnAttach = animate;
}
}
private void performOpenMode(final int openMode, final boolean animate) {
mSelectedChooser = null;
if (openMode == OPEN_MODE_CAMERA) {
selectFirstChooserOfType(CameraMediaChooser.class);
} else if (openMode == OPEN_MODE_GALLERY) {
selectFirstChooserOfType(GalleryMediaChooser.class);
} else if (openMode == OPEN_MODE_AUDIO) {
selectFirstChooserOfType(AudioMediaChooser.class);
} else if (openMode == OPEN_MODE_CONTACT) {
selectFirstChooserOfType(ContactMediaChooser.class);
} else {
doOpen(MEDIA_TYPE_DEFAULT, animate);
return;
}
if (mSelectedChooser == null && !mEnabledChoosers.isEmpty()) {
selectChooser(mEnabledChoosers.get(0));
}
expandMediaPickerPanel(animate);
}
private void selectFirstChooserOfType(final Class<?> chooserType) {
for (final MediaChooser chooser : mEnabledChoosers) {
if (chooserType.isInstance(chooser)) {
selectChooser(chooser);
return;
}
}
}
private void setHostContainerVisible(final boolean visible) {
final View container;
if (mMediaPickerPanel != null && mMediaPickerPanel.getParent() instanceof View
&& ((View) mMediaPickerPanel.getParent()).getId() == R.id.mediapicker_container) {
container = (View) mMediaPickerPanel.getParent();
} else if (getActivity() != null) {
container = getActivity().findViewById(R.id.mediapicker_container);
} else {
container = null;
}
if (container != null) {
container.setVisibility(visible ? View.VISIBLE : View.GONE);
}
}
private void expandMediaPickerPanel(final boolean animate) {
if (mMediaPickerPanel == null) {
return;
}
setHostContainerVisible(true);
mMediaPickerPanel.setVisibility(View.VISIBLE);
mMediaPickerPanel.setViewPagerSuppressed(false);
if (mViewPager != null && mPagerAdapter != null) {
mViewPager.setAdapter(mPagerAdapter);
}
final boolean isTouchExplorationEnabled = AccessibilityUtil.isTouchExplorationEnabled(
Factory.get().getApplicationContext());
mMediaPickerPanel.setFullScreenOnly(isTouchExplorationEnabled);
final int startingPage = mSelectedChooser == null ? MediaPickerPanel.PAGE_NOT_SET
: mEnabledChoosers.indexOf(mSelectedChooser);
mMediaPickerPanel.setExpanded(true, animate, startingPage);
}
private void doOpen(int startingMediaType, final boolean animate) {
final boolean isTouchExplorationEnabled = AccessibilityUtil.isTouchExplorationEnabled(
// getActivity() will be null at this point
Factory.get().getApplicationContext());
// If no specific starting type is specified (i.e. MEDIA_TYPE_DEFAULT), try to get the
// last opened chooser index from shared prefs.
if (startingMediaType == MEDIA_TYPE_DEFAULT) {
final int selectedChooserIndex = mBinding.getData().getSelectedChooserIndex();
if (selectedChooserIndex >= 0 && selectedChooserIndex < mEnabledChoosers.size()) {
selectChooser(mEnabledChoosers.get(selectedChooserIndex));
} else {
// This is the first time the picker is being used
if (isTouchExplorationEnabled) {
// Accessibility defaults to audio attachment mode.
startingMediaType = MEDIA_TYPE_AUDIO;
}
} else if (isTouchExplorationEnabled) {
startingMediaType = MEDIA_TYPE_AUDIO;
}
}
@@ -372,16 +525,11 @@ public class MediaPicker extends Fragment implements DraftMessageSubscriptionDat
}
}
if (mSelectedChooser == null) {
// Fall back to the first chooser.
if (mSelectedChooser == null && !mEnabledChoosers.isEmpty()) {
selectChooser(mEnabledChoosers.get(0));
}
if (mMediaPickerPanel != null) {
mMediaPickerPanel.setFullScreenOnly(isTouchExplorationEnabled);
mMediaPickerPanel.setExpanded(true, animate,
mEnabledChoosers.indexOf(mSelectedChooser));
}
expandMediaPickerPanel(animate);
}
/** @return True if the media picker is open */
@@ -413,7 +561,7 @@ public class MediaPicker extends Fragment implements DraftMessageSubscriptionDat
}
final ImageButton tabButton = chooser.getTabButton();
if (tabButton != null) {
tabButton.setVisibility(enabled ? View.VISIBLE : View.GONE);
tabButton.setVisibility(View.GONE);
}
}
@@ -443,7 +591,12 @@ public class MediaPicker extends Fragment implements DraftMessageSubscriptionDat
public void dismiss(final boolean animate) {
mOpen = false;
if (mMediaPickerPanel != null) {
mMediaPickerPanel.setViewPagerSuppressed(true);
mMediaPickerPanel.setExpanded(false, animate, MediaPickerPanel.PAGE_NOT_SET);
if (!animate) {
mMediaPickerPanel.setVisibility(View.GONE);
setHostContainerVisible(false);
}
}
mSelectedChooser = null;
}
@@ -58,6 +58,9 @@ public class MediaPickerPanel extends ViewGroup {
*/
private boolean mExpanded;
/** When true, only the attachment grid is shown (Google Messages menu sheet). */
private boolean mViewPagerSuppressed;
/**
* The current desired height of the MediaPicker. This property may be animated and the
* measure pass uses it to determine what size the components are.
@@ -154,13 +157,32 @@ public class MediaPickerPanel extends ViewGroup {
// take the max here as on some devices the mDefaultViewPagerHeight may be too big in
// landscape mode after animation.
final int tabAdjustedDesiredHeight = desiredHeight - tabStripHeight;
final int viewPagerHeight =
tabAdjustedDesiredHeight <= 1 ? mDefaultViewPagerHeight : tabAdjustedDesiredHeight;
final int viewPagerHeight;
if (mViewPagerSuppressed || !mExpanded) {
viewPagerHeight = 0;
mViewPager.setVisibility(GONE);
} else {
viewPagerHeight =
tabAdjustedDesiredHeight <= 1 ? mDefaultViewPagerHeight : tabAdjustedDesiredHeight;
mViewPager.setVisibility(VISIBLE);
}
int viewPagerHeightMeasureSpec = MeasureSpec.makeMeasureSpec(
viewPagerHeight, MeasureSpec.EXACTLY);
measureChild(mViewPager, widthMeasureSpec, viewPagerHeightMeasureSpec);
setMeasuredDimension(mViewPager.getMeasuredWidth(), desiredHeight);
final int totalHeight = (mViewPagerSuppressed || !mExpanded) ? 0 : desiredHeight;
setMeasuredDimension(Math.max(mViewPager.getMeasuredWidth(), getSuggestedMinimumWidth()),
totalHeight);
}
void setViewPagerSuppressed(final boolean suppressed) {
if (mViewPagerSuppressed == suppressed) {
return;
}
mViewPagerSuppressed = suppressed;
if (suppressed) {
mViewPager.setVisibility(GONE);
}
requestLayout();
}
@Override
@@ -224,6 +246,11 @@ public class MediaPickerPanel extends ViewGroup {
}
private void setupViewPager(final int startingPage) {
if (mViewPagerSuppressed) {
mViewPager.setVisibility(GONE);
mViewPager.setAdapter(null);
return;
}
mViewPager.setVisibility(View.VISIBLE);
if (startingPage >= 0 && startingPage < mMediaPicker.getPagerAdapter().getCount()) {
mViewPager.setAdapter(mMediaPicker.getPagerAdapter());
@@ -250,13 +277,16 @@ public class MediaPickerPanel extends ViewGroup {
}
mFullScreen = false;
mExpanded = expanded;
mHandler.post(() -> setDesiredHeight(getDesiredHeight(), animate));
if (expanded) {
setVisibility(VISIBLE);
setupViewPager(startingPage);
mMediaPicker.dispatchOpened();
} else {
mViewPager.setAdapter(null);
mViewPager.setVisibility(GONE);
mMediaPicker.dispatchDismissed();
}
mHandler.post(() -> setDesiredHeight(getDesiredHeight(), animate));
// Call setFullScreenView() when we are in landscape mode so it can go full screen as
// soon as it is expanded.
@@ -292,9 +322,31 @@ public class MediaPickerPanel extends ViewGroup {
};
animation.setDuration(UiUtils.MEDIAPICKER_TRANSITION_DURATION);
animation.setInterpolator(UiUtils.EASE_OUT_INTERPOLATOR);
animation.setAnimationListener(new Animation.AnimationListener() {
@Override
public void onAnimationStart(final Animation a) {
}
@Override
public void onAnimationRepeat(final Animation a) {
}
@Override
public void onAnimationEnd(final Animation a) {
if (!mExpanded) {
setVisibility(GONE);
if (getParent() instanceof View) {
((View) getParent()).setVisibility(GONE);
}
}
}
});
startAnimation(animation);
} else {
mCurrentDesiredHeight = height;
if (!mExpanded) {
setVisibility(GONE);
}
}
requestLayout();
}
@@ -305,6 +357,9 @@ public class MediaPickerPanel extends ViewGroup {
private int measureHeight() {
final int measureSpec = MeasureSpec.makeMeasureSpec(Integer.MAX_VALUE, MeasureSpec.AT_MOST);
measureChild(mTabStrip, measureSpec, measureSpec);
if (mViewPagerSuppressed) {
return mTabStrip.getMeasuredHeight();
}
return mDefaultViewPagerHeight + mTabStrip.getMeasuredHeight();
}
@@ -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