Messaging: Add home categories and shared modal/input chrome

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-27 02:41:59 +09:00
co-authored by Cursor
parent a32250fe34
commit 42148360e0
77 changed files with 2823 additions and 182 deletions
@@ -23,13 +23,16 @@ import android.view.ActionMode;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.MotionEvent;
import android.view.View;
import android.view.Window;
import androidx.annotation.NonNull;
import androidx.appcompat.app.ActionBar;
import androidx.appcompat.app.AppCompatActivity;
import com.android.messaging.R;
import com.android.messaging.ui.home.HomeInputFocus;
import com.android.messaging.util.BugleActivityUtil;
import com.android.messaging.util.ImeUtil;
import com.android.messaging.util.LogUtil;
@@ -91,6 +94,13 @@ public class BugleActionBarActivity extends AppCompatActivity implements ImeUtil
BugleActivityUtil.onActivityResume(this, BugleActionBarActivity.this);
}
@Override
public boolean dispatchTouchEvent(final MotionEvent ev) {
final Window window = getWindow();
HomeInputFocus.clearIfOutside(window == null ? null : window.getDecorView(), ev);
return super.dispatchTouchEvent(ev);
}
@Override
protected void onPause() {
super.onPause();
@@ -221,6 +221,9 @@ public class ComposeMessageView extends LinearLayout
mComposeEditText.setOnEditorActionListener(this);
mComposeEditText.addTextChangedListener(this);
mComposeEditText.setOnFocusChangeListener((v, hasFocus) -> {
if (mAttachMediaButton != null) {
mAttachMediaButton.setSelected(hasFocus);
}
if (v == mComposeEditText && hasFocus) {
if (mHost != null) {
mHost.onComposeEditTextFocused();
@@ -320,6 +323,7 @@ public class ComposeMessageView extends LinearLayout
}
mAttachHost = createComposeAttachHost();
mAttachMediaButton.setOnClickListener(clickView -> toggleComposeAttachmentSheet());
mAttachMediaButton.setSelected(mComposeEditText.hasFocus());
final ImageButton emojiButton = findViewById(R.id.home_compose_emoji_button);
if (emojiButton != null) {
@@ -784,36 +788,22 @@ public class ComposeMessageView extends LinearLayout
int sendWidgetMode = SEND_WIDGET_MODE_SELF_AVATAR;
if (selfSendButtonUri != null) {
if (hasWorkingDraft && isDataLoadedForMessageSend()) {
UiUtils.revealOrHideViewWithAnimation(mSendButton, VISIBLE, null);
if (isOverriddenAvatarAGroup()) {
// If the host has overriden the avatar to show a group avatar where the
// send button sits, we have to hide the group avatar because it can be larger
// than the send button and pieces of the avatar will stick out from behind
// the send button.
UiUtils.revealOrHideViewWithAnimation(mSelfSendIcon, GONE, null);
}
hideSelfSendIcon();
mMmsIndicator.setVisibility(draftMessageData.getIsMms() ? VISIBLE : INVISIBLE);
sendWidgetMode = SEND_WIDGET_MODE_SEND_BUTTON;
} else {
mSelfSendIcon.setImageResourceUri(selfSendButtonUri);
if (isOverriddenAvatarAGroup()) {
UiUtils.revealOrHideViewWithAnimation(mSelfSendIcon, VISIBLE, null);
}
UiUtils.revealOrHideViewWithAnimation(mSendButton, GONE, null);
hideSelfSendIcon();
mMmsIndicator.setVisibility(INVISIBLE);
if (shouldShowSimSelector(mConversationDataModel.getData())) {
sendWidgetMode = SEND_WIDGET_MODE_SIM_SELECTOR;
}
}
} else {
mSelfSendIcon.setImageResourceUri(null);
hideSelfSendIcon();
if (hasWorkingDraft && isDataLoadedForMessageSend()) {
UiUtils.revealOrHideViewWithAnimation(mSendButton, VISIBLE, null);
UiUtils.revealOrHideViewWithAnimation(mSelfSendIcon, GONE, null);
mMmsIndicator.setVisibility(draftMessageData.getIsMms() ? VISIBLE : INVISIBLE);
sendWidgetMode = SEND_WIDGET_MODE_SEND_BUTTON;
} else {
UiUtils.revealOrHideViewWithAnimation(mSendButton, GONE, null);
mMmsIndicator.setVisibility(INVISIBLE);
}
}
@@ -898,10 +888,24 @@ public class ComposeMessageView extends LinearLayout
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);
final boolean showSend = sendWidgetMode == SEND_WIDGET_MODE_SEND_BUTTON;
if (showSend) {
mSendColumn.setVisibility(VISIBLE);
UiUtils.revealOrHideViewWithAnimation(mSendButton, VISIBLE, null);
UiUtils.revealOrHideViewWithAnimation(mVoiceButton, GONE, null);
return;
}
UiUtils.revealOrHideViewWithAnimation(mVoiceButton, VISIBLE, null);
if (mSendColumn.getVisibility() == VISIBLE) {
UiUtils.revealOrHideViewWithAnimation(mSendButton, GONE, () -> {
if (mSendWidgetMode != SEND_WIDGET_MODE_SEND_BUTTON && mSendColumn != null) {
mSendColumn.setVisibility(GONE);
}
});
} else {
mSendButton.setVisibility(GONE);
mSendColumn.setVisibility(GONE);
}
}
private void setSendButtonAccessibility(final int sendWidgetMode) {
@@ -911,7 +915,6 @@ public class ComposeMessageView extends LinearLayout
// important for accessibility.
mSelfSendIcon.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO);
mSelfSendIcon.setContentDescription(null);
mSendButton.setVisibility(View.GONE);
setSendWidgetAccessibilityTraversalOrder(SEND_WIDGET_MODE_SELF_AVATAR);
break;
@@ -1078,12 +1081,13 @@ public class ComposeMessageView extends LinearLayout
mHost.notifyOfAttachmentLoadFailed();
}
private boolean isOverriddenAvatarAGroup() {
final Uri overridenSelfUri = mHost.getSelfSendButtonIconUri();
if (overridenSelfUri == null) {
return false;
private void hideSelfSendIcon() {
if (mSelfSendIcon == null) {
return;
}
return AvatarUriUtil.TYPE_GROUP_URI.equals(AvatarUriUtil.getAvatarType(overridenSelfUri));
mSelfSendIcon.animate().cancel();
mSelfSendIcon.setVisibility(GONE);
mSelfSendIcon.setImageResourceUri(null);
}
@Override
@@ -319,7 +319,7 @@ public class ConversationFragment extends Fragment implements ConversationDataLi
mScrollToDismissHandled = true;
}
}
}
}
};
private final ActivityResultLauncher<Intent> mLauncher = registerForActivityResult(
@@ -968,7 +968,7 @@ public class ConversationFragment extends Fragment implements ConversationDataLi
mRecyclerView.post(mAdapter::notifyDataSetChanged);
return;
}
mAdapter.notifyDataSetChanged();
mAdapter.notifyDataSetChanged();
});
}
@@ -1219,8 +1219,8 @@ public class ConversationFragment extends Fragment implements ConversationDataLi
mListState = mRecyclerView.getLayoutManager().onSaveInstanceState();
try {
LocalBroadcastManager.getInstance(getActivity())
.unregisterReceiver(mConversationSelfIdChangeReceiver);
LocalBroadcastManager.getInstance(getActivity())
.unregisterReceiver(mConversationSelfIdChangeReceiver);
} catch (final IllegalArgumentException ignored) {
// onResume may have aborted before the receiver was registered (camera return).
}
@@ -2421,7 +2421,7 @@ public class ConversationFragment extends Fragment implements ConversationDataLi
AccessibilityUtil.getVocalizedPhoneNumber(getResources(), conversationName);
titleView.setContentDescription(vocalizedConversationName);
if (getActivity() != null) {
getActivity().setTitle(conversationName);
getActivity().setTitle(conversationName);
}
} else if (getActivity() != null) {
final String appName = getString(R.string.app_name);
@@ -32,6 +32,8 @@ import androidx.recyclerview.widget.RecyclerView;
import com.android.messaging.R;
import com.android.messaging.datamodel.data.ConversationListItemData;
import com.android.messaging.ui.CursorRecyclerAdapter;
import com.android.messaging.ui.home.HomeCategoryClassifier;
import com.android.messaging.ui.home.HomeCategoryStore;
import com.android.messaging.ui.home.PinnedConversationStore;
import java.util.ArrayList;
@@ -58,6 +60,7 @@ public class ConversationListAdapter
private final ConversationListItemView.HostInterface mClivHostInterface;
private final ConversationListItemData mFilterProbe = new ConversationListItemData();
private int mFilterMode = FILTER_ALL;
private String mCategoryId = HomeCategoryStore.ID_ALL;
private int[] mDisplayPositions = new int[0];
private View mPinnedStripRoot;
private boolean mPinnedHeaderVisible;
@@ -113,6 +116,46 @@ public class ConversationListAdapter
return mFilterMode;
}
public void setCategoryId(final String categoryId) {
final String next = categoryId == null ? HomeCategoryStore.ID_ALL : categoryId;
if (next.equals(mCategoryId)) {
return;
}
mCategoryId = next;
rebuildFilterIndex();
notifyDataSetChanged();
}
public String getCategoryId() {
return mCategoryId;
}
public void refreshCategoryFilter() {
rebuildFilterIndex();
notifyDataSetChanged();
}
public List<ConversationListItemData> snapshotConversations() {
final Cursor cursor = getCursor();
if (cursor == null || cursor.getCount() == 0) {
return Collections.emptyList();
}
final List<ConversationListItemData> items = new ArrayList<>(cursor.getCount());
final int saved = cursor.getPosition();
for (int i = 0; i < cursor.getCount(); i++) {
if (!cursor.moveToPosition(i)) {
continue;
}
final ConversationListItemData item = new ConversationListItemData();
item.bind(cursor);
items.add(item);
}
if (saved >= 0 && saved < cursor.getCount()) {
cursor.moveToPosition(saved);
}
return items;
}
public void notifySelectionChromeChanged() {
final int count = getItemCount();
if (count > 0) {
@@ -245,16 +288,13 @@ public class ConversationListAdapter
}
private boolean passesFilter() {
if (mFilterMode == FILTER_ALL) {
return true;
if (mFilterMode == FILTER_UNREAD && mFilterProbe.getIsRead()) {
return false;
}
if (mFilterMode == FILTER_UNREAD) {
return !mFilterProbe.getIsRead();
if (mFilterMode == FILTER_GROUPS && mFilterProbe.getParticipantCount() <= 1) {
return false;
}
if (mFilterMode == FILTER_GROUPS) {
return mFilterProbe.getParticipantCount() > 1;
}
return true;
return HomeCategoryClassifier.matches(mContext, mCategoryId, mFilterProbe);
}
private void sortPinnedFirst(final ArrayList<Integer> positions, final Cursor cursor) {
@@ -71,6 +71,11 @@ 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.home.HomeCategoryAddModal;
import com.android.messaging.ui.home.HomeCategoryAssignSheet;
import com.android.messaging.ui.home.HomeCategoryClassifier;
import com.android.messaging.ui.home.HomeCategoryStore;
import com.android.messaging.ui.home.HomeModal;
import com.android.messaging.ui.home.HomeEmptyState;
import com.android.messaging.ui.SnackBarInteraction;
import com.android.messaging.ui.UIIntents;
@@ -91,6 +96,7 @@ import com.android.messaging.util.ThreadUtil;
import com.android.messaging.util.UiUtils;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
@@ -130,6 +136,10 @@ public class ConversationListFragment extends Fragment implements ConversationLi
private TextView mHomeReadAllButton;
private int mHomeSelectionCount;
private View mHomeMessagesTop;
private View mHomeCategoryStrip;
private ViewGroup mHomeCategoryAll;
private View mHomeCategoryScroll;
private LinearLayout mHomeCategoryRow;
private TextView mHomeTitle;
private View mHomeAutomationPane;
private View mHomeAutomationCreateButton;
@@ -388,6 +398,17 @@ public class ConversationListFragment extends Fragment implements ConversationLi
}
setupHomeChrome(rootView);
mHomeMessagesTop = rootView.findViewById(R.id.home_messages_top);
mHomeCategoryStrip = rootView.findViewById(R.id.home_category_strip);
mHomeCategoryAll = rootView.findViewById(R.id.home_category_all);
mHomeCategoryScroll = rootView.findViewById(R.id.home_category_scroll);
mHomeCategoryRow = rootView.findViewById(R.id.home_category_row);
final View categoryAdd = rootView.findViewById(R.id.home_category_add);
if (categoryAdd != null) {
categoryAdd.setOnClickListener(v -> HomeCategoryAddModal.show(requireContext(),
created -> selectHomeCategory(created.id)));
}
mAdapter.setCategoryId(HomeCategoryStore.getSelectedId(requireContext()));
bindHomeCategoryStrip();
mHomeTitle = rootView.findViewById(R.id.home_title);
mHomeAutomationPane = rootView.findViewById(R.id.home_automation_pane);
mHomeNavDock = rootView.findViewById(R.id.home_nav_dock);
@@ -1108,6 +1129,11 @@ public class ConversationListFragment extends Fragment implements ConversationLi
if (mHomeMessagesTop != null) {
mHomeMessagesTop.setVisibility(View.VISIBLE);
}
if (mHomeCategoryStrip != null) {
mHomeCategoryStrip.setVisibility(showMessages ? View.VISIBLE : View.GONE);
} else if (mHomeCategoryScroll != null) {
mHomeCategoryScroll.setVisibility(showMessages ? View.VISIBLE : View.GONE);
}
final CharSequence title = getString(showMessages
? R.string.home_title : R.string.home_automation_title);
if (mHomeTitle != null) {
@@ -1470,6 +1496,10 @@ public class ConversationListFragment extends Fragment implements ConversationLi
dismissHomePopover();
toggleConversationMute(data);
});
content.findViewById(R.id.popover_conversation_category).setOnClickListener(v -> {
dismissHomePopover();
showAssignHomeCategory(data);
});
content.findViewById(R.id.popover_conversation_delete).setOnClickListener(v -> {
dismissHomePopover();
confirmDeleteConversation(data);
@@ -1832,21 +1862,224 @@ public class ConversationListFragment extends Fragment implements ConversationLi
content.findViewById(R.id.popover_edit_pinned).setOnClickListener(v -> {
dismissHomePopoverAndThen(() -> setPinnedEditMode(true));
});
content.findViewById(R.id.popover_name_photo).setOnClickListener(v -> {
dismissHomePopover();
launchHomeSubScreen(() -> {
if (getActivity() instanceof ConversationListActivity) {
((ConversationListActivity) getActivity()).onActionBarSettings();
}
});
});
}
private void selectHomeChip(final int filterMode) {
mAdapter.setFilterMode(filterMode);
bindHomeCategoryStrip();
updateEmptyListUi(mAdapter.getConversationItemCount() == 0);
}
private void bindHomeCategoryStrip() {
if (mHomeCategoryRow == null || mArchiveMode || mForwardMessageMode) {
return;
}
final Context context = getContext();
if (context == null) {
return;
}
final String selectedId = HomeCategoryStore.getSelectedId(context);
final List<ConversationListItemData> items = mAdapter.snapshotConversations();
final LayoutInflater inflater = LayoutInflater.from(context);
if (mHomeCategoryAll != null) {
mHomeCategoryAll.removeAllViews();
}
mHomeCategoryRow.removeAllViews();
for (final HomeCategoryStore.Category category : HomeCategoryStore.listTabs(context)) {
final boolean pinnedAll = HomeCategoryStore.ID_ALL.equals(category.id);
final ViewGroup host = pinnedAll && mHomeCategoryAll != null
? mHomeCategoryAll : mHomeCategoryRow;
final View tab = inflater.inflate(R.layout.home_category_tab, host, false);
bindHomeCategoryTab(tab, category, selectedId, items);
host.addView(tab);
}
}
private void bindHomeCategoryTab(final View tab, final HomeCategoryStore.Category category,
final String selectedId, final List<ConversationListItemData> items) {
final Context context = tab.getContext();
final TextView label = tab.findViewById(R.id.home_category_tab_label);
final View underline = tab.findViewById(R.id.home_category_tab_underline);
final View dot = tab.findViewById(R.id.home_category_tab_dot);
final boolean selected = selectedId.equals(category.id);
if (label != null) {
label.setText(category.name);
label.setTypeface(null, selected ? android.graphics.Typeface.BOLD
: android.graphics.Typeface.NORMAL);
label.setTextColor(ContextCompat.getColor(context, selected
? R.color.home_accent : R.color.home_text_secondary));
}
if (underline != null) {
underline.setVisibility(selected ? View.VISIBLE : View.INVISIBLE);
}
if (dot != null) {
final boolean showDot = !HomeCategoryStore.ID_ALL.equals(category.id)
&& categoryHasUnread(context, category.id, items);
dot.setVisibility(showDot ? View.VISIBLE : View.GONE);
}
tab.setTag(category.id);
tab.setOnClickListener(v -> selectHomeCategory(category.id));
if (!category.builtIn) {
tab.setOnLongClickListener(v -> {
confirmDeleteHomeCategory(category);
return true;
});
}
}
private boolean categoryHasUnread(final Context context, final String categoryId,
final List<ConversationListItemData> items) {
for (final ConversationListItemData item : items) {
if (!item.getIsRead()
&& HomeCategoryClassifier.matches(context, categoryId, item)) {
return true;
}
}
return false;
}
private void selectHomeCategory(final String categoryId) {
final Context context = getContext();
if (context == null || mAdapter == null || TextUtils.isEmpty(categoryId)) {
return;
}
final String previous = HomeCategoryStore.getSelectedId(context);
if (categoryId.equals(previous) && homeCategoryStripHas(categoryId)) {
return;
}
final boolean fromEnd = homeCategoryIndex(context, categoryId)
>= homeCategoryIndex(context, previous);
HomeCategoryStore.setSelectedId(context, categoryId);
mAdapter.setCategoryId(categoryId);
mAdapter.refreshCategoryFilter();
if (homeCategoryStripHas(categoryId)) {
applyHomeCategorySelection(true);
} else {
bindHomeCategoryStrip();
}
updateEmptyListUi(mAdapter.getConversationItemCount() == 0);
if (mRecyclerView != null && mRecyclerView.getVisibility() == View.VISIBLE) {
HomeUiMotion.categoryPageEnter(mRecyclerView, fromEnd);
}
}
private boolean homeCategoryStripHas(final String categoryId) {
if (TextUtils.isEmpty(categoryId)) {
return false;
}
return findHomeCategoryTab(categoryId) != null;
}
private View findHomeCategoryTab(final String categoryId) {
final View pinned = findHomeCategoryTabIn(mHomeCategoryAll, categoryId);
if (pinned != null) {
return pinned;
}
return findHomeCategoryTabIn(mHomeCategoryRow, categoryId);
}
private static View findHomeCategoryTabIn(final ViewGroup host, final String categoryId) {
if (host == null) {
return null;
}
for (int i = 0; i < host.getChildCount(); i++) {
if (categoryId.equals(host.getChildAt(i).getTag())) {
return host.getChildAt(i);
}
}
return null;
}
private int homeCategoryIndex(final Context context, final String categoryId) {
final List<HomeCategoryStore.Category> tabs = HomeCategoryStore.listTabs(context);
for (int i = 0; i < tabs.size(); i++) {
if (tabs.get(i).id.equals(categoryId)) {
return i;
}
}
return 0;
}
private void applyHomeCategorySelection(final boolean animate) {
final Context context = getContext();
if (context == null) {
return;
}
final String selectedId = HomeCategoryStore.getSelectedId(context);
final int selectedColor = ContextCompat.getColor(context, R.color.home_accent);
final int idleColor = ContextCompat.getColor(context, R.color.home_text_secondary);
View selectedTab = null;
selectedTab = applyHomeCategoryHost(mHomeCategoryAll, selectedId, selectedColor, idleColor,
animate, selectedTab);
selectedTab = applyHomeCategoryHost(mHomeCategoryRow, selectedId, selectedColor, idleColor,
animate, selectedTab);
if (selectedTab != null
&& selectedTab.getParent() == mHomeCategoryRow
&& mHomeCategoryScroll instanceof android.widget.HorizontalScrollView) {
final View tab = selectedTab;
final android.widget.HorizontalScrollView scroller =
(android.widget.HorizontalScrollView) mHomeCategoryScroll;
scroller.post(() -> scroller.smoothScrollTo(
Math.max(tab.getLeft() - tab.getWidth(), 0), 0));
}
}
private static View applyHomeCategoryHost(final ViewGroup host, final String selectedId,
final int selectedColor, final int idleColor, final boolean animate,
View selectedTab) {
if (host == null) {
return selectedTab;
}
for (int i = 0; i < host.getChildCount(); i++) {
final View tab = host.getChildAt(i);
final Object id = tab.getTag();
if (!(id instanceof String)) {
continue;
}
final boolean selected = selectedId.equals(id);
if (selected) {
selectedTab = tab;
}
HomeUiMotion.applyCategoryTab(
tab.findViewById(R.id.home_category_tab_label),
tab.findViewById(R.id.home_category_tab_underline),
selectedColor, idleColor, selected, animate);
}
return selectedTab;
}
private void confirmDeleteHomeCategory(final HomeCategoryStore.Category category) {
final Context context = getContext();
if (context == null || category == null || category.builtIn) {
return;
}
HomeModal.builder(context)
.icon(R.drawable.ic_home_category)
.title(R.string.home_category_delete_title)
.message(R.string.home_category_delete_message)
.negative(android.R.string.cancel, null)
.positive(R.string.home_conversation_delete, () -> {
HomeCategoryStore.removeCustom(context, category.id);
selectHomeCategory(HomeCategoryStore.getSelectedId(context));
})
.show();
}
private void showAssignHomeCategory(final ConversationListItemData data) {
final Context context = getContext();
if (context == null || data == null || TextUtils.isEmpty(data.getConversationId())) {
return;
}
HomeCategoryAssignSheet.show(context, data, () -> {
if (!isAdded() || mAdapter == null) {
return;
}
mAdapter.refreshCategoryFilter();
bindHomeCategoryStrip();
updateEmptyListUi(mAdapter.getConversationItemCount() == 0);
});
}
@Override
public void onAttach(@NonNull final Context context) {
super.onAttach(context);
@@ -1889,6 +2122,7 @@ public class ConversationListFragment extends Fragment implements ConversationLi
final Cursor oldCursor = mAdapter.swapCursor(cursor);
prunePinnedConversations(cursor);
refreshPinnedStrip();
bindHomeCategoryStrip();
updateEmptyListUi(mAdapter.getConversationItemCount() == 0);
if (mListState != null && cursor != null && oldCursor == null) {
mRecyclerView.getLayoutManager().onRestoreInstanceState(mListState);
@@ -2028,6 +2262,11 @@ public class ConversationListFragment extends Fragment implements ConversationLi
mEmptyListMessageView.setContent(R.drawable.ic_home_menu_archive,
R.string.home_empty_archive_title,
R.string.home_empty_archive_body);
} else if (!HomeCategoryStore.ID_ALL.equals(mAdapter.getCategoryId())
|| mAdapter.getFilterMode() != ConversationListAdapter.FILTER_ALL) {
mEmptyListMessageView.setContent(R.drawable.ic_home_nav_messages,
R.string.home_category_empty_title,
R.string.home_category_empty_body);
} else {
mEmptyListMessageView.setContent(R.drawable.ic_home_nav_messages,
R.string.home_empty_messages_title,
@@ -341,20 +341,32 @@ public class ConversationListItemView extends FrameLayout implements OnClickList
private void setSnippet() {
final String snippetText = ellipsizeSnippetForMaxLines(getSnippetText());
final int firstLineMargin = getSnippetLeadingMargin();
final CharSequence toSet;
if (firstLineMargin > 0 && !TextUtils.isEmpty(snippetText)) {
final SpannableString spanned = new SpannableString(snippetText);
spanned.setSpan(new LeadingMarginSpan.Standard(firstLineMargin, 0),
0, spanned.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
toSet = spanned;
} else {
toSet = snippetText;
}
if (TextUtils.equals(toSet, mSnippetTextView.getText())) {
final int indent = getSnippetChipIndent();
if (indent <= 0) {
mSnippetTextView.setText(snippetText);
return;
}
mSnippetTextView.setText(toSet);
final SpannableString spanned = new SpannableString(snippetText == null ? "" : snippetText);
spanned.setSpan(new LeadingMarginSpan.Standard(indent, 0),
0, spanned.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
mSnippetTextView.setText(spanned);
}
private int getSnippetChipIndent() {
if (mCategoryChipView == null || mCategoryChipView.getVisibility() != VISIBLE) {
return 0;
}
int width = mCategoryChipView.getWidth();
if (width <= 0) {
mCategoryChipView.measure(
View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED),
View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED));
width = mCategoryChipView.getMeasuredWidth();
}
if (width <= 0) {
return 0;
}
return width + getResources().getDimensionPixelSize(R.dimen.looply_space_sm);
}
private String ellipsizeSnippetForMaxLines(final String source) {
@@ -367,11 +379,11 @@ public class ConversationListItemView extends FrameLayout implements OnClickList
if (maxLines <= 0 || width <= 0) {
return source;
}
final int firstLineMargin = getSnippetLeadingMargin();
final int indent = getSnippetChipIndent();
final CharSequence measured;
if (firstLineMargin > 0) {
if (indent > 0) {
final SpannableString spanned = new SpannableString(source);
spanned.setSpan(new LeadingMarginSpan.Standard(firstLineMargin, 0),
spanned.setSpan(new LeadingMarginSpan.Standard(indent, 0),
0, spanned.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
measured = spanned;
} else {
@@ -408,17 +420,6 @@ public class ConversationListItemView extends FrameLayout implements OnClickList
return source.substring(0, cut) + "...";
}
private int getSnippetLeadingMargin() {
if (mCategoryChipView == null || mCategoryChipView.getVisibility() != VISIBLE) {
return 0;
}
mCategoryChipView.measure(
MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED),
MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED));
return mCategoryChipView.getMeasuredWidth()
+ Math.round(6f * getResources().getDisplayMetrics().density);
}
private void updateSelectionIndicator(final boolean selectionMode, final boolean isSelected,
final Resources resources) {
if (mSelectionIndicatorView == null || mSelectionRingView == null
@@ -0,0 +1,100 @@
package com.android.messaging.ui.home;
import android.content.Context;
import android.text.TextUtils;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.Window;
import android.view.WindowManager;
import android.widget.FrameLayout;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import com.android.messaging.R;
import com.google.android.material.bottomsheet.BottomSheetDialog;
/** Default home bottom sheet: handle, close X, optional title, custom body, dismiss on outside tap. */
public final class HomeBottomSheet {
public static final class Builder {
private final Context mContext;
private CharSequence mTitle;
private View mCustom;
private boolean mCancelable = true;
public Builder(@NonNull final Context context) {
mContext = context;
}
public Builder title(@StringRes final int titleRes) {
mTitle = mContext.getString(titleRes);
return this;
}
public Builder title(final CharSequence title) {
mTitle = title;
return this;
}
public Builder custom(final View custom) {
mCustom = custom;
return this;
}
public Builder cancelable(final boolean cancelable) {
mCancelable = cancelable;
return this;
}
public BottomSheetDialog show() {
return HomeBottomSheet.show(this);
}
}
private HomeBottomSheet() {
}
public static Builder builder(@NonNull final Context context) {
return new Builder(context);
}
private static BottomSheetDialog show(final Builder options) {
final Context context = options.mContext;
final View root = LayoutInflater.from(context).inflate(R.layout.home_bottom_sheet, null, false);
final TextView title = root.findViewById(R.id.home_sheet_title);
if (title != null) {
title.setText(options.mTitle);
title.setVisibility(TextUtils.isEmpty(options.mTitle) ? View.GONE : View.VISIBLE);
}
final FrameLayout custom = root.findViewById(R.id.home_sheet_custom);
if (custom != null && options.mCustom != null) {
if (options.mCustom.getParent() instanceof ViewGroup) {
((ViewGroup) options.mCustom.getParent()).removeView(options.mCustom);
}
custom.addView(options.mCustom, new FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT));
}
final BottomSheetDialog dialog = new BottomSheetDialog(context,
R.style.Home_BottomSheetDialog);
dialog.setContentView(root);
dialog.setCancelable(options.mCancelable);
dialog.setCanceledOnTouchOutside(options.mCancelable);
final View close = root.findViewById(R.id.home_sheet_close);
if (close != null) {
close.setOnClickListener(v -> dialog.dismiss());
close.setVisibility(options.mCancelable ? View.VISIBLE : View.GONE);
}
final Window window = dialog.getWindow();
if (window != null) {
window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE);
}
dialog.show();
HomeInputFocus.install(dialog.getWindow());
return dialog;
}
}
@@ -0,0 +1,80 @@
package com.android.messaging.ui.home;
import android.content.Context;
import android.text.TextUtils;
import android.view.LayoutInflater;
import android.view.View;
import android.widget.EditText;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.android.messaging.R;
import com.android.messaging.util.ImeUtil;
import java.util.Collections;
import java.util.List;
/** Shared Add category name modal. */
public final class HomeCategoryAddModal {
public interface Listener {
void onCategoryCreated(HomeCategoryStore.Category created);
}
private HomeCategoryAddModal() {
}
public static void show(@NonNull final Context context, @Nullable final Listener listener) {
final View content = LayoutInflater.from(context)
.inflate(R.layout.home_category_name_dialog, null, false);
final EditText field = content.findViewById(R.id.home_category_name_field);
HomeModal.builder(context)
.icon(R.drawable.ic_home_category)
.title(R.string.home_category_add)
.message(R.string.home_category_name_message)
.custom(content)
.negative(android.R.string.cancel, null)
.positive(R.string.home_category_add_new, () -> {
final String name = field == null ? "" : field.getText().toString();
final HomeCategoryStore.Category created = tryCreate(context, name, "",
Collections.emptyList());
if (created != null && listener != null) {
listener.onCategoryCreated(created);
}
})
.show();
if (field != null) {
field.requestFocus();
ImeUtil.get().showImeKeyboard(context, field);
}
}
@Nullable
public static HomeCategoryStore.Category tryCreate(@NonNull final Context context,
final String name, final String kind, @Nullable final List<String> conversationIds) {
final String trimmed = name == null ? "" : name.trim();
if (TextUtils.isEmpty(trimmed)) {
return null;
}
if (HomeCategoryStore.hasName(context, trimmed)) {
HomeToast.show(context, R.string.home_category_exists_toast);
return null;
}
if (HomeCategoryStore.listCustom(context).size() >= HomeCategoryStore.MAX_CUSTOM) {
HomeToast.show(context, R.string.home_category_limit_toast);
return null;
}
final HomeCategoryStore.Category created = HomeCategoryStore.addCustom(context, trimmed,
kind);
if (created == null) {
HomeToast.show(context, R.string.home_category_exists_toast);
return null;
}
if (conversationIds != null && !conversationIds.isEmpty()) {
HomeCategoryStore.assignAll(context, conversationIds, created.id);
}
HomeToast.show(context, R.string.home_category_added_toast);
return created;
}
}
@@ -0,0 +1,134 @@
package com.android.messaging.ui.home;
import android.content.Context;
import android.text.TextUtils;
import android.view.LayoutInflater;
import android.view.View;
import androidx.annotation.NonNull;
import androidx.core.content.ContextCompat;
import com.android.messaging.R;
import com.android.messaging.datamodel.data.ConversationListItemData;
import com.google.android.material.chip.Chip;
import com.google.android.material.chip.ChipGroup;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
/** Assigns a conversation to categories using the default home modal. */
public final class HomeCategoryAssignSheet {
public interface Listener {
void onAssignmentsChanged();
}
private HomeCategoryAssignSheet() {
}
public static void show(@NonNull final Context context,
@NonNull final ConversationListItemData data, final Listener listener) {
final String conversationId = data.getConversationId();
if (TextUtils.isEmpty(conversationId)) {
return;
}
final View custom = LayoutInflater.from(context)
.inflate(R.layout.home_category_assign_sheet, null, false);
final ChipGroup chips = custom.findViewById(R.id.home_category_assign_chips);
final Set<String> selected = new LinkedHashSet<>(
HomeCategoryStore.assignedIds(context, conversationId));
if (!selected.contains(HomeCategoryStore.ID_PERSONAL)
&& !selected.contains(HomeCategoryStore.ID_PROMOTION)) {
selected.add(HomeCategoryClassifier.isPromotion(data)
? HomeCategoryStore.ID_PROMOTION : HomeCategoryStore.ID_PERSONAL);
}
final Runnable[] bindChips = new Runnable[1];
bindChips[0] = () -> {
if (chips == null) {
return;
}
chips.removeAllViews();
final List<HomeCategoryStore.Category> categories =
HomeCategoryStore.listTabs(context);
for (final HomeCategoryStore.Category category : categories) {
if (HomeCategoryStore.ID_ALL.equals(category.id)) {
continue;
}
final Chip chip = new Chip(context);
chip.setText(category.name);
chip.setCheckable(true);
chip.setCheckedIconVisible(false);
chip.setChecked(selected.contains(category.id));
styleChip(chip);
chip.setOnCheckedChangeListener((button, isChecked) -> {
if (isChecked) {
selected.add(category.id);
} else {
selected.remove(category.id);
}
styleChip(chip);
});
chips.addView(chip);
}
final Chip addChip = new Chip(context);
addChip.setText(R.string.home_category_add_new);
addChip.setCheckable(false);
addChip.setCheckedIconVisible(false);
styleChip(addChip);
addChip.setOnClickListener(v -> HomeCategoryAddModal.show(context, created -> {
selected.add(created.id);
bindChips[0].run();
}));
chips.addView(addChip);
};
bindChips[0].run();
HomeModal.builder(context)
.icon(R.drawable.ic_home_category)
.title(R.string.home_category_assign)
.message(R.string.home_category_assign_message)
.custom(custom)
.negative(android.R.string.cancel, null)
.positive(R.string.home_category_done, () -> {
applyAssignments(context, conversationId, selected);
if (listener != null) {
listener.onAssignmentsChanged();
}
HomeToast.show(context, R.string.home_category_updated_toast);
})
.show();
}
private static void applyAssignments(final Context context, final String conversationId,
final Set<String> selected) {
final Set<String> current = HomeCategoryStore.assignedIds(context, conversationId);
for (final String id : selected) {
if (!current.contains(id)) {
HomeCategoryStore.assign(context, conversationId, id);
}
}
for (final String id : current) {
if (!selected.contains(id)) {
HomeCategoryStore.unassign(context, conversationId, id);
}
}
}
private static void styleChip(final Chip chip) {
final float density = chip.getResources().getDisplayMetrics().density;
chip.setChipBackgroundColorResource(R.color.home_category_chip_bg);
chip.setChipStrokeColorResource(R.color.home_category_chip_stroke);
chip.setChipStrokeWidth(density);
chip.setChipMinHeight(36f * density);
chip.setChipStartPadding(12f * density);
chip.setChipEndPadding(12f * density);
chip.setTextSize(14f);
chip.setTypeface(chip.getTypeface(), android.graphics.Typeface.BOLD);
chip.setTextColor(ContextCompat.getColorStateList(chip.getContext(),
R.color.home_category_chip_text));
chip.setCheckedIconVisible(false);
chip.setEnsureMinTouchTargetSize(false);
}
}
@@ -0,0 +1,149 @@
package com.android.messaging.ui.home;
import android.content.Context;
import android.telephony.PhoneNumberUtils;
import android.text.TextUtils;
import com.android.messaging.R;
import com.android.messaging.datamodel.data.ConversationListItemData;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
/** Personal / promotion split and suggested categories from conversation text. */
public final class HomeCategoryClassifier {
public static final class Suggestion {
public final String kind;
public final String name;
public final List<String> conversationIds = new ArrayList<>();
Suggestion(final String kind, final String name) {
this.kind = kind;
this.name = name;
}
}
private HomeCategoryClassifier() {
}
public static boolean matches(final Context context, final String categoryId,
final ConversationListItemData item) {
if (item == null || TextUtils.isEmpty(categoryId)
|| HomeCategoryStore.ID_ALL.equals(categoryId)) {
return true;
}
if (HomeCategoryStore.ID_PERSONAL.equals(categoryId)) {
if (HomeCategoryStore.isAssigned(context, item.getConversationId(),
HomeCategoryStore.ID_PERSONAL)) {
return true;
}
if (HomeCategoryStore.isAssigned(context, item.getConversationId(),
HomeCategoryStore.ID_PROMOTION)) {
return false;
}
return isPersonal(item);
}
if (HomeCategoryStore.ID_PROMOTION.equals(categoryId)) {
if (HomeCategoryStore.isAssigned(context, item.getConversationId(),
HomeCategoryStore.ID_PROMOTION)) {
return true;
}
if (HomeCategoryStore.isAssigned(context, item.getConversationId(),
HomeCategoryStore.ID_PERSONAL)) {
return false;
}
return isPromotion(item);
}
return HomeCategoryStore.isAssigned(context, item.getConversationId(), categoryId);
}
public static boolean isPersonal(final ConversationListItemData item) {
return !isPromotion(item);
}
public static boolean isPromotion(final ConversationListItemData item) {
if (item == null) {
return false;
}
if (item.getIsGroup() || item.getParticipantContactId() > 0) {
return false;
}
if (looksLikeMobile(item)) {
return false;
}
return true;
}
public static boolean isVerification(final ConversationListItemData item) {
return containsAny(haystack(item),
"verification", "verify", "otp", "passcode", "auth code",
"인증", "인증번호", "인증코드", "인증 번호");
}
public static List<Suggestion> suggestions(final Context context,
final List<ConversationListItemData> items) {
final List<Suggestion> out = new ArrayList<>();
if (HomeCategoryStore.hasKind(context, HomeCategoryStore.KIND_VERIFICATION)) {
return out;
}
final Suggestion verification = new Suggestion(HomeCategoryStore.KIND_VERIFICATION,
context.getString(R.string.home_category_suggest_verification));
if (items != null) {
for (final ConversationListItemData item : items) {
if (item != null && isVerification(item)
&& !TextUtils.isEmpty(item.getConversationId())) {
verification.conversationIds.add(item.getConversationId());
}
}
}
if (!verification.conversationIds.isEmpty()) {
out.add(verification);
}
return out;
}
private static boolean looksLikeMobile(final ConversationListItemData item) {
final String dest = item.getOtherParticipantNormalizedDestination();
if (looksLikeMobileNumber(dest)) {
return true;
}
return looksLikeMobileNumber(item.getName());
}
private static boolean looksLikeMobileNumber(final String raw) {
if (TextUtils.isEmpty(raw)) {
return false;
}
final String digits = PhoneNumberUtils.normalizeNumber(raw);
if (TextUtils.isEmpty(digits)) {
return false;
}
for (int i = 0; i < digits.length(); i++) {
final char c = digits.charAt(i);
if (c != '+' && !Character.isDigit(c)) {
return false;
}
}
return digits.length() >= 8;
}
private static String haystack(final ConversationListItemData item) {
final String name = item.getName() == null ? "" : item.getName();
final String snippet = item.getSnippetText() == null ? "" : item.getSnippetText();
return (name + " " + snippet).toLowerCase(Locale.ROOT);
}
private static boolean containsAny(final String haystack, final String... needles) {
if (TextUtils.isEmpty(haystack)) {
return false;
}
for (final String needle : needles) {
if (haystack.contains(needle)) {
return true;
}
}
return false;
}
}
@@ -0,0 +1,152 @@
/*
* 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.content.Context;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewConfiguration;
import android.view.ViewParent;
import android.widget.HorizontalScrollView;
import com.android.messaging.R;
/** Category tab strip that keeps horizontal swipes instead of letting parents steal them. */
public final class HomeCategoryScroller extends HorizontalScrollView {
private final int mTouchSlop;
private float mDownX;
private float mDownY;
private boolean mDragging;
public HomeCategoryScroller(final Context context) {
this(context, null);
}
public HomeCategoryScroller(final Context context, final AttributeSet attrs) {
this(context, attrs, 0);
}
public HomeCategoryScroller(final Context context, final AttributeSet attrs,
final int defStyleAttr) {
super(context, attrs, defStyleAttr);
mTouchSlop = ViewConfiguration.get(context).getScaledTouchSlop();
setHorizontalScrollBarEnabled(false);
setHorizontalFadingEdgeEnabled(false);
setOverScrollMode(OVER_SCROLL_IF_CONTENT_SCROLLS);
}
@Override
protected void onLayout(final boolean changed, final int l, final int t, final int r,
final int b) {
super.onLayout(changed, l, t, r, b);
updateEdgeFades();
}
@Override
protected void onScrollChanged(final int l, final int t, final int oldl, final int oldt) {
super.onScrollChanged(l, t, oldl, oldt);
updateEdgeFades();
}
private void updateEdgeFades() {
final ViewParent parent = getParent();
if (!(parent instanceof View)) {
return;
}
final View host = (View) parent;
final View start = host.findViewById(R.id.home_category_fade_start);
final View end = host.findViewById(R.id.home_category_fade_end);
final boolean overflow = canScrollContent();
final int startVis = overflow && getScrollX() > 0 ? VISIBLE : GONE;
final int endVis = overflow ? VISIBLE : GONE;
if (start != null && start.getVisibility() != startVis) {
start.setVisibility(startVis);
}
if (end != null && end.getVisibility() != endVis) {
end.setVisibility(endVis);
}
}
@Override
public boolean onInterceptTouchEvent(final MotionEvent ev) {
switch (ev.getActionMasked()) {
case MotionEvent.ACTION_DOWN:
mDownX = ev.getX();
mDownY = ev.getY();
mDragging = false;
disallowParent(canScrollContent());
break;
case MotionEvent.ACTION_MOVE:
if (isHorizontalDrag(ev)) {
mDragging = true;
disallowParent(true);
return true;
}
break;
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
mDragging = false;
disallowParent(false);
break;
default:
break;
}
return super.onInterceptTouchEvent(ev) || mDragging;
}
@Override
public boolean onTouchEvent(final MotionEvent ev) {
final int action = ev.getActionMasked();
if (action == MotionEvent.ACTION_DOWN) {
mDownX = ev.getX();
mDownY = ev.getY();
mDragging = false;
} else if (action == MotionEvent.ACTION_MOVE && isHorizontalDrag(ev)) {
mDragging = true;
}
if (mDragging || canScrollContent()) {
disallowParent(action != MotionEvent.ACTION_UP && action != MotionEvent.ACTION_CANCEL);
}
if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) {
mDragging = false;
disallowParent(false);
}
return super.onTouchEvent(ev);
}
private boolean isHorizontalDrag(final MotionEvent ev) {
final float dx = Math.abs(ev.getX() - mDownX);
final float dy = Math.abs(ev.getY() - mDownY);
return dx > mTouchSlop && dx > dy;
}
private boolean canScrollContent() {
if (getChildCount() == 0) {
return false;
}
final View child = getChildAt(0);
return child.getWidth() > getWidth() - getPaddingLeft() - getPaddingRight();
}
private void disallowParent(final boolean disallow) {
final ViewParent parent = getParent();
if (parent != null) {
parent.requestDisallowInterceptTouchEvent(disallow);
}
}
}
@@ -0,0 +1,256 @@
package com.android.messaging.ui.home;
import android.content.Context;
import android.content.SharedPreferences;
import android.text.TextUtils;
import com.android.messaging.R;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.UUID;
/** Built-in and user-created conversation categories for the home list. */
public final class HomeCategoryStore {
public static final String ID_ALL = "all";
public static final String ID_PERSONAL = "personal";
public static final String ID_PROMOTION = "promotion";
public static final String KIND_VERIFICATION = "verification";
private static final String PREFS_NAME = "home_categories";
private static final String KEY_CUSTOM = "custom";
private static final String KEY_ASSIGN = "assign";
private static final String KEY_SELECTED = "selected";
private static final String ROW = "\n";
private static final String COL = "\t";
public static final int MAX_CUSTOM = 12;
public static final int MAX_NAME = 24;
public static final class Category {
public final String id;
public final String name;
public final String kind;
public final boolean builtIn;
Category(final String id, final String name, final String kind, final boolean builtIn) {
this.id = id;
this.name = name;
this.kind = kind == null ? "" : kind;
this.builtIn = builtIn;
}
}
private HomeCategoryStore() {
}
public static boolean isBuiltIn(final String id) {
return ID_ALL.equals(id) || ID_PERSONAL.equals(id) || ID_PROMOTION.equals(id);
}
public static List<Category> listTabs(final Context context) {
final List<Category> tabs = new ArrayList<>();
tabs.add(new Category(ID_ALL, context.getString(R.string.home_category_all), "", true));
tabs.add(new Category(ID_PERSONAL, context.getString(R.string.home_category_personal),
"", true));
tabs.add(new Category(ID_PROMOTION, context.getString(R.string.home_category_promotion),
"", true));
tabs.addAll(listCustom(context));
return tabs;
}
public static List<Category> listCustom(final Context context) {
final String raw = prefs(context).getString(KEY_CUSTOM, "");
if (TextUtils.isEmpty(raw)) {
return Collections.emptyList();
}
final List<Category> items = new ArrayList<>();
for (final String line : raw.split(ROW, -1)) {
if (TextUtils.isEmpty(line)) {
continue;
}
final String[] parts = line.split(COL, -1);
if (parts.length < 2 || TextUtils.isEmpty(parts[0]) || TextUtils.isEmpty(parts[1])) {
continue;
}
final String kind = parts.length > 2 ? parts[2] : "";
items.add(new Category(parts[0], parts[1], kind, false));
}
return items;
}
public static boolean hasKind(final Context context, final String kind) {
if (TextUtils.isEmpty(kind)) {
return false;
}
for (final Category category : listCustom(context)) {
if (kind.equals(category.kind)) {
return true;
}
}
return false;
}
public static boolean hasName(final Context context, final String name) {
final String trimmed = name == null ? "" : name.trim();
if (TextUtils.isEmpty(trimmed)) {
return false;
}
for (final Category category : listTabs(context)) {
if (trimmed.equalsIgnoreCase(category.name)) {
return true;
}
}
return false;
}
public static Category addCustom(final Context context, final String name, final String kind) {
String trimmed = name == null ? "" : name.trim();
if (TextUtils.isEmpty(trimmed)) {
return null;
}
if (trimmed.length() > MAX_NAME) {
trimmed = trimmed.substring(0, MAX_NAME);
}
if (hasName(context, trimmed)) {
return null;
}
final List<Category> items = new ArrayList<>(listCustom(context));
if (items.size() >= MAX_CUSTOM) {
return null;
}
final Category created = new Category("c_" + UUID.randomUUID().toString(), trimmed,
kind == null ? "" : kind, false);
items.add(created);
saveCustom(context, items);
return created;
}
public static void removeCustom(final Context context, final String categoryId) {
if (isBuiltIn(categoryId)) {
return;
}
final List<Category> items = new ArrayList<>(listCustom(context));
boolean removed = false;
for (int i = items.size() - 1; i >= 0; i--) {
if (items.get(i).id.equals(categoryId)) {
items.remove(i);
removed = true;
}
}
if (!removed) {
return;
}
saveCustom(context, items);
final List<String> kept = new ArrayList<>();
for (final String row : assignmentRows(context)) {
final String[] parts = row.split(COL, -1);
if (parts.length >= 2 && !categoryId.equals(parts[1])) {
kept.add(row);
}
}
prefs(context).edit().putString(KEY_ASSIGN, TextUtils.join(ROW, kept)).apply();
if (categoryId.equals(getSelectedId(context))) {
setSelectedId(context, ID_ALL);
}
}
public static void assign(final Context context, final String conversationId,
final String categoryId) {
if (TextUtils.isEmpty(conversationId) || TextUtils.isEmpty(categoryId)
|| ID_ALL.equals(categoryId)) {
return;
}
final LinkedHashSet<String> rows = new LinkedHashSet<>(assignmentRows(context));
rows.add(conversationId + COL + categoryId);
prefs(context).edit().putString(KEY_ASSIGN, TextUtils.join(ROW, rows)).apply();
}
public static void assignAll(final Context context, final List<String> conversationIds,
final String categoryId) {
if (conversationIds == null) {
return;
}
for (final String id : conversationIds) {
assign(context, id, categoryId);
}
}
public static void unassign(final Context context, final String conversationId,
final String categoryId) {
if (TextUtils.isEmpty(conversationId) || TextUtils.isEmpty(categoryId)) {
return;
}
final String needle = conversationId + COL + categoryId;
final List<String> rows = assignmentRows(context);
if (!rows.remove(needle)) {
return;
}
prefs(context).edit().putString(KEY_ASSIGN, TextUtils.join(ROW, rows)).apply();
}
public static Set<String> assignedIds(final Context context,
final String conversationId) {
final LinkedHashSet<String> ids = new LinkedHashSet<>();
if (TextUtils.isEmpty(conversationId)) {
return ids;
}
for (final String row : assignmentRows(context)) {
final String[] parts = row.split(COL, -1);
if (parts.length >= 2 && conversationId.equals(parts[0])) {
ids.add(parts[1]);
}
}
return ids;
}
public static boolean isAssigned(final Context context, final String conversationId,
final String categoryId) {
if (TextUtils.isEmpty(conversationId) || TextUtils.isEmpty(categoryId)) {
return false;
}
final String needle = conversationId + COL + categoryId;
return assignmentRows(context).contains(needle);
}
public static String getSelectedId(final Context context) {
final String selected = prefs(context).getString(KEY_SELECTED, ID_ALL);
return TextUtils.isEmpty(selected) ? ID_ALL : selected;
}
public static void setSelectedId(final Context context, final String categoryId) {
prefs(context).edit().putString(KEY_SELECTED,
TextUtils.isEmpty(categoryId) ? ID_ALL : categoryId).apply();
}
private static List<String> assignmentRows(final Context context) {
final String raw = prefs(context).getString(KEY_ASSIGN, "");
if (TextUtils.isEmpty(raw)) {
return new ArrayList<>();
}
final List<String> rows = new ArrayList<>();
for (final String line : raw.split(ROW, -1)) {
if (!TextUtils.isEmpty(line)) {
rows.add(line);
}
}
return rows;
}
private static void saveCustom(final Context context, final List<Category> items) {
final List<String> rows = new ArrayList<>(items.size());
for (final Category item : items) {
rows.add(item.id + COL + item.name + COL + item.kind);
}
prefs(context).edit().putString(KEY_CUSTOM, TextUtils.join(ROW, rows)).apply();
}
private static SharedPreferences prefs(final Context context) {
return context.getApplicationContext().getSharedPreferences(PREFS_NAME,
Context.MODE_PRIVATE);
}
}
@@ -0,0 +1,112 @@
/*
* 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.content.Context;
import android.util.AttributeSet;
import android.view.View;
import android.widget.LinearLayout;
import com.android.messaging.R;
/**
* Category tabs + add button. Tabs stay left of the add control; the add button
* follows the last tab and never crosses the strip's end padding.
*/
public final class HomeCategoryStrip extends LinearLayout {
public HomeCategoryStrip(final Context context) {
this(context, null);
}
public HomeCategoryStrip(final Context context, final AttributeSet attrs) {
this(context, attrs, 0);
}
public HomeCategoryStrip(final Context context, final AttributeSet attrs,
final int defStyleAttr) {
super(context, attrs, defStyleAttr);
setOrientation(HORIZONTAL);
setClipToPadding(true);
setClipChildren(true);
}
@Override
protected void onMeasure(final int widthMeasureSpec, final int heightMeasureSpec) {
final View all = findViewById(R.id.home_category_all);
final View add = findViewById(R.id.home_category_add);
final View scroll = findViewById(R.id.home_category_scroll_host);
int pinned = 0;
int contentHeight = 0;
if (all != null && all.getVisibility() != GONE) {
measureChildWithMargins(all, widthMeasureSpec, 0, heightMeasureSpec, 0);
pinned += measuredWidthWithMargins(all);
contentHeight = Math.max(contentHeight, measuredHeightWithMargins(all));
}
if (add != null && add.getVisibility() != GONE) {
measureChildWithMargins(add, widthMeasureSpec, pinned, heightMeasureSpec, 0);
pinned += measuredWidthWithMargins(add);
contentHeight = Math.max(contentHeight, measuredHeightWithMargins(add));
}
int contentWidth = pinned;
if (scroll != null && scroll.getVisibility() != GONE) {
measureChildWithMargins(scroll, widthMeasureSpec, pinned, heightMeasureSpec, 0);
contentWidth += measuredWidthWithMargins(scroll);
contentHeight = Math.max(contentHeight, measuredHeightWithMargins(scroll));
}
setMeasuredDimension(
resolveSize(contentWidth + getPaddingLeft() + getPaddingRight(),
widthMeasureSpec),
resolveSize(contentHeight + getPaddingTop() + getPaddingBottom(),
heightMeasureSpec));
}
@Override
protected void onLayout(final boolean changed, final int left, final int top, final int right,
final int bottom) {
final int innerTop = getPaddingTop();
final int innerBottom = bottom - top - getPaddingBottom();
int x = getPaddingLeft();
x = layoutChild(findViewById(R.id.home_category_all), x, innerTop, innerBottom);
x = layoutChild(findViewById(R.id.home_category_scroll_host), x, innerTop, innerBottom);
layoutChild(findViewById(R.id.home_category_add), x, innerTop, innerBottom);
}
private int layoutChild(final View child, final int x, final int innerTop,
final int innerBottom) {
if (child == null || child.getVisibility() == GONE) {
return x;
}
final MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();
final int height = child.getMeasuredHeight();
final int childTop = innerTop + lp.topMargin
+ Math.max(0, (innerBottom - innerTop - height - lp.topMargin - lp.bottomMargin) / 2);
final int childLeft = x + lp.leftMargin;
child.layout(childLeft, childTop, childLeft + child.getMeasuredWidth(),
childTop + height);
return childLeft + child.getMeasuredWidth() + lp.rightMargin;
}
private static int measuredWidthWithMargins(final View child) {
final MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();
return child.getMeasuredWidth() + lp.leftMargin + lp.rightMargin;
}
private static int measuredHeightWithMargins(final View child) {
final MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();
return child.getMeasuredHeight() + lp.topMargin + lp.bottomMargin;
}
}
@@ -89,6 +89,16 @@ public class HomeComposeActivity extends BugleActionBarActivity
mNextButton.setOnClickListener(v -> openGroupNameStep());
mRecipientField = findViewById(R.id.home_compose_recipient);
final View recipientBox = findViewById(R.id.home_compose_recipient_box);
if (recipientBox != null) {
recipientBox.setOnClickListener(v -> {
if (mRecipientField.getVisibility() != View.VISIBLE) {
return;
}
mRecipientField.requestFocus();
ImeUtil.get().showImeKeyboard(this, mRecipientField);
});
}
mRecipientField.addTextChangedListener(new TextWatcher() {
@Override
public void beforeTextChanged(final CharSequence s, final int start, final int count,
@@ -89,6 +89,5 @@ public final class HomeDeleteAlertDialog {
WindowManager.LayoutParams.WRAP_CONTENT);
window.getDecorView().setPadding(margin, 0, margin, 0);
}
HomeUiMotion.dialogEnter(content);
}
}
@@ -0,0 +1,88 @@
package com.android.messaging.ui.home;
import android.graphics.Rect;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewParent;
import android.view.Window;
import android.widget.EditText;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.view.WindowCallbackWrapper;
import com.android.messaging.util.ImeUtil;
/** Shared rule: tap outside an input box clears focus and hides the keyboard. */
public final class HomeInputFocus {
private HomeInputFocus() {
}
public static void install(@Nullable final Window window) {
if (window == null) {
return;
}
final Window.Callback current = window.getCallback();
if (current instanceof Installed) {
return;
}
window.setCallback(new Installed(current, window));
}
public static boolean clearIfOutside(@Nullable final View root, @Nullable final MotionEvent ev) {
if (root == null || ev == null || ev.getAction() != MotionEvent.ACTION_DOWN) {
return false;
}
final View focused = root.findFocus();
if (!(focused instanceof EditText)) {
return false;
}
final View box = findBox(focused);
if (isInside(box, ev)) {
return false;
}
root.setFocusable(true);
root.setFocusableInTouchMode(true);
focused.clearFocus();
root.requestFocus();
ImeUtil.get().hideImeKeyboard(focused.getContext(), focused);
return true;
}
private static View findBox(final View focused) {
ViewParent parent = focused.getParent();
while (parent instanceof ViewGroup) {
final ViewGroup group = (ViewGroup) parent;
if (group.addStatesFromChildren()) {
return group;
}
parent = group.getParent();
}
return focused;
}
private static boolean isInside(final View view, final MotionEvent ev) {
final Rect rect = new Rect();
if (!view.getGlobalVisibleRect(rect)) {
return false;
}
return rect.contains(Math.round(ev.getRawX()), Math.round(ev.getRawY()));
}
private static final class Installed extends WindowCallbackWrapper {
private final Window mWindow;
Installed(final Window.Callback wrapped, final Window window) {
super(wrapped);
mWindow = window;
}
@Override
public boolean dispatchTouchEvent(@NonNull final MotionEvent event) {
clearIfOutside(mWindow.getDecorView(), event);
return super.dispatchTouchEvent(event);
}
}
}
@@ -0,0 +1,271 @@
package com.android.messaging.ui.home;
import android.content.Context;
import android.graphics.drawable.ColorDrawable;
import android.text.TextUtils;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.Window;
import android.view.WindowManager;
import android.widget.FrameLayout;
import android.widget.ImageView;
import android.widget.TextView;
import androidx.annotation.DrawableRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import androidx.appcompat.app.AlertDialog;
import com.android.messaging.R;
/**
* Default home modal: centered icon on a soft well, title, body, optional
* custom content, and stacked pill actions.
*/
public final class HomeModal {
public interface Action {
void onClick();
}
public static final class Handle {
public final AlertDialog dialog;
public final TextView title;
public final TextView message;
public final TextView positive;
public final TextView negative;
public final FrameLayout custom;
Handle(final AlertDialog dialog, final TextView title, final TextView message,
final TextView positive, final TextView negative, final FrameLayout custom) {
this.dialog = dialog;
this.title = title;
this.message = message;
this.positive = positive;
this.negative = negative;
this.custom = custom;
}
public void setTitle(final CharSequence text) {
if (title != null) {
title.setText(text);
title.setVisibility(TextUtils.isEmpty(text) ? View.GONE : View.VISIBLE);
}
}
public void setMessage(final CharSequence text) {
if (message != null) {
message.setText(text);
message.setVisibility(TextUtils.isEmpty(text) ? View.GONE : View.VISIBLE);
}
}
public void setPositive(final CharSequence label, @Nullable final Action action,
final boolean dismiss) {
bindButton(positive, label, action, dismiss);
}
public void setNegative(final CharSequence label, @Nullable final Action action,
final boolean dismiss) {
bindButton(negative, label, action, dismiss);
}
private void bindButton(final TextView button, final CharSequence label,
final Action action, final boolean dismiss) {
if (button == null) {
return;
}
button.setText(label);
button.setVisibility(TextUtils.isEmpty(label) ? View.GONE : View.VISIBLE);
button.setOnClickListener(v -> {
if (dismiss) {
dialog.dismiss();
}
if (action != null) {
action.onClick();
}
});
}
}
public static final class Builder {
private final Context mContext;
@DrawableRes
private int mIconRes;
private CharSequence mTitle;
private CharSequence mMessage;
private CharSequence mPositive;
private CharSequence mNegative;
private Action mOnPositive;
private Action mOnNegative;
private View mCustom;
private boolean mCancelable = true;
public Builder(@NonNull final Context context) {
mContext = context;
}
public Builder icon(@DrawableRes final int iconRes) {
mIconRes = iconRes;
return this;
}
public Builder title(@StringRes final int titleRes) {
mTitle = mContext.getString(titleRes);
return this;
}
public Builder title(final CharSequence title) {
mTitle = title;
return this;
}
public Builder message(@StringRes final int messageRes) {
mMessage = mContext.getString(messageRes);
return this;
}
public Builder message(final CharSequence message) {
mMessage = message;
return this;
}
public Builder custom(final View custom) {
mCustom = custom;
return this;
}
public Builder positive(@StringRes final int labelRes, @Nullable final Action action) {
mPositive = mContext.getString(labelRes);
mOnPositive = action;
return this;
}
public Builder positive(final CharSequence label, @Nullable final Action action) {
mPositive = label;
mOnPositive = action;
return this;
}
public Builder negative(@StringRes final int labelRes, @Nullable final Action action) {
mNegative = mContext.getString(labelRes);
mOnNegative = action;
return this;
}
public Builder negative(final CharSequence label, @Nullable final Action action) {
mNegative = label;
mOnNegative = action;
return this;
}
public Builder cancelable(final boolean cancelable) {
mCancelable = cancelable;
return this;
}
public Handle show() {
return HomeModal.show(this);
}
}
private HomeModal() {
}
public static Builder builder(@NonNull final Context context) {
return new Builder(context);
}
private static Handle show(final Builder options) {
final Context context = options.mContext;
final View content = LayoutInflater.from(context).inflate(R.layout.home_modal, null, false);
bind(content, options);
final AlertDialog dialog = new AlertDialog.Builder(context, R.style.Home_AlertDialog)
.setView(content)
.setCancelable(options.mCancelable)
.create();
dialog.setCanceledOnTouchOutside(options.mCancelable);
final View close = content.findViewById(R.id.home_modal_close);
if (close != null) {
close.setVisibility(options.mCancelable ? View.VISIBLE : View.GONE);
close.setOnClickListener(v -> dialog.dismiss());
}
final TextView positive = content.findViewById(R.id.home_modal_positive);
final TextView negative = content.findViewById(R.id.home_modal_negative);
bindButton(negative, options.mNegative, options.mOnNegative, dialog, true);
bindButton(positive, options.mPositive, options.mOnPositive, dialog, true);
dialog.show();
final Window window = dialog.getWindow();
if (window != null) {
window.setBackgroundDrawable(new ColorDrawable(android.graphics.Color.TRANSPARENT));
window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE);
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);
HomeInputFocus.install(window);
}
return new Handle(dialog, content.findViewById(R.id.home_modal_title),
content.findViewById(R.id.home_modal_message), positive, negative,
content.findViewById(R.id.home_modal_custom));
}
private static void bind(final View content, final Builder options) {
final View iconWell = content.findViewById(R.id.home_modal_icon_well);
final ImageView icon = content.findViewById(R.id.home_modal_icon);
if (options.mIconRes != 0 && icon != null) {
icon.setImageResource(options.mIconRes);
if (iconWell != null) {
iconWell.setVisibility(View.VISIBLE);
} else {
icon.setVisibility(View.VISIBLE);
}
}
final TextView title = content.findViewById(R.id.home_modal_title);
if (title != null) {
title.setText(options.mTitle);
title.setVisibility(TextUtils.isEmpty(options.mTitle) ? View.GONE : View.VISIBLE);
}
final TextView message = content.findViewById(R.id.home_modal_message);
if (message != null) {
message.setText(options.mMessage);
message.setVisibility(TextUtils.isEmpty(options.mMessage) ? View.GONE : View.VISIBLE);
}
final FrameLayout custom = content.findViewById(R.id.home_modal_custom);
if (custom != null && options.mCustom != null) {
if (options.mCustom.getParent() instanceof ViewGroup) {
((ViewGroup) options.mCustom.getParent()).removeView(options.mCustom);
}
custom.addView(options.mCustom, new FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT));
custom.setVisibility(View.VISIBLE);
}
}
private static void bindButton(final TextView button, final CharSequence label,
final Action action, final AlertDialog dialog, final boolean dismiss) {
if (button == null || TextUtils.isEmpty(label)) {
return;
}
button.setText(label);
button.setVisibility(View.VISIBLE);
button.setOnClickListener(v -> {
if (dismiss) {
dialog.dismiss();
}
if (action != null) {
action.onClick();
}
});
}
}
@@ -16,12 +16,14 @@
package com.android.messaging.ui.home;
import android.animation.ArgbEvaluator;
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 android.widget.TextView;
import androidx.interpolator.view.animation.FastOutSlowInInterpolator;
@@ -679,19 +681,52 @@ public final class HomeUiMotion {
return;
}
content.setAlpha(0f);
content.setScaleX(0.94f);
content.setScaleY(0.94f);
content.setScaleX(0.92f);
content.setScaleY(0.92f);
beginLayer(content);
content.animate()
.alpha(1f)
.scaleX(1f)
.scaleY(1f)
.setDuration(300)
.setDuration(280)
.setInterpolator(EASE)
.withEndAction(() -> endLayer(content))
.start();
}
public static void dialogExit(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.94f)
.scaleY(0.94f)
.setDuration(180)
.setInterpolator(EASE)
.withEndAction(() -> {
endLayer(content);
content.setAlpha(1f);
content.setScaleX(1f);
content.setScaleY(1f);
if (endAction != null) {
endAction.run();
}
})
.start();
}
private static void applyBottomDockStateInstant(final View mainNav, final View selectionBar,
final boolean selectionActive) {
if (mainNav != null) {
@@ -907,7 +942,7 @@ public final class HomeUiMotion {
if (attachButton == null) {
return;
}
attachButton.setImageResource(R.drawable.ic_home_compose_plus);
attachButton.setImageResource(R.drawable.ic_home_automation_plus);
attachButton.setRotation(0f);
}
@@ -1070,4 +1105,107 @@ public final class HomeUiMotion {
})
.start();
}
/** Selects a category tab: accent color plus a scale-in underline. */
public static void applyCategoryTab(final TextView label, final View underline,
final int selectedColor, final int idleColor, final boolean selected,
final boolean animate) {
if (label != null) {
label.setTypeface(null, selected ? android.graphics.Typeface.BOLD
: android.graphics.Typeface.NORMAL);
final Object running = label.getTag(R.id.home_category_tab_label);
if (running instanceof ValueAnimator) {
((ValueAnimator) running).cancel();
label.setTag(R.id.home_category_tab_label, null);
}
if (!enabled() || !animate) {
label.setTextColor(selected ? selectedColor : idleColor);
} else {
final int from = label.getCurrentTextColor();
final int to = selected ? selectedColor : idleColor;
if (from != to) {
final ValueAnimator color = ValueAnimator.ofObject(new ArgbEvaluator(), from, to);
color.setDuration(HOME_TAB_MS);
color.setInterpolator(EASE);
color.addUpdateListener(animation ->
label.setTextColor((Integer) animation.getAnimatedValue()));
color.addListener(new android.animation.AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(final android.animation.Animator animation) {
if (label.getTag(R.id.home_category_tab_label) == color) {
label.setTag(R.id.home_category_tab_label, null);
}
}
});
label.setTag(R.id.home_category_tab_label, color);
color.start();
}
}
}
if (underline == null) {
return;
}
underline.animate().cancel();
underline.setPivotX(Math.max(underline.getWidth(), 1) / 2f);
underline.setPivotY(underline.getHeight());
if (selected) {
underline.setVisibility(View.VISIBLE);
if (!enabled() || !animate) {
underline.setScaleX(1f);
underline.setAlpha(1f);
return;
}
underline.setScaleX(0.35f);
underline.setAlpha(0f);
underline.animate()
.scaleX(1f)
.alpha(1f)
.setDuration(HOME_TAB_MS)
.setInterpolator(EASE)
.start();
return;
}
if (!enabled() || !animate || underline.getVisibility() != View.VISIBLE) {
underline.setScaleX(1f);
underline.setAlpha(1f);
underline.setVisibility(View.INVISIBLE);
return;
}
underline.animate()
.scaleX(0.35f)
.alpha(0f)
.setDuration(HOME_TAB_MS)
.setInterpolator(EASE)
.withEndAction(() -> {
underline.setVisibility(View.INVISIBLE);
underline.setScaleX(1f);
underline.setAlpha(1f);
})
.start();
}
/** Short fade + horizontal slide after a category list swap. */
public static void categoryPageEnter(final View list, final boolean fromEnd) {
if (list == null) {
return;
}
list.animate().cancel();
if (!enabled()) {
list.setAlpha(1f);
list.setTranslationX(0f);
return;
}
final int slide = Math.max(list.getWidth() / 12,
list.getResources().getDimensionPixelSize(R.dimen.looply_space_xl));
list.setAlpha(0.2f);
list.setTranslationX(fromEnd ? slide : -slide);
beginLayer(list);
list.animate()
.alpha(1f)
.translationX(0f)
.setDuration(HOME_TAB_MS)
.setInterpolator(EASE)
.withEndAction(() -> endLayer(list))
.start();
}
}