Skip to content

Conversation Settings Screen: Java/XML to Kotlin/Compose migration#112

Open
m4pl wants to merge 34 commits into
GrapheneOS:mainfrom
m4pl:conversation-settings-compose
Open

Conversation Settings Screen: Java/XML to Kotlin/Compose migration#112
m4pl wants to merge 34 commits into
GrapheneOS:mainfrom
m4pl:conversation-settings-compose

Conversation

@m4pl
Copy link
Copy Markdown
Contributor

@m4pl m4pl commented May 19, 2026

Migrated the Conversation Settings screen from Java + XML to Kotlin + Compose. Partially reused code from PR to avoid conflicts and code duplication. Additionally, implemented logic for blocking, archiving, and snooze settings.

Note: This PR is partially blocked by Rewrite Conversation screen PR, as the SIM card switching logic in chat settings cannot be properly tested until it is merged.

Screenshots

Before After
Screenshot_20260520_001018 Screenshot_20260519_235539

Video

Screen_recording_20260520_012114.webm

@m4pl m4pl requested review from RankoR and inthewaves May 19, 2026 23:24
@m4pl m4pl marked this pull request as ready for review May 19, 2026 23:31
Comment on lines +6 to +33
internal val ScreenContentPadding = 16.dp
internal val SectionSpacing = 12.dp
internal val GroupedItemSpacing = 4.dp

private val SettingsCardCornerRadius = 20.dp
private val GroupedItemInnerCornerRadius = 4.dp

internal val SettingsCardShape = RoundedCornerShape(
size = SettingsCardCornerRadius,
)

internal val GroupedTopItemShape = RoundedCornerShape(
topStart = SettingsCardCornerRadius,
topEnd = SettingsCardCornerRadius,
bottomStart = GroupedItemInnerCornerRadius,
bottomEnd = GroupedItemInnerCornerRadius,
)

internal val GroupedMiddleItemShape = RoundedCornerShape(
size = GroupedItemInnerCornerRadius,
)

internal val GroupedBottomItemShape = RoundedCornerShape(
topStart = GroupedItemInnerCornerRadius,
topEnd = GroupedItemInnerCornerRadius,
bottomStart = SettingsCardCornerRadius,
bottomEnd = SettingsCardCornerRadius,
)
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 it would be better to derive these from MaterialTheme shapes, something like (check the default dps of the shapes)

val MaterialTheme.settingsCardShape: CornerBasedShape
    @Composable @ReadOnlyComposable
    get() = shapes.extraLarge

val MaterialTheme.groupedMiddleItemShape: CornerBasedShape
    @Composable @ReadOnlyComposable
    get() = shapes.extraSmall

val MaterialTheme.groupedTopItemShape: CornerBasedShape
    @Composable @ReadOnlyComposable
    get() = shapes.extraLarge.copy(
        bottomStart = shapes.extraSmall.bottomStart,
        bottomEnd = shapes.extraSmall.bottomEnd,
    )

val MaterialTheme.groupedBottomItemShape: CornerBasedShape
    @Composable @ReadOnlyComposable
    get() = shapes.extraLarge.copy(
        topStart = shapes.extraSmall.topStart,
        topEnd = shapes.extraSmall.topEnd,
    )

