mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-02-14 23:18:43 +00:00
Convert InternalConversationSettings to compose.
This commit is contained in:
1
.idea/codeStyles/Project.xml
generated
1
.idea/codeStyles/Project.xml
generated
@@ -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" />
|
||||||
|
|||||||
@@ -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)
|
}
|
||||||
|
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
)
|
)
|
||||||
} else {
|
SignalDatabase.messages.markAsSent(id, true)
|
||||||
"Recipient not found!"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun colorize(name: String, support: Recipient.Capability): CharSequence {
|
Toast.makeText(context, "Done!", Toast.LENGTH_SHORT).show()
|
||||||
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 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?
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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!")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user