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="IMPORT_LAYOUT_TABLE">
<value>
<package name="" withSubpackages="true" static="false" module="true" />
<package name="android" withSubpackages="true" static="false" />
<emptyLine />
<package name="androidx" withSubpackages="true" static="false" />

View File

@@ -1,38 +1,32 @@
package org.thoughtcrime.securesms.components.settings.conversation
import android.graphics.Bitmap
import android.graphics.Color
import android.text.TextUtils
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.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.signal.core.util.Base64
import org.signal.core.util.Hex
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import org.signal.core.util.concurrent.SignalExecutors
import org.signal.core.util.isAbsent
import org.signal.core.util.orNull
import org.signal.core.util.roundedString
import org.signal.core.util.withinTransaction
import org.signal.libsignal.zkgroup.profiles.ProfileKey
import org.thoughtcrime.securesms.MainActivity
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.attachments.Attachment
import org.thoughtcrime.securesms.attachments.UriAttachment
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
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.compose.ComposeFragment
import org.thoughtcrime.securesms.database.AttachmentTable
import org.thoughtcrime.securesms.database.MessageType
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.groups.GroupId
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.mms.IncomingMessage
import org.thoughtcrime.securesms.mms.OutgoingMessage
import org.thoughtcrime.securesms.profiles.AvatarHelper
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.util.BitmapUtil
import org.thoughtcrime.securesms.util.MediaUtil
import org.thoughtcrime.securesms.util.SpanUtil
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 kotlin.random.Random
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.
*/
class InternalConversationSettingsFragment : DSLSettingsFragment(
titleId = R.string.ConversationSettingsFragment__internal_details
) {
@Stable
class InternalConversationSettingsFragment : ComposeFragment(), InternalConversationSettingsScreenCallbacks {
private val viewModel: InternalViewModel by viewModels(
factoryProducer = {
@@ -64,378 +54,14 @@ class InternalConversationSettingsFragment : DSLSettingsFragment(
}
)
override fun bindAdapter(adapter: MappingAdapter) {
viewModel.state.observe(viewLifecycleOwner) { state ->
adapter.submitList(getConfiguration(state).toMappingModelList())
}
}
@Composable
override fun FragmentContent() {
val state: InternalConversationSettingsState by viewModel.state.collectAsStateWithLifecycle()
private fun getConfiguration(state: InternalState): DSLConfiguration {
val recipient = state.recipient
return configure {
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()
}
)
}
InternalConversationSettingsScreen(
state = state,
callbacks = this
)
}
private fun makeDummyAttachment(): Attachment {
@@ -469,44 +95,174 @@ class InternalConversationSettingsFragment : DSLSettingsFragment(
)
}
private fun copyToClipboard(text: String) {
Util.copyToClipboard(requireContext(), text)
override fun onNavigationClick() {
requireActivity().onBackPressedDispatcher.onBackPressed()
}
override fun copyToClipboard(data: String) {
Util.copyToClipboard(requireContext(), data)
Toast.makeText(requireContext(), "Copied to clipboard", Toast.LENGTH_SHORT).show()
}
private fun buildCapabilitySpan(recipient: Recipient): CharSequence {
val capabilities: RecipientRecord.Capabilities? = SignalDatabase.recipients.getCapabilities(recipient.id)
override fun triggerThreadUpdate(threadId: Long?) {
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) {
TextUtils.concat(
colorize("SSREv2", capabilities.storageServiceEncryptionV2)
)
} else {
"Recipient not found!"
override fun disableProfileSharing(recipientId: RecipientId) {
SignalDatabase.recipients.setProfileSharing(recipientId, false)
}
override fun deleteSessions(recipientId: RecipientId) {
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 {
return when (support) {
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 archiveSessions(recipientId: RecipientId) {
AppDependencies.protocolStore.aci().sessions().archiveSessions(recipientId)
}
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(
val recipientId: RecipientId
) : ViewModel(), RecipientForeverObserver {
private val store = Store(
InternalState(
private val store = MutableStateFlow(
InternalConversationSettingsState.create(
recipient = Recipient.resolved(recipientId),
threadId = null,
groupId = null
)
)
val state = store.stateLiveData
val state: StateFlow<InternalConversationSettingsState> = store
val liveRecipient = Recipient.live(recipientId)
init {
@@ -520,7 +276,7 @@ class InternalConversationSettingsFragment : DSLSettingsFragment(
}
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() {
@@ -533,10 +289,4 @@ class InternalConversationSettingsFragment : DSLSettingsFragment(
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.res.dimensionResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.dp
import org.signal.core.ui.R
@@ -224,6 +225,34 @@ object Rows {
onClick: (() -> Unit)? = null,
onLongClick: (() -> Unit)? = null,
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(
text = {
@@ -353,6 +382,28 @@ object Rows {
enabled: Boolean = true,
textColor: Color = MaterialTheme.colorScheme.onSurface,
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(
modifier = modifier