Skip to content

Conversation List Screen: Java/XML to Kotlin/Compose migration#162

Open
m4pl wants to merge 38 commits into
GrapheneOS:mainfrom
m4pl:conversation-list-compose
Open

Conversation List Screen: Java/XML to Kotlin/Compose migration#162
m4pl wants to merge 38 commits into
GrapheneOS:mainfrom
m4pl:conversation-list-compose

Conversation

@m4pl

@m4pl m4pl commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Migrated the Conversation List screen from Java + XML to Kotlin + Compose. Please verify the list animations (archive, undo, pin/unpin, scroll-to-top) manually on a device, as I had some issues after writing the code. I also tried to move some components to ui/common/components.

Screenshots

Before After
Screenshot_20260625_021402 Screenshot_20260625_021447

Video

Screen_recording_20260625_015257.mp4

@m4pl m4pl force-pushed the conversation-list-compose branch from bad5319 to d8c26b7 Compare June 25, 2026 23:53
m4pl added 29 commits June 26, 2026 12:36
@m4pl m4pl force-pushed the conversation-list-compose branch from d8c26b7 to b9681c6 Compare June 26, 2026 10:56
@m4pl m4pl requested review from RankoR and inthewaves and removed request for RankoR June 26, 2026 14:45
@m4pl m4pl force-pushed the conversation-list-compose branch from a6b46ac to 000a06c Compare June 26, 2026 14:47
@m4pl m4pl marked this pull request as ready for review June 26, 2026 15:05
import com.android.messaging.data.conversationlist.model.ConversationListMessageStatus