fun closeAfterBlock_fromRoot_navigatesBackWithFinishResult() {
renderScreen()

emitNavEvent(ConversationSettingsNavEvent.CloseAfterBlock)
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.

Compile error Unresolved reference 'CloseAfterBlock'

Comment on lines +1 to +16
package com.android.messaging.data.conversationsettings.model

import java.util.concurrent.TimeUnit

private const val ONE_HOUR = 1L
private const val EIGHT_HOURS = 8L
private const val TWENTY_FOUR_HOURS = 24L

internal enum class SnoozeOption(
val durationMillis: Long,
) {
OneHour(TimeUnit.HOURS.toMillis(ONE_HOUR)),
EightHours(TimeUnit.HOURS.toMillis(EIGHT_HOURS)),
TwentyFourHours(TimeUnit.HOURS.toMillis(TWENTY_FOUR_HOURS)),
Always(Long.MAX_VALUE),
}
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.

For Kotlin, I think Kotlin Duration is more idomatic than Java TimeUnit

Comment on lines +90 to +101
is Action.ArchiveClicked -> {
delegate.setArchived(true)
emitNavigationEvent(NavEvent.CloseAfterArchive)
}

is Action.UnblockClicked -> {
delegate.setDestinationBlocked(false)
}

is Action.BlockConfirmed -> {
delegate.setDestinationBlocked(true)
}
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.

Going by the test compilation failure, for Action.BlockConfirmed, are we missing a CloseAfterBlock here? It seems old Messaging fragment does close after block:

case PeopleOptionsItemData.SETTING_BLOCKED:
if (item.getOtherParticipant().isBlocked()) {
mBinding.getData().setDestinationBlocked(mBinding, false);
break;
}
final Resources res = getResources();
final Activity activity = getActivity();
new AlertDialog.Builder(activity)
.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,
new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface arg0, int arg1) {
mBinding.getData().setDestinationBlocked(mBinding, true);
activity.setResult(ConversationActivity.FINISH_RESULT_CODE);
activity.finish();
}
})
.create()
.show();
break;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Ah, yes, exactly. Thanks for pointing that out, I forgot to fix the test in the last commit. I used Google's Messages app as a reference: it doesn't go back after a block, but it does after an archive. I decided to replicate that behavior, so I'll fix the test to match it.

Comment thread AndroidManifest.xml Outdated
Comment on lines +232 to +249
<activity
android:name=".ui.conversationsettings.PeopleAndOptionsActivity"
android:name=".ui.conversationsettings.ConversationSettingsActivity"
android:allowEmbedded="true"
android:configChanges="orientation|screenSize|keyboardHidden"
android:screenOrientation="user"
android:label="@string/people_and_options_activity_title"
android:theme="@style/BugleTheme"
android:parentActivityName="com.android.messaging.ui.conversation.ConversationActivity">
android:parentActivityName="com.android.messaging.ui.conversation.ConversationActivity"
android:resizeableActivity="true"
android:screenOrientation="user"
android:theme="@style/Theme.Compose">
<meta-data
android:name="android.support.PARENT_ACTIVITY"
android:value="com.android.messaging.ui.conversation.ConversationActivity" />
</activity>

<activity-alias
android:name=".ui.conversationsettings.PeopleAndOptionsActivity"
android:exported="true"
android:targetActivity=".ui.conversationsettings.ConversationSettingsActivity" />
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 we don't need this activity-alias. We needed it for the app settings activity because it originally had android:exported="true" so it could've potentially broken external apps that used that activity,

For here, the original PeopleAndOptionsActivity had no android:exported attribute (and no intent-filter), so it was effectively not exported

conversationId = conversationId,
conversationTitle = metadata?.conversationName.orEmpty(),
isArchived = metadata?.isArchived ?: false,
isSnoozed = notificationRepository.isSnoozed(conversationId),
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 to compute isSnoozed once and snapshots it based on System.currentTimeMillis(). Technically, snooze time can run out while the screen is active, and it won't update the UI

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I agree. It's unlikely, but it could happen. I'll correct it.

Comment on lines +103 to +105
is Action.ParticipantPressed -> {
resolveConversation(action.destination, shouldOpenChat = true)
}
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.

Old Messages opened the contact card on participant press

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I changed this because, in my opinion, it makes more sense to open the chat when tapping on a participant. But I can easily change it back if you want. Also, just a heads-up that there is now a Contact info button at the top of the settings:
Screenshot_20260521_101041

Copy link
Copy Markdown
Member

@inthewaves inthewaves May 21, 2026

Choose a reason for hiding this comment

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

Ah okay, that makes sense. But from a consistency point of view, a lot of other messaging apps will show some sort of contact info or options in this area instead of opening the chat. e.g. the old Messaging app open contact info, Google Messages has a popup with options, Signal and WhatsApp show a bottom sheet with actions

