Convert InternalConversationSettings to compose.

This commit is contained in:
Alex Hart
2025-05-27 10:48:34 -03:00
committed by GitHub
parent 6879778f4b
commit daa3e5d95a
5 changed files with 804 additions and 417 deletions

View File

@@ -16,6 +16,7 @@
<option name="ALIGN_MULTILINE_TEXT_BLOCKS" value="true" /> <option name="ALIGN_MULTILINE_TEXT_BLOCKS" value="true" />
<option name="IMPORT_LAYOUT_TABLE"> <option name="IMPORT_LAYOUT_TABLE">
<value> <value>
<package name="" withSubpackages="true" static="false" module="true" />
<package name="android" withSubpackages="true" static="false" /> <package name="android" withSubpackages="true" static="false" />
<emptyLine /> <emptyLine />
<package name="androidx" withSubpackages="true" static="false" /> <package name="androidx" withSubpackages="true" static="false" />

View File

@@ -1,38 +1,32 @@
package org.thoughtcrime.securesms.components.settings.conversation package org.thoughtcrime.securesms.components.settings.conversation
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.Color
import android.text.TextUtils
import android.widget.Toast import android.widget.Toast
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import com.google.android.material.dialog.MaterialAlertDialogBuilder import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.signal.core.util.Base64 import kotlinx.coroutines.flow.MutableStateFlow
import org.signal.core.util.Hex import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import org.signal.core.util.concurrent.SignalExecutors import org.signal.core.util.concurrent.SignalExecutors
import org.signal.core.util.isAbsent import org.signal.core.util.isAbsent
import org.signal.core.util.orNull
import org.signal.core.util.roundedString import org.signal.core.util.roundedString
import org.signal.core.util.withinTransaction import org.signal.core.util.withinTransaction
import org.signal.libsignal.zkgroup.profiles.ProfileKey import org.signal.libsignal.zkgroup.profiles.ProfileKey
import org.thoughtcrime.securesms.MainActivity import org.thoughtcrime.securesms.MainActivity
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.attachments.Attachment import org.thoughtcrime.securesms.attachments.Attachment
import org.thoughtcrime.securesms.attachments.UriAttachment import org.thoughtcrime.securesms.attachments.UriAttachment
import org.thoughtcrime.securesms.components.settings.DSLConfiguration import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.database.AttachmentTable import org.thoughtcrime.securesms.database.AttachmentTable
import org.thoughtcrime.securesms.database.MessageType
import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
import org.thoughtcrime.securesms.database.model.RecipientRecord
import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.groups.GroupId import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.mms.IncomingMessage
import org.thoughtcrime.securesms.mms.OutgoingMessage import org.thoughtcrime.securesms.mms.OutgoingMessage
import org.thoughtcrime.securesms.profiles.AvatarHelper import org.thoughtcrime.securesms.profiles.AvatarHelper
import org.thoughtcrime.securesms.providers.BlobProvider import org.thoughtcrime.securesms.providers.BlobProvider
@@ -41,10 +35,7 @@ import org.thoughtcrime.securesms.recipients.RecipientForeverObserver
import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.BitmapUtil import org.thoughtcrime.securesms.util.BitmapUtil
import org.thoughtcrime.securesms.util.MediaUtil import org.thoughtcrime.securesms.util.MediaUtil
import org.thoughtcrime.securesms.util.SpanUtil
import org.thoughtcrime.securesms.util.Util import org.thoughtcrime.securesms.util.Util
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.livedata.Store
import java.util.Objects import java.util.Objects
import kotlin.random.Random import kotlin.random.Random
import kotlin.time.Duration.Companion.nanoseconds import kotlin.time.Duration.Companion.nanoseconds
@@ -53,9 +44,8 @@ import kotlin.time.DurationUnit
/** /**
* Shows internal details about a recipient that you can view from the conversation settings. * Shows internal details about a recipient that you can view from the conversation settings.
*/ */
class InternalConversationSettingsFragment : DSLSettingsFragment( @Stable
titleId = R.string.ConversationSettingsFragment__internal_details class InternalConversationSettingsFragment : ComposeFragment(), InternalConversationSettingsScreenCallbacks {
) {
private val viewModel: InternalViewModel by viewModels( private val viewModel: InternalViewModel by viewModels(
factoryProducer = { factoryProducer = {
@@ -64,378 +54,14 @@ class InternalConversationSettingsFragment : DSLSettingsFragment(
} }
) )
override fun bindAdapter(adapter: MappingAdapter) { @Composable
viewModel.state.observe(viewLifecycleOwner) { state -> override fun FragmentContent() {
adapter.submitList(getConfiguration(state).toMappingModelList()) val state: InternalConversationSettingsState by viewModel.state.collectAsStateWithLifecycle()
}
}
private fun getConfiguration(state: InternalState): DSLConfiguration { InternalConversationSettingsScreen(
val recipient = state.recipient state = state,
return configure { callbacks = this
sectionHeaderPref(DSLSettingsText.from("Data")) )
textPref(
title = DSLSettingsText.from("RecipientId"),
summary = DSLSettingsText.from(recipient.id.serialize())
)
if (!recipient.isGroup) {
val e164: String = recipient.e164.orElse("null")
longClickPref(
title = DSLSettingsText.from("E164"),
summary = DSLSettingsText.from(e164),
onLongClick = { copyToClipboard(e164) }
)
val aci: String = recipient.aci.map { it.toString() }.orElse("null")
longClickPref(
title = DSLSettingsText.from("ACI"),
summary = DSLSettingsText.from(aci),
onLongClick = { copyToClipboard(aci) }
)
val pni: String = recipient.pni.map { it.toString() }.orElse("null")
longClickPref(
title = DSLSettingsText.from("PNI"),
summary = DSLSettingsText.from(pni),
onLongClick = { copyToClipboard(pni) }
)
}
if (state.groupId != null) {
val groupId: String = state.groupId.toString()
longClickPref(
title = DSLSettingsText.from("GroupId"),
summary = DSLSettingsText.from(groupId),
onLongClick = { copyToClipboard(groupId) }
)
}
val threadId: String = if (state.threadId != null) state.threadId.toString() else "N/A"
longClickPref(
title = DSLSettingsText.from("ThreadId"),
summary = DSLSettingsText.from(threadId),
onLongClick = { copyToClipboard(threadId) }
)
if (!recipient.isGroup) {
textPref(
title = DSLSettingsText.from("Profile Name"),
summary = DSLSettingsText.from("[${recipient.profileName.givenName}] [${state.recipient.profileName.familyName}]")
)
val profileKeyBase64 = recipient.profileKey?.let(Base64::encodeWithPadding) ?: "None"
longClickPref(
title = DSLSettingsText.from("Profile Key (Base64)"),
summary = DSLSettingsText.from(profileKeyBase64),
onLongClick = { copyToClipboard(profileKeyBase64) }
)
val profileKeyHex = recipient.profileKey?.let(Hex::toStringCondensed) ?: ""
longClickPref(
title = DSLSettingsText.from("Profile Key (Hex)"),
summary = DSLSettingsText.from(profileKeyHex),
onLongClick = { copyToClipboard(profileKeyHex) }
)
textPref(
title = DSLSettingsText.from("Sealed Sender Mode"),
summary = DSLSettingsText.from(recipient.sealedSenderAccessMode.toString())
)
textPref(
title = DSLSettingsText.from("Phone Number Sharing"),
summary = DSLSettingsText.from(recipient.phoneNumberSharing.name)
)
textPref(
title = DSLSettingsText.from("Phone Number Discoverability"),
summary = DSLSettingsText.from(SignalDatabase.recipients.getPhoneNumberDiscoverability(recipient.id)?.name ?: "null")
)
}
textPref(
title = DSLSettingsText.from("Profile Sharing (AKA \"Whitelisted\")"),
summary = DSLSettingsText.from(recipient.isProfileSharing.toString())
)
if (!recipient.isGroup) {
textPref(
title = DSLSettingsText.from("Capabilities"),
summary = DSLSettingsText.from(buildCapabilitySpan(recipient))
)
}
clickPref(
title = DSLSettingsText.from("Trigger Thread Update"),
summary = DSLSettingsText.from("Triggers a thread update. Useful for testing perf."),
onClick = {
val startTimeNanos = System.nanoTime()
SignalDatabase.threads.update(state.threadId ?: -1, true)
val endTimeNanos = System.nanoTime()
Toast.makeText(context, "Thread update took ${(endTimeNanos - startTimeNanos).nanoseconds.toDouble(DurationUnit.MILLISECONDS).roundedString(2)} ms", Toast.LENGTH_SHORT).show()
}
)
if (!recipient.isGroup) {
sectionHeaderPref(DSLSettingsText.from("Actions"))
clickPref(
title = DSLSettingsText.from("Disable Profile Sharing"),
summary = DSLSettingsText.from("Clears profile sharing/whitelisted status, which should cause the Message Request UI to show."),
onClick = {
MaterialAlertDialogBuilder(requireContext())
.setTitle("Are you sure?")
.setNegativeButton(android.R.string.cancel) { d, _ -> d.dismiss() }
.setPositiveButton(android.R.string.ok) { _, _ -> SignalDatabase.recipients.setProfileSharing(recipient.id, false) }
.show()
}
)
clickPref(
title = DSLSettingsText.from("Delete Sessions"),
summary = DSLSettingsText.from("Deletes all sessions with this recipient, essentially guaranteeing an encryption error if they send you a message."),
onClick = {
MaterialAlertDialogBuilder(requireContext())
.setTitle("Are you sure?")
.setNegativeButton(android.R.string.cancel) { d, _ -> d.dismiss() }
.setPositiveButton(android.R.string.ok) { _, _ ->
if (recipient.hasAci) {
SignalDatabase.sessions.deleteAllFor(serviceId = SignalStore.account.requireAci(), addressName = recipient.requireAci().toString())
}
if (recipient.hasPni) {
SignalDatabase.sessions.deleteAllFor(serviceId = SignalStore.account.requireAci(), addressName = recipient.requirePni().toString())
}
}
.show()
}
)
clickPref(
title = DSLSettingsText.from("Archive Sessions"),
summary = DSLSettingsText.from("Archives all sessions associated with this recipient, causing you to create a new session the next time you send a message (while not causing decryption errors)."),
onClick = {
MaterialAlertDialogBuilder(requireContext())
.setTitle("Are you sure?")
.setNegativeButton(android.R.string.cancel) { d, _ -> d.dismiss() }
.setPositiveButton(android.R.string.ok) { _, _ ->
AppDependencies.protocolStore.aci().sessions().archiveSessions(recipient.id)
}
.show()
}
)
}
clickPref(
title = DSLSettingsText.from("Delete Avatar"),
summary = DSLSettingsText.from("Deletes the avatar file and clears manually showing the avatar, resulting in a blurred gradient (assuming no profile sharing, no group in common, etc.)"),
onClick = {
MaterialAlertDialogBuilder(requireContext())
.setTitle("Are you sure?")
.setNegativeButton(android.R.string.cancel) { d, _ -> d.dismiss() }
.setPositiveButton(android.R.string.ok) { _, _ ->
SignalDatabase.recipients.manuallyUpdateShowAvatar(recipient.id, false)
AvatarHelper.delete(requireContext(), recipient.id)
}
.show()
}
)
clickPref(
title = DSLSettingsText.from("Clear recipient data"),
summary = DSLSettingsText.from("Clears service id, profile data, sessions, identities, and thread."),
onClick = {
MaterialAlertDialogBuilder(requireContext())
.setTitle("Are you sure?")
.setNegativeButton(android.R.string.cancel) { d, _ -> d.dismiss() }
.setPositiveButton(android.R.string.ok) { _, _ ->
SignalDatabase.threads.deleteConversation(SignalDatabase.threads.getThreadIdIfExistsFor(recipient.id))
if (recipient.hasServiceId) {
SignalDatabase.recipients.debugClearServiceIds(recipient.id)
SignalDatabase.recipients.debugClearProfileData(recipient.id)
}
if (recipient.hasAci) {
SignalDatabase.sessions.deleteAllFor(serviceId = SignalStore.account.requireAci(), addressName = recipient.requireAci().toString())
SignalDatabase.sessions.deleteAllFor(serviceId = SignalStore.account.requirePni(), addressName = recipient.requireAci().toString())
AppDependencies.protocolStore.aci().identities().delete(recipient.requireAci().toString())
}
if (recipient.hasPni) {
SignalDatabase.sessions.deleteAllFor(serviceId = SignalStore.account.requireAci(), addressName = recipient.requirePni().toString())
SignalDatabase.sessions.deleteAllFor(serviceId = SignalStore.account.requirePni(), addressName = recipient.requirePni().toString())
AppDependencies.protocolStore.aci().identities().delete(recipient.requirePni().toString())
}
startActivity(MainActivity.clearTop(requireContext()))
}
.show()
}
)
if (!recipient.isGroup) {
clickPref(
title = DSLSettingsText.from("Add 1,000 dummy messages"),
summary = DSLSettingsText.from("Just adds 1,000 random messages to the chat. Text-only, nothing complicated."),
onClick = {
MaterialAlertDialogBuilder(requireContext())
.setTitle("Are you sure?")
.setNegativeButton(android.R.string.cancel) { d, _ -> d.dismiss() }
.setPositiveButton(android.R.string.ok) { _, _ ->
val messageCount = 1000
val startTime = System.currentTimeMillis() - messageCount
SignalDatabase.rawDatabase.withinTransaction {
val targetThread = SignalDatabase.threads.getOrCreateThreadIdFor(recipient)
for (i in 1..messageCount) {
val time = startTime + i
if (Math.random() > 0.5) {
val id = SignalDatabase.messages.insertMessageOutbox(
message = OutgoingMessage(threadRecipient = recipient, sentTimeMillis = time, body = "Outgoing: $i"),
threadId = targetThread
)
SignalDatabase.messages.markAsSent(id, true)
} else {
SignalDatabase.messages.insertMessageInbox(
retrieved = IncomingMessage(type = MessageType.NORMAL, from = recipient.id, sentTimeMillis = time, serverTimeMillis = time, receivedTimeMillis = System.currentTimeMillis(), body = "Incoming: $i"),
candidateThreadId = targetThread
)
}
}
}
Toast.makeText(context, "Done!", Toast.LENGTH_SHORT).show()
}
.show()
}
)
clickPref(
title = DSLSettingsText.from("Add 10 dummy messages with attachments"),
summary = DSLSettingsText.from("Adds 10 random messages to the chat with attachments of a random image. Attachments are not uploaded."),
onClick = {
MaterialAlertDialogBuilder(requireContext())
.setTitle("Are you sure?")
.setNegativeButton(android.R.string.cancel) { d, _ -> d.dismiss() }
.setPositiveButton(android.R.string.ok) { _, _ ->
val messageCount = 10
val startTime = System.currentTimeMillis() - messageCount
SignalDatabase.rawDatabase.withinTransaction {
val targetThread = SignalDatabase.threads.getOrCreateThreadIdFor(recipient)
for (i in 1..messageCount) {
val time = startTime + i
val attachment = makeDummyAttachment()
val id = SignalDatabase.messages.insertMessageOutbox(
message = OutgoingMessage(threadRecipient = recipient, sentTimeMillis = time, body = "Outgoing: $i", attachments = listOf(attachment)),
threadId = targetThread
)
SignalDatabase.messages.markAsSent(id, true)
}
}
Toast.makeText(context, "Done!", Toast.LENGTH_SHORT).show()
}
.show()
}
)
}
if (recipient.isSelf) {
sectionHeaderPref(DSLSettingsText.from("Donations"))
// TODO [alex] - DB on main thread!
val subscriber: InAppPaymentSubscriberRecord? = InAppPaymentsRepository.getSubscriber(InAppPaymentSubscriberRecord.Type.DONATION)
val summary = if (subscriber != null) {
"""currency code: ${subscriber.currency!!.currencyCode}
|subscriber id: ${subscriber.subscriberId.serialize()}
""".trimMargin()
} else {
"None"
}
longClickPref(
title = DSLSettingsText.from("Subscriber ID"),
summary = DSLSettingsText.from(summary),
onLongClick = {
if (subscriber != null) {
copyToClipboard(subscriber.subscriberId.serialize())
}
}
)
}
sectionHeaderPref(DSLSettingsText.from("PNP"))
clickPref(
title = DSLSettingsText.from("Split and create threads"),
summary = DSLSettingsText.from("Splits this contact into two recipients and two threads so that you can test merging them together. This will remain the 'primary' recipient."),
onClick = {
MaterialAlertDialogBuilder(requireContext())
.setTitle("Are you sure?")
.setNegativeButton(android.R.string.cancel) { d, _ -> d.dismiss() }
.setPositiveButton(android.R.string.ok) { _, _ ->
if (!recipient.hasE164) {
Toast.makeText(context, "Recipient doesn't have an E164! Can't split.", Toast.LENGTH_SHORT).show()
return@setPositiveButton
}
SignalDatabase.recipients.debugClearE164AndPni(recipient.id)
val splitRecipientId: RecipientId = SignalDatabase.recipients.getAndPossiblyMergePnpVerified(null, recipient.pni.orElse(null), recipient.requireE164())
val splitRecipient: Recipient = Recipient.resolved(splitRecipientId)
val splitThreadId: Long = SignalDatabase.threads.getOrCreateThreadIdFor(splitRecipient)
val messageId: Long = SignalDatabase.messages.insertMessageOutbox(
OutgoingMessage.text(splitRecipient, "Test Message ${System.currentTimeMillis()}", 0),
splitThreadId,
false,
null
)
SignalDatabase.messages.markAsSent(messageId, true)
SignalDatabase.threads.update(splitThreadId, true)
Toast.makeText(context, "Done! We split the E164/PNI from this contact into $splitRecipientId", Toast.LENGTH_SHORT).show()
}
.show()
}
)
clickPref(
title = DSLSettingsText.from("Split without creating threads"),
summary = DSLSettingsText.from("Splits this contact into two recipients so you can test merging them together. This will become the PNI-based recipient. Another recipient will be made with this ACI and profile key. Doing a CDS refresh should allow you to see a Session Switchover Event, as long as you had a session with this PNI."),
onClick = {
MaterialAlertDialogBuilder(requireContext())
.setTitle("Are you sure?")
.setNegativeButton(android.R.string.cancel) { d, _ -> d.dismiss() }
.setPositiveButton(android.R.string.ok) { _, _ ->
if (recipient.pni.isAbsent()) {
Toast.makeText(context, "Recipient doesn't have a PNI! Can't split.", Toast.LENGTH_SHORT).show()
return@setPositiveButton
}
if (recipient.serviceId.isAbsent()) {
Toast.makeText(context, "Recipient doesn't have a serviceId! Can't split.", Toast.LENGTH_SHORT).show()
return@setPositiveButton
}
SignalDatabase.recipients.debugRemoveAci(recipient.id)
val aciRecipientId: RecipientId = SignalDatabase.recipients.getAndPossiblyMergePnpVerified(recipient.requireAci(), null, null)
recipient.profileKey?.let { profileKey ->
SignalDatabase.recipients.setProfileKey(aciRecipientId, ProfileKey(profileKey))
}
SignalDatabase.recipients.debugClearProfileData(recipient.id)
Toast.makeText(context, "Done! Split the ACI and profile key off into $aciRecipientId", Toast.LENGTH_SHORT).show()
}
.show()
}
)
}
} }
private fun makeDummyAttachment(): Attachment { private fun makeDummyAttachment(): Attachment {
@@ -469,44 +95,174 @@ class InternalConversationSettingsFragment : DSLSettingsFragment(
) )
} }
private fun copyToClipboard(text: String) { override fun onNavigationClick() {
Util.copyToClipboard(requireContext(), text) requireActivity().onBackPressedDispatcher.onBackPressed()
}
override fun copyToClipboard(data: String) {
Util.copyToClipboard(requireContext(), data)
Toast.makeText(requireContext(), "Copied to clipboard", Toast.LENGTH_SHORT).show() Toast.makeText(requireContext(), "Copied to clipboard", Toast.LENGTH_SHORT).show()
} }
private fun buildCapabilitySpan(recipient: Recipient): CharSequence { override fun triggerThreadUpdate(threadId: Long?) {
val capabilities: RecipientRecord.Capabilities? = SignalDatabase.recipients.getCapabilities(recipient.id) val startTimeNanos = System.nanoTime()
SignalDatabase.threads.update(threadId ?: -1L, true)
val endTimeNanos = System.nanoTime()
Toast.makeText(context, "Thread update took ${(endTimeNanos - startTimeNanos).nanoseconds.toDouble(DurationUnit.MILLISECONDS).roundedString(2)} ms", Toast.LENGTH_SHORT).show()
}
return if (capabilities != null) { override fun disableProfileSharing(recipientId: RecipientId) {
TextUtils.concat( SignalDatabase.recipients.setProfileSharing(recipientId, false)
colorize("SSREv2", capabilities.storageServiceEncryptionV2) }
)
} else { override fun deleteSessions(recipientId: RecipientId) {
"Recipient not found!" val recipient = Recipient.live(recipientId).get()
val aci = recipient.aci.orNull()
val pni = recipient.pni.orNull()
if (aci != null) {
SignalDatabase.sessions.deleteAllFor(serviceId = SignalStore.account.requireAci(), addressName = aci.toString())
}
if (pni != null) {
SignalDatabase.sessions.deleteAllFor(serviceId = SignalStore.account.requireAci(), addressName = pni.toString())
} }
} }
private fun colorize(name: String, support: Recipient.Capability): CharSequence { override fun archiveSessions(recipientId: RecipientId) {
return when (support) { AppDependencies.protocolStore.aci().sessions().archiveSessions(recipientId)
Recipient.Capability.SUPPORTED -> SpanUtil.color(Color.rgb(0, 150, 0), name) }
Recipient.Capability.NOT_SUPPORTED -> SpanUtil.color(Color.RED, name)
Recipient.Capability.UNKNOWN -> SpanUtil.italic(name) override fun deleteAvatar(recipientId: RecipientId) {
SignalDatabase.recipients.manuallyUpdateShowAvatar(recipientId, false)
AvatarHelper.delete(requireContext(), recipientId)
}
override fun clearRecipientData(recipientId: RecipientId) {
val recipient = Recipient.live(recipientId).get()
SignalDatabase.threads.deleteConversation(SignalDatabase.threads.getThreadIdIfExistsFor(recipientId))
if (recipient.hasServiceId) {
SignalDatabase.recipients.debugClearServiceIds(recipientId)
SignalDatabase.recipients.debugClearProfileData(recipientId)
} }
if (recipient.hasAci) {
SignalDatabase.sessions.deleteAllFor(serviceId = SignalStore.account.requireAci(), addressName = recipient.requireAci().toString())
SignalDatabase.sessions.deleteAllFor(serviceId = SignalStore.account.requirePni(), addressName = recipient.requireAci().toString())
AppDependencies.protocolStore.aci().identities().delete(recipient.requireAci().toString())
}
if (recipient.hasPni) {
SignalDatabase.sessions.deleteAllFor(serviceId = SignalStore.account.requireAci(), addressName = recipient.requirePni().toString())
SignalDatabase.sessions.deleteAllFor(serviceId = SignalStore.account.requirePni(), addressName = recipient.requirePni().toString())
AppDependencies.protocolStore.aci().identities().delete(recipient.requirePni().toString())
}
startActivity(MainActivity.clearTop(requireContext()))
}
override fun add1000Messages(recipientId: RecipientId) {
val recipient = Recipient.live(recipientId).get()
val messageCount = 10
val startTime = System.currentTimeMillis() - messageCount
SignalDatabase.rawDatabase.withinTransaction {
val targetThread = SignalDatabase.threads.getOrCreateThreadIdFor(recipient)
for (i in 1..messageCount) {
val time = startTime + i
val attachment = makeDummyAttachment()
val id = SignalDatabase.messages.insertMessageOutbox(
message = OutgoingMessage(threadRecipient = recipient, sentTimeMillis = time, body = "Outgoing: $i", attachments = listOf(attachment)),
threadId = targetThread
)
SignalDatabase.messages.markAsSent(id, true)
}
}
Toast.makeText(context, "Done!", Toast.LENGTH_SHORT).show()
}
override fun add10Messages(recipientId: RecipientId) {
val recipient = Recipient.live(recipientId).get()
val messageCount = 10
val startTime = System.currentTimeMillis() - messageCount
SignalDatabase.rawDatabase.withinTransaction {
val targetThread = SignalDatabase.threads.getOrCreateThreadIdFor(recipient)
for (i in 1..messageCount) {
val time = startTime + i
val attachment = makeDummyAttachment()
val id = SignalDatabase.messages.insertMessageOutbox(
message = OutgoingMessage(threadRecipient = recipient, sentTimeMillis = time, body = "Outgoing: $i", attachments = listOf(attachment)),
threadId = targetThread
)
SignalDatabase.messages.markAsSent(id, true)
}
}
Toast.makeText(context, "Done!", Toast.LENGTH_SHORT).show()
}
override fun splitAndCreateThreads(recipientId: RecipientId) {
val recipient = Recipient.live(recipientId).get()
if (!recipient.hasE164) {
Toast.makeText(context, "Recipient doesn't have an E164! Can't split.", Toast.LENGTH_SHORT).show()
return
}
SignalDatabase.recipients.debugClearE164AndPni(recipient.id)
val splitRecipientId: RecipientId = SignalDatabase.recipients.getAndPossiblyMergePnpVerified(null, recipient.pni.orElse(null), recipient.requireE164())
val splitRecipient: Recipient = Recipient.resolved(splitRecipientId)
val splitThreadId: Long = SignalDatabase.threads.getOrCreateThreadIdFor(splitRecipient)
val messageId: Long = SignalDatabase.messages.insertMessageOutbox(
OutgoingMessage.text(splitRecipient, "Test Message ${System.currentTimeMillis()}", 0),
splitThreadId,
false,
null
)
SignalDatabase.messages.markAsSent(messageId, true)
SignalDatabase.threads.update(splitThreadId, true)
Toast.makeText(context, "Done! We split the E164/PNI from this contact into $splitRecipientId", Toast.LENGTH_SHORT).show()
}
override fun splitWithoutCreatingThreads(recipientId: RecipientId) {
val recipient = Recipient.live(recipientId).get()
if (recipient.pni.isAbsent()) {
Toast.makeText(context, "Recipient doesn't have a PNI! Can't split.", Toast.LENGTH_SHORT).show()
}
if (recipient.serviceId.isAbsent()) {
Toast.makeText(context, "Recipient doesn't have a serviceId! Can't split.", Toast.LENGTH_SHORT).show()
}
SignalDatabase.recipients.debugRemoveAci(recipient.id)
val aciRecipientId: RecipientId = SignalDatabase.recipients.getAndPossiblyMergePnpVerified(recipient.requireAci(), null, null)
recipient.profileKey?.let { profileKey ->
SignalDatabase.recipients.setProfileKey(aciRecipientId, ProfileKey(profileKey))
}
SignalDatabase.recipients.debugClearProfileData(recipient.id)
Toast.makeText(context, "Done! Split the ACI and profile key off into $aciRecipientId", Toast.LENGTH_SHORT).show()
} }
class InternalViewModel( class InternalViewModel(
val recipientId: RecipientId val recipientId: RecipientId
) : ViewModel(), RecipientForeverObserver { ) : ViewModel(), RecipientForeverObserver {
private val store = Store( private val store = MutableStateFlow(
InternalState( InternalConversationSettingsState.create(
recipient = Recipient.resolved(recipientId), recipient = Recipient.resolved(recipientId),
threadId = null, threadId = null,
groupId = null groupId = null
) )
) )
val state = store.stateLiveData val state: StateFlow<InternalConversationSettingsState> = store
val liveRecipient = Recipient.live(recipientId) val liveRecipient = Recipient.live(recipientId)
init { init {
@@ -520,7 +276,7 @@ class InternalConversationSettingsFragment : DSLSettingsFragment(
} }
override fun onRecipientChanged(recipient: Recipient) { override fun onRecipientChanged(recipient: Recipient) {
store.update { state -> state.copy(recipient = recipient) } store.update { state -> InternalConversationSettingsState.create(recipient, state.threadId, state.groupId) }
} }
override fun onCleared() { override fun onCleared() {
@@ -533,10 +289,4 @@ class InternalConversationSettingsFragment : DSLSettingsFragment(
return Objects.requireNonNull(modelClass.cast(InternalViewModel(recipientId))) return Objects.requireNonNull(modelClass.cast(InternalViewModel(recipientId)))
} }
} }
data class InternalState(
val recipient: Recipient,
val threadId: Long?,
val groupId: GroupId?
)
} }

View File

@@ -0,0 +1,463 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.conversation
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import org.signal.core.ui.compose.Dialogs
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.Rows
import org.signal.core.ui.compose.Scaffolds
import org.signal.core.ui.compose.SignalPreview
import org.signal.core.ui.compose.Texts
import org.signal.core.util.Hex.fromStringCondensed
import org.signal.libsignal.zkgroup.groups.GroupMasterKey
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.recipients.RecipientId
private enum class Dialog {
NONE,
DISABLE_PROFILE_SHARING,
DELETE_SESSIONS,
ARCHIVE_SESSIONS,
DELETE_AVATAR,
CLEAR_RECIPIENT_DATA,
ADD_1000_MESSAGES,
ADD_10_MESSAGES,
SPLIT_AND_CREATE_THREADS,
SPLIT_WITHOUT_CREATING_THREADS
}
/**
* Shows internal details about a recipient that you can view from the conversation settings.
*/
@Composable
fun InternalConversationSettingsScreen(
state: InternalConversationSettingsState,
callbacks: InternalConversationSettingsScreenCallbacks
) {
var dialog by remember { mutableStateOf(Dialog.NONE) }
Scaffolds.Settings(
title = stringResource(R.string.ConversationSettingsFragment__internal_details),
onNavigationClick = callbacks::onNavigationClick,
navigationIconPainter = painterResource(R.drawable.symbol_arrow_start_24),
navigationContentDescription = stringResource(R.string.CallScreenTopBar__go_back)
) { paddingValues ->
LazyColumn(
modifier = Modifier.padding(paddingValues)
) {
item {
Texts.SectionHeader(
text = "Data"
)
}
item {
Rows.TextRow(
text = "RecipientId",
label = state.recipientId.toString()
)
}
if (!state.isGroup) {
item {
LongClickToCopy(
text = "E164",
label = state.e164,
callback = callbacks
)
}
item {
LongClickToCopy(
text = "ACI",
label = state.aci,
callback = callbacks
)
}
item {
LongClickToCopy(
text = "PNI",
label = state.pni,
callback = callbacks
)
}
}
if (state.groupIdString != null) {
item {
LongClickToCopy(
text = "GroupId",
label = state.groupIdString,
callback = callbacks
)
}
}
item {
LongClickToCopy(
text = "ThreadId",
label = state.threadIdString,
callback = callbacks
)
}
if (!state.isGroup) {
item {
Rows.TextRow(
text = "Profile Name",
label = state.profileName
)
}
item {
LongClickToCopy(
text = "Profile Key (Base64)",
label = state.profileKeyBase64,
callback = callbacks
)
}
item {
LongClickToCopy(
text = "Profile Key (Hex)",
label = state.profileKeyHex,
callback = callbacks
)
}
item {
Rows.TextRow(
text = "Sealed Sender Mode",
label = state.sealedSenderAccessMode
)
}
item {
Rows.TextRow(
text = "Phone Number Sharing",
label = state.phoneNumberSharing
)
}
item {
Rows.TextRow(
text = "Phone Number Discoverability",
label = state.phoneNumberDiscoverability
)
}
}
item {
Rows.TextRow(
text = "Profile Sharing (AKA \"Whitelisted\")",
label = state.profileSharing
)
}
if (!state.isGroup) {
item {
Rows.TextRow(
text = remember { AnnotatedString("Capabilities") },
label = state.capabilities
)
}
}
item {
val onClick = remember(state.threadId) {
{ callbacks.triggerThreadUpdate(state.threadId) }
}
Rows.TextRow(
text = "Trigger Thread Update",
label = "Triggers a thread update. Useful for testing perf.",
onClick = onClick
)
}
if (!state.isGroup) {
item {
Texts.SectionHeader(text = "Actions")
}
item {
Rows.TextRow(
text = "Disable Profile Sharing",
label = "Clears profile sharing/whitelisted status, which should cause the Message Request UI to show.",
onClick = {
dialog = Dialog.DISABLE_PROFILE_SHARING
}
)
}
item {
Rows.TextRow(
text = "Delete Sessions",
label = "Deletes all sessions with this recipient, essentially guaranteeing an encryption error if they send you a message.",
onClick = {
dialog = Dialog.DELETE_SESSIONS
}
)
}
item {
Rows.TextRow(
text = "Archive Sessions",
label = "Archives all sessions associated with this recipient, causing you to create a new session the next time you send a message (while not causing decryption errors).",
onClick = {
dialog = Dialog.ARCHIVE_SESSIONS
}
)
}
}
item {
Rows.TextRow(
text = "Delete Avatar",
label = "Deletes the avatar file and clears manually showing the avatar, resulting in a blurred gradient (assuming no profile sharing, no group in common, etc.)",
onClick = {
dialog = Dialog.DELETE_AVATAR
}
)
}
item {
Rows.TextRow(
text = "Clear recipient data",
label = "Clears service id, profile data, sessions, identities, and thread.",
onClick = {
dialog = Dialog.CLEAR_RECIPIENT_DATA
}
)
}
if (!state.isGroup) {
item {
Rows.TextRow(
text = "Add 1,000 dummy messages",
label = "Just adds 1,000 random messages to the chat. Text-only, nothing complicated.",
onClick = {
dialog = Dialog.ADD_1000_MESSAGES
}
)
}
item {
Rows.TextRow(
text = "Add 10 dummy messages with attachments",
label = "Adds 10 random messages to the chat with attachments of a random image. Attachments are not uploaded.",
onClick = {
dialog = Dialog.ADD_10_MESSAGES
}
)
}
}
if (state.isSelf) {
item {
Texts.SectionHeader(text = "Donations")
}
item {
val onLongClick = remember(state.subscriberId) {
{ callbacks.copyToClipboard(state.subscriberId) }
}
Rows.TextRow(
text = "Subscriber ID",
label = state.subscriberId,
onLongClick = onLongClick
)
}
}
item {
Texts.SectionHeader(
text = "PNP"
)
}
item {
Rows.TextRow(
text = "Split and create threads",
label = "Splits this contact into two recipients and two threads so that you can test merging them together. This will remain the 'primary' recipient.",
onClick = {
dialog = Dialog.SPLIT_AND_CREATE_THREADS
}
)
Rows.TextRow(
text = "Split without creating threads",
label = "Splits this contact into two recipients so you can test merging them together. This will become the PNI-based recipient. Another recipient will be made with this ACI and profile key. Doing a CDS refresh should allow you to see a Session Switchover Event, as long as you had a session with this PNI.",
onClick = {
dialog = Dialog.SPLIT_WITHOUT_CREATING_THREADS
}
)
}
}
}
if (dialog != Dialog.NONE) {
val onConfirm = rememberOnConfirm(state, callbacks, dialog)
AreYouSureDialog(onConfirm) {
dialog = Dialog.NONE
}
}
}
@Composable
private fun rememberOnConfirm(
state: InternalConversationSettingsState,
callbacks: InternalConversationSettingsScreenCallbacks,
dialog: Dialog
): () -> Unit {
return remember(dialog, callbacks, state.threadId, state.recipientId) {
when (dialog) {
Dialog.NONE -> {
{}
}
Dialog.DISABLE_PROFILE_SHARING -> {
{ callbacks.disableProfileSharing(state.recipientId) }
}
Dialog.DELETE_SESSIONS -> {
{ callbacks.deleteSessions(state.recipientId) }
}
Dialog.ARCHIVE_SESSIONS -> {
{ callbacks.archiveSessions(state.recipientId) }
}
Dialog.DELETE_AVATAR -> {
{ callbacks.deleteAvatar(state.recipientId) }
}
Dialog.CLEAR_RECIPIENT_DATA -> {
{ callbacks.clearRecipientData(state.recipientId) }
}
Dialog.ADD_1000_MESSAGES -> {
{ callbacks.add1000Messages(state.recipientId) }
}
Dialog.ADD_10_MESSAGES -> {
{ callbacks.add10Messages(state.recipientId) }
}
Dialog.SPLIT_AND_CREATE_THREADS -> {
{ callbacks.splitAndCreateThreads(state.recipientId) }
}
Dialog.SPLIT_WITHOUT_CREATING_THREADS -> {
{ callbacks.splitWithoutCreatingThreads(state.recipientId) }
}
}
}
}
@Composable
private fun LongClickToCopy(
text: String,
label: String,
callback: InternalConversationSettingsScreenCallbacks
) {
val onLongClick = remember(label, callback) {
{ callback.copyToClipboard(label) }
}
Rows.TextRow(
text = text,
label = label,
onLongClick = onLongClick
)
}
@Composable
private fun AreYouSureDialog(
onConfirm: () -> Unit,
onDismiss: () -> Unit
) {
Dialogs.SimpleAlertDialog(
title = "",
body = "Are you sure?",
confirm = stringResource(android.R.string.ok),
dismiss = stringResource(android.R.string.cancel),
onConfirm = onConfirm,
onDismiss = onDismiss
)
}
@SignalPreview
@Composable
fun InternalConversationSettingsScreenPreview() {
Previews.Preview {
InternalConversationSettingsScreen(
state = createState(),
callbacks = InternalConversationSettingsScreenCallbacks.Empty
)
}
}
@SignalPreview
@Composable
fun InternalConversationSettingsScreenGroupPreview() {
Previews.Preview {
InternalConversationSettingsScreen(
state = createState(GroupId.v2(GroupMasterKey(fromStringCondensed("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")))),
callbacks = InternalConversationSettingsScreenCallbacks.Empty
)
}
}
private fun createState(
groupId: GroupId? = null
): InternalConversationSettingsState {
return InternalConversationSettingsState(
recipientId = RecipientId.from(1),
isGroup = groupId != null,
e164 = "+11111111111",
aci = "TEST-ACI",
pni = "TEST-PNI",
groupId = groupId,
threadId = 12,
profileName = "Miles Morales",
profileKeyBase64 = "profile64",
profileKeyHex = "profileHex",
sealedSenderAccessMode = "SealedSenderAccessMode",
phoneNumberSharing = "PhoneNumberSharing",
phoneNumberDiscoverability = "PhoneNumberDiscoverability",
profileSharing = "ProfileSharing",
capabilities = AnnotatedString("CapabilitiesString"),
hasServiceId = true,
isSelf = groupId == null,
subscriberId = "SubscriberId"
)
}
@Stable
interface InternalConversationSettingsScreenCallbacks {
fun onNavigationClick() = Unit
fun copyToClipboard(data: String) = Unit
fun triggerThreadUpdate(threadId: Long?) = Unit
fun disableProfileSharing(recipientId: RecipientId) = Unit
fun deleteSessions(recipientId: RecipientId) = Unit
fun archiveSessions(recipientId: RecipientId) = Unit
fun deleteAvatar(recipientId: RecipientId) = Unit
fun clearRecipientData(recipientId: RecipientId) = Unit
fun add1000Messages(recipientId: RecipientId) = Unit
fun add10Messages(recipientId: RecipientId) = Unit
fun splitAndCreateThreads(recipientId: RecipientId) = Unit
fun splitWithoutCreatingThreads(recipientId: RecipientId) = Unit
object Empty : InternalConversationSettingsScreenCallbacks
}

View File

@@ -0,0 +1,122 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.settings.conversation
import androidx.annotation.WorkerThread
import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.withStyle
import org.signal.core.util.Base64
import org.signal.core.util.Hex
import org.thoughtcrime.securesms.components.settings.app.subscription.InAppPaymentsRepository
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.InAppPaymentSubscriberRecord
import org.thoughtcrime.securesms.database.model.RecipientRecord
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
@Immutable
data class InternalConversationSettingsState(
val recipientId: RecipientId,
val isGroup: Boolean,
val e164: String,
val aci: String,
val pni: String,
val groupId: GroupId?,
val threadId: Long?,
val profileName: String,
val profileKeyBase64: String,
val profileKeyHex: String,
val sealedSenderAccessMode: String,
val phoneNumberSharing: String,
val phoneNumberDiscoverability: String,
val profileSharing: String,
val capabilities: AnnotatedString,
val hasServiceId: Boolean,
val isSelf: Boolean,
val subscriberId: String
) {
val groupIdString = groupId?.toString()
val threadIdString = threadId?.toString() ?: "N/A"
companion object {
@WorkerThread
fun create(recipient: Recipient, threadId: Long?, groupId: GroupId?): InternalConversationSettingsState {
return InternalConversationSettingsState(
recipientId = recipient.id,
isGroup = recipient.isGroup,
e164 = recipient.e164.orElse("null"),
aci = recipient.aci.map { it.toString() }.orElse("null"),
pni = recipient.pni.map { it.toString() }.orElse("null"),
groupId = groupId,
threadId = threadId,
profileName = with(recipient) {
if (isGroup) "" else "[${profileName.givenName}] [${profileName.familyName}]"
},
profileKeyBase64 = with(recipient) {
if (isGroup) "" else profileKey?.let(Base64::encodeWithPadding) ?: "None"
},
profileKeyHex = with(recipient) {
if (isGroup) "" else profileKey?.let(Hex::toStringCondensed) ?: "None"
},
sealedSenderAccessMode = recipient.sealedSenderAccessMode.toString(),
phoneNumberSharing = recipient.phoneNumberSharing.name,
phoneNumberDiscoverability = SignalDatabase.recipients.getPhoneNumberDiscoverability(recipient.id)?.name ?: "null",
profileSharing = recipient.isProfileSharing.toString(),
capabilities = buildCapabilities(recipient),
hasServiceId = recipient.hasServiceId,
isSelf = recipient.isSelf,
subscriberId = buildSubscriberId(recipient)
)
}
@WorkerThread
private fun buildSubscriberId(recipient: Recipient): String {
return if (recipient.isSelf) {
val subscriber: InAppPaymentSubscriberRecord? = InAppPaymentsRepository.getSubscriber(InAppPaymentSubscriberRecord.Type.DONATION)
if (subscriber != null) {
"""currency code: ${subscriber.currency!!.currencyCode}
|subscriber id: ${subscriber.subscriberId.serialize()}
""".trimMargin()
} else {
"None"
}
} else {
"None"
}
}
@WorkerThread
private fun buildCapabilities(recipient: Recipient): AnnotatedString {
return if (recipient.isGroup) {
AnnotatedString("null")
} else {
val capabilities: RecipientRecord.Capabilities? = SignalDatabase.recipients.getCapabilities(recipient.id)
if (capabilities != null) {
val style: SpanStyle = when (capabilities.storageServiceEncryptionV2) {
Recipient.Capability.SUPPORTED -> SpanStyle(color = Color(0, 150, 0))
Recipient.Capability.NOT_SUPPORTED -> SpanStyle(color = Color.Red)
Recipient.Capability.UNKNOWN -> SpanStyle(fontStyle = FontStyle.Italic)
}
buildAnnotatedString {
withStyle(style = style) {
append("SSREv2")
}
}
} else {
AnnotatedString("Recipient not found!")
}
}
}
}
}

View File

@@ -44,6 +44,7 @@ import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import org.signal.core.ui.R import org.signal.core.ui.R
@@ -224,6 +225,34 @@ object Rows {
onClick: (() -> Unit)? = null, onClick: (() -> Unit)? = null,
onLongClick: (() -> Unit)? = null, onLongClick: (() -> Unit)? = null,
enabled: Boolean = true enabled: Boolean = true
) {
TextRow(
text = remember(text) { AnnotatedString(text) },
label = remember(label) { label?.let { AnnotatedString(label) } },
icon = icon,
modifier = modifier,
iconModifier = iconModifier,
foregroundTint = foregroundTint,
onClick = onClick,
onLongClick = onLongClick,
enabled = enabled
)
}
/**
* Text row that positions [text] and optional [label] in a [TextAndLabel] to the side of an optional [icon].
*/
@Composable
fun TextRow(
text: AnnotatedString,
modifier: Modifier = Modifier,
iconModifier: Modifier = Modifier,
label: AnnotatedString? = null,
icon: Painter? = null,
foregroundTint: Color = MaterialTheme.colorScheme.onSurface,
onClick: (() -> Unit)? = null,
onLongClick: (() -> Unit)? = null,
enabled: Boolean = true
) { ) {
TextRow( TextRow(
text = { text = {
@@ -353,6 +382,28 @@ object Rows {
enabled: Boolean = true, enabled: Boolean = true,
textColor: Color = MaterialTheme.colorScheme.onSurface, textColor: Color = MaterialTheme.colorScheme.onSurface,
textStyle: TextStyle = MaterialTheme.typography.bodyLarge textStyle: TextStyle = MaterialTheme.typography.bodyLarge
) {
TextAndLabel(
text = remember(text) { text?.let { AnnotatedString(it) } },
label = remember(label) { label?.let { AnnotatedString(it) } },
modifier = modifier,
enabled = enabled,
textColor = textColor,
textStyle = textStyle
)
}
/**
* Row component to position text above an optional label.
*/
@Composable
fun RowScope.TextAndLabel(
text: AnnotatedString? = null,
modifier: Modifier = Modifier,
label: AnnotatedString? = null,
enabled: Boolean = true,
textColor: Color = MaterialTheme.colorScheme.onSurface,
textStyle: TextStyle = MaterialTheme.typography.bodyLarge
) { ) {
Column( Column(
modifier = modifier modifier = modifier