@Immutable
internal data class ConversationListItemUiModel(

@inthewaves inthewaves Jun 27, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the last message was an incoming MMS message that hasn't been downloaded, not enough info is displayed to the user:

Google Messages for reference on the same message:

Comment on lines +452 to +455
val description = when {
isArchive -> stringResource(R.string.action_archive)
else -> stringResource(R.string.mark_as_unread)
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems like it'll use R.string.mark_as_unread for content description even if the swipe action is mark as read

Comment on lines +69 to +74
private int upgradeToVersion3(final SQLiteDatabase db) {
db.execSQL("ALTER TABLE " + DatabaseHelper.CONVERSATIONS_TABLE + " ADD COLUMN " +
DatabaseHelper.ConversationColumns.PINNED + " INT DEFAULT(0)");
LogUtil.i(TAG, "Upgraded database to version 3");
return 3;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems to be missing the index creation

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As a side note, we should probably eventually add tests to ensure a migrated database is the same as newly created database

enableEdgeToEdge()

setContent {
AppTheme {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The theme for ConversationListActivity in the manifest is still @style/LaunchTheme, maybe we should use Theme.Compose instead

Comment on lines +97 to +101
var pendingAddContactDestination by rememberSaveable { mutableStateOf<String?>(null) }
var pendingDelete by rememberSaveable { mutableStateOf(false) }
var pendingBlockConversationId by rememberSaveable { mutableStateOf<String?>(null) }
var pendingBlockDestination by rememberSaveable { mutableStateOf<String?>(null) }
var pendingSnooze by rememberSaveable { mutableStateOf(false) }

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These use rememberSaveable but they seem like they can be out of sync with the actual state in the ViewModel, e.g

  1. Enable Android developer option "Don't keep activities", or otherwise force the app process/activity to be destroyed while in the background
  2. Open Messaging, long-press a conversation, and tap the delete action so the confirmation dialog is visible.
  3. Background the app, then return to Messaging from Recents.
  4. Observe that the delete dialog can be restored even though the new view-model state no longer has the original selection. When only one conversation was selected for deletion, the dialog title can change from "Delete this conversation" to "Delete these conversations?" after the process death, and the delete button is a no-op.
  5. Repeat with snooze, the dialog will be a no-op

It seems that for block or add contact from a row action, the restored dialog can still reference the old destination after the underlying list state has been recreated

It might be better to just make these remember


@Composable
internal fun ConversationListDialogs(
selectedCount: Int,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we should sanity check this against a selectedCount of 0. Dialogs are still shown when selectedCount is 0 it seems (e.g. the Don't keep activities enabled with Delete and Snooze dialogs)

Comment on lines +67 to +75
if (isSnoozeVisible) {
SnoozeChatDialog(
onDismiss = onDismissSnooze,
onConfirm = { option ->
onAction(Action.SnoozeOptionSelected(option))
onDismissSnooze()
},
)
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The snooze chat dialog should use plural strings when multiple chats are selected. The dialog currently says "Snooze chat" and "You won't get notifications from this chat for the selected period"

Comment on lines +57 to +84
TwoLineListItem(
onClick = onClick,
leadingContent = {
ConversationListItemAvatar(
item = item,
isSelectionMode = isSelectionMode,
onToggleSelection = onClick,
onMessageClick = onAvatarMessageClick,
onCallClick = onAvatarCallClick,
onContactClick = onAvatarContactClick,
onInfoClick = onAvatarInfoClick,
)
},
titleContent = {
ConversationListItemHeader(item)
},
modifier = modifier.testTag(
tag = conversationListItemTestTag(item.conversationId),
),
onLongClick = onLongClick,
color = itemContainerColor(item),
subtitleContent = {
ConversationListItemBody(item)
},
trailingContent = {
ConversationListItemTrailing(item)
},
)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This appears to regress a lot of the accessibility content descriptions compared to the old Messages code, e.g.

https://github.com/GrapheneOS/Messaging/blob/12/src/com/android/messaging/ui/conversationlist/ConversationListItemView.java#L304

https://github.com/GrapheneOS/Messaging/blob/12/res/values/strings.xml#L372-L389

When I use TalkBack with the old messages app, for successful messages, it will say stuff like

Message from <contact>, <message body>, time <time>

This is from

<string name="one_on_one_incoming_successful_message_prefix">Message from <xliff:g id="sender">%1$s</xliff:g>: <xliff:g id="message">%2$s</xliff:g>. Time: <xliff:g id="time">%3$s</xliff:g>.</string>

e.g.

Message from Bob, "Hello there". Time: 33 minutes

and it will not select the avatar. The only other things TalkBack will scroll over are attachments, e.g. for an audio attachment, it will say "Play audio attachment"

When I use TalkBack with this PR, it will just say,

Bob, 33 minutes, "Hello there"

and for the top conversation it might say "in list, 57 items"

Swiping on TalkBack to try to go to the next may select the avatar (when the contact doesn't have a custom avatar in contacts) and it'll just say "capital F" for a contact with first name F, or "unlabeled" for just a number or a group avatar

Comment on lines +218 to +226
@Composable
private fun ConversationListItemBadgeIcon(icon: ImageVector) {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(ItemBadgeIconSize),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}

@inthewaves inthewaves Jun 27, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should avoid null contentDescriptions and describe what the badge is indicating, or the accessibility hint on the conversation list item itself should mention the status instead of the badge icon being traversable

Comment on lines +402 to +413
) { contentPadding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(top = contentPadding.calculateTopPadding())
.onGloballyPositioned { coordinates ->
pinAnimationController?.updateContentTop(coordinates.boundsInRoot().top)
}
.background(backdropColor)
.clip(ContentCornerShape)
.background(MaterialTheme.colorScheme.background),
) {

@inthewaves inthewaves Jun 27, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Needs to be inset aware when using 3-button gesture navigation; the camera cutout can also clip into the sides especially in the other landscape orientation (camera hole not shown)

(The appbar itself seems to be inset aware)

Comment on lines +182 to +191
@Composable
private fun ConversationListItemTrailing(item: ConversationListItemUiModel) {
Row(
modifier = Modifier.padding(end = ItemTrailingEndPadding),
horizontalArrangement = Arrangement.spacedBy(ItemHeaderSpacing),
verticalAlignment = Alignment.CenterVertically,
) {
ConversationListItemPreviewThumbnail(item.snippet.preview)
}
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the thumbnail placement makes it look unaligned or not uniform, because it offsets the timestamp compared to other conversations that don't have the thumbnail

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants