diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml index f99aa2fdfb..a295dd4b93 100644 --- a/.idea/codeStyles/Project.xml +++ b/.idea/codeStyles/Project.xml @@ -16,6 +16,7 @@ + diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/InternalConversationSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/InternalConversationSettingsFragment.kt index 9d3f6d08eb..a3e6033802 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/InternalConversationSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/InternalConversationSettingsFragment.kt @@ -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 = 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? - ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/InternalConversationSettingsScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/InternalConversationSettingsScreen.kt new file mode 100644 index 0000000000..87948433ca --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/InternalConversationSettingsScreen.kt @@ -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 +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/InternalConversationSettingsState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/InternalConversationSettingsState.kt new file mode 100644 index 0000000000..a1b542c514 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/InternalConversationSettingsState.kt @@ -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!") + } + } + } + } +} diff --git a/core-ui/src/main/java/org/signal/core/ui/compose/Rows.kt b/core-ui/src/main/java/org/signal/core/ui/compose/Rows.kt index b29e24f460..93e1a98eaa 100644 --- a/core-ui/src/main/java/org/signal/core/ui/compose/Rows.kt +++ b/core-ui/src/main/java/org/signal/core/ui/compose/Rows.kt @@ -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