Also, opening chat screen from here can have other implications. e.g., someone might want to leave a message for a person in a group unread because they might not want to read it at that time but still want the unread status so that they know to come back later. If we make this default open the chat screen, it will mark those messages as read. If they are used to other messaging apps showing contact options in this area, then this will be a surprise.

Generally, for actual groups, I think it's better to make contact info or at least some options as the primary action instead of as the secondary action on the side, especially for people they don't have in their contacts yet.

Google Messages: Contact

Google Messages: Non-contact

Signal

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thanks for ideas, I'll refine the approach

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I have added the logic to open the user preview when clicking on the item. I also added this same preview to the blocked contacts screen. I tried to make it look nice and clear:
Screenshot_20260525_101114

Copy link
Copy Markdown
Member

@inthewaves inthewaves May 26, 2026

Choose a reason for hiding this comment

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

That looks pretty good! (I don't know why this comment showed up down here but it's for #112 (comment))


return isVoiceCapable &&
!phoneNumber.isNullOrBlank() &&
!PhoneNumberUtils.isEmergencyNumber(phoneNumber)
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.

marked as deprecated:

Please use TelephonyManager.isEmergencyNumber(String) instead.

participant: ParticipantUiState?,
isVoiceCapable: Boolean,
): Boolean {
val phoneNumber = participant?.normalizedDestination
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 can be an email

pd.mIsEmailAddress = MmsSmsUtils.isEmailAddress(pd.mSendDestination);
pd.mNormalizedDestination = pd.mIsEmailAddress ?
pd.mSendDestination :
PhoneUtils.getDefault().getCanonicalBySystemLocale(pd.mSendDestination);

Can use something like PhoneNumberUtils.isWellFormedSmsAddress to make sure

Comment on lines +113 to +115
LaunchedEffect(screenModel, effectHandler) {
screenModel.effects.collect(effectHandler::handle)
}
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.

Technically effectHandler is only created in Activity onCreate so it's only created once (and recreating activity would recompose everything anyway). But we generally shouldn't use what's effectively a lambda as a key to a LaunchedEffect; it's better to use https://developer.android.com/develop/ui/compose/side-effects#rememberupdatedstate

@m4pl m4pl force-pushed the conversation-settings-compose branch 2 times, most recently from d9a14e5 to 1ed081e Compare May 21, 2026 12:48
@inthewaves
Copy link
Copy Markdown
Member

I've approved the conversation screen rewrite (#109), so we'll be able to properly test this more after that

@m4pl m4pl force-pushed the conversation-settings-compose branch 3 times, most recently from 4309022 to a00c959 Compare May 24, 2026 14:58
Comment on lines +13 to 34
private val AppShapes = Shapes(
extraSmall = RoundedCornerShape(size = 12.dp),
small = RoundedCornerShape(size = 16.dp),
medium = RoundedCornerShape(size = 20.dp),
large = RoundedCornerShape(size = 28.dp),
extraLarge = RoundedCornerShape(size = 36.dp),
)

@Composable
fun AppTheme(
content: @Composable () -> Unit,
) {
val context = LocalContext.current
val colorScheme = when {
isSystemInDarkTheme() -> dynamicDarkColorScheme(context = context)
else -> dynamicLightColorScheme(context = context)
}

MaterialTheme(
colorScheme = colorScheme,
shapes = AppShapes,
content = content,
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.

cc @RankoR as both of you have been doing stuff with shapes in PRs: Does this look okay?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I copied this code from @RankoR

Comment on lines +223 to +225
if (onAction != null) {
ParticipantInfoButton(onClick = onAction)
}
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 this info button might be redundant now that clicking the row has that quick actions popup

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

They actually lead to different screens - the popup's contact action opens the system contact screen, while the info button navigates to our in-app participant info screen. I'd like to keep both behaviors, but happy to hear your thoughts. Maybe we should surface the in-app one differently?

Copy link
Copy Markdown
Member

@inthewaves inthewaves May 26, 2026

Choose a reason for hiding this comment

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

Ah I see, that seems fine then. Google Messages does seem to do it the opposite way: their in-app button is in the popup but the side button (a vertical 3-dot icon) is the one that opens system contact screen

Comment on lines +103 to +105
is Action.ParticipantPressed -> {
resolveConversation(action.destination, shouldOpenChat = true)
}
Copy link
Copy Markdown
Member

@inthewaves inthewaves May 26, 2026

Choose a reason for hiding this comment

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

That looks pretty good! (I don't know why this comment showed up down here but it's for #112 (comment))

Comment on lines +11 to +26
internal class AnchorRelativePositionProvider(
private val anchorBoundsPx: IntRect,
private val gapPx: Int,
private val transformOriginState: MutableState<TransformOrigin>,
) : PopupPositionProvider {

override fun calculatePosition(
anchorBounds: IntRect,
windowSize: IntSize,
layoutDirection: LayoutDirection,
popupContentSize: IntSize,
): IntOffset {
val popupWidth = popupContentSize.width
val popupHeight = popupContentSize.height

val rightX = anchorBoundsPx.left
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.

AnchorRelativePositionProvider seems to already receive an anchorBounds parameter the calculatePosition function, so probably don't need to store anchorBoundsPx in the class

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The anchorBounds param Compose passes is the bounds of the Popup's parent layout, not the avatar's. Since the popup should be anchored to the avatar specifically, those bounds are measured explicitly via onGloballyPositioned. Let me know if you see a cleaner way!

Copy link
Copy Markdown
Member

@inthewaves inthewaves May 26, 2026

Choose a reason for hiding this comment

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

Maybe we could just put the avatar + quick actions inside of an avatar-sized Box inside of ParticipantAvatarSlot, then the ParticipantAvatar can just use Modifer.matchParentSize()

Comment on lines +588 to +615
participants.forEach { participant ->
val destination = participant.normalizedDestination.orEmpty()
val hasDestination = destination.isNotEmpty()

ParticipantItem(
participant = participant,
onClick = {
if (hasDestination) {
onAction(ParticipantAction.ParticipantPressed(destination))
}
},
onLongClick = {
val details = participant.details
if (!details.isNullOrEmpty()) {
onAction(ParticipantAction.ParticipantLongPressed(details))
}
},
onCallClick = {
onAction(ParticipantAction.ParticipantCallClicked(destination))
}.takeIf { participant.canCall },
onContactClick = {
onAction(ParticipantAction.ParticipantContactInfoClicked(participant))
}.takeIf { hasDestination },
onAction = {
onAction(ParticipantAction.ParticipantActionPressed(destination))
}.takeIf { hasDestination && isGroup },
)
}
Copy link
Copy Markdown
Member

@inthewaves inthewaves May 26, 2026

Choose a reason for hiding this comment

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

Better for us surround this ParticipantItem with key(some stable id from participant) especially since this is in a Column and group participants could technically change, each participant holds its own state + has side effects, etc.

From https://developer.android.com/develop/ui/compose/lifecycle#add-info-smart-recomposition:

Calling a composable multiple times will add it to Composition multiple times as well. When calling a composable multiple times from the same call site, Compose doesn’t have any information to uniquely identify each call to that composable, so the execution order is used in addition to the call site in order to keep the instances distinct. This behavior is sometimes all that is needed, but in some cases it can cause unwanted behavior.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done. Added a stable id: String to ParticipantUiState.

@m4pl m4pl force-pushed the conversation-settings-compose branch from 751fc18 to defa8fe Compare May 28, 2026 00:40
Copy link
Copy Markdown
Member

@inthewaves inthewaves left a comment

Choose a reason for hiding this comment

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

Looks good

@m4pl m4pl force-pushed the conversation-settings-compose branch from defa8fe to 16b6b2d Compare May 29, 2026 13:22
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