From 6854f7eb2a688ca203152fe2133d7891497a029b Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Wed, 20 Mar 2024 13:18:45 -0400 Subject: [PATCH] Add an 'internal details' screen for message details. --- .../ComposeFullScreenDialogFragment.kt | 41 ++++ .../securesms/database/AttachmentTable.kt | 1 - .../InternalMessageDetailsFragment.kt | 230 ++++++++++++++++++ .../InternalMessageDetailsViewModel.kt | 72 ++++++ .../messagedetails/MessageDetailsAdapter.java | 3 +- .../MessageDetailsFragment.java | 5 + .../MessageHeaderViewHolder.java | 39 ++- .../res/layout/message_details_header.xml | 11 + 8 files changed, 389 insertions(+), 13 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/compose/ComposeFullScreenDialogFragment.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/messagedetails/InternalMessageDetailsFragment.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/messagedetails/InternalMessageDetailsViewModel.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/compose/ComposeFullScreenDialogFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/compose/ComposeFullScreenDialogFragment.kt new file mode 100644 index 0000000000..9e78d00e88 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/compose/ComposeFullScreenDialogFragment.kt @@ -0,0 +1,41 @@ +package org.thoughtcrime.securesms.compose + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.fragment.app.DialogFragment +import org.signal.core.ui.theme.SignalTheme +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.util.DynamicTheme + +/** + * Generic ComposeFragment which can be subclassed to build UI with compose. + */ +abstract class ComposeFullScreenDialogFragment : DialogFragment() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setStyle(STYLE_NO_FRAME, R.style.Signal_DayNight_Dialog_FullScreen) + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + SignalTheme( + isDarkMode = DynamicTheme.isDarkTheme(LocalContext.current) + ) { + DialogContent() + } + } + } + } + + @Composable + abstract fun DialogContent() +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentTable.kt index 864a3aaf9e..d3070e27f1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentTable.kt @@ -956,7 +956,6 @@ class AttachmentTable( return transferFile } - @VisibleForTesting fun getDataFileInfo(attachmentId: AttachmentId): DataFileInfo? { return readableDatabase .select(ID, DATA_FILE, DATA_SIZE, DATA_RANDOM, DATA_HASH_START, DATA_HASH_END, TRANSFORM_PROPERTIES, UPLOAD_TIMESTAMP) diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagedetails/InternalMessageDetailsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/messagedetails/InternalMessageDetailsFragment.kt new file mode 100644 index 0000000000..e60e1fd0f2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/messagedetails/InternalMessageDetailsFragment.kt @@ -0,0 +1,230 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.messagedetails + +import android.content.Context +import android.widget.Toast +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.core.os.bundleOf +import androidx.fragment.app.FragmentActivity +import org.thoughtcrime.securesms.compose.ComposeFullScreenDialogFragment +import org.thoughtcrime.securesms.database.model.MessageRecord +import org.thoughtcrime.securesms.messagedetails.InternalMessageDetailsViewModel.AttachmentInfo +import org.thoughtcrime.securesms.messagedetails.InternalMessageDetailsViewModel.ViewState +import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.recipients.ui.bottomsheet.RecipientBottomSheetDialogFragment +import org.thoughtcrime.securesms.util.Util +import org.thoughtcrime.securesms.util.viewModel + +class InternalMessageDetailsFragment : ComposeFullScreenDialogFragment() { + companion object { + const val ARG_MESSAGE_ID = "message_id" + + @JvmStatic + fun create(messageRecord: MessageRecord): InternalMessageDetailsFragment { + return InternalMessageDetailsFragment().apply { + arguments = bundleOf( + ARG_MESSAGE_ID to messageRecord.id + ) + } + } + } + + val viewModel: InternalMessageDetailsViewModel by viewModel { InternalMessageDetailsViewModel(requireArguments().getLong(ARG_MESSAGE_ID, 0)) } + + @Composable + override fun DialogContent() { + val state by viewModel.state + + state?.let { + Content(it) + } + } +} + +@Composable +private fun Content(state: ViewState) { + val context = LocalContext.current + + Surface( + modifier = Modifier + .fillMaxSize() + ) { + Column( + modifier = Modifier.verticalScroll(rememberScrollState()) + ) { + Text( + text = "Message Details", + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier + .padding(8.dp) + .fillMaxWidth() + ) + ClickToCopyRow( + name = "MessageId", + value = state.id.toString() + ) + ClickToCopyRow( + name = "Sent Timestamp", + value = state.sentTimestamp.toString() + ) + ClickToCopyRow( + name = "Received Timestamp", + value = state.receivedTimestamp.toString() + ) + + val serverTimestampString = if (state.serverSentTimestamp <= 0L) { + "N/A" + } else { + state.serverSentTimestamp.toString() + } + + ClickToCopyRow( + name = "Server Sent Timestamp", + value = serverTimestampString + ) + DetailRow( + name = "To", + value = state.to.toString(), + onClick = { + val fragmentManager = (context as FragmentActivity).supportFragmentManager + RecipientBottomSheetDialogFragment.show(fragmentManager, state.to, null) + } + ) + DetailRow( + name = "From", + value = state.from.toString(), + onClick = { + val fragmentManager = (context as FragmentActivity).supportFragmentManager + RecipientBottomSheetDialogFragment.show(fragmentManager, state.from, null) + } + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "Attachments", + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier + .padding(8.dp) + .fillMaxWidth() + ) + + if (state.attachments.isEmpty()) { + Text( + text = "None", + modifier = Modifier + .padding(8.dp) + .fillMaxWidth() + ) + } else { + state.attachments.forEach { attachment -> + AttachmentBlock(attachment) + } + } + } + } +} + +@Composable +private fun DetailRow(name: String, value: String, onClick: () -> Unit) { + val formattedString = buildAnnotatedString { + withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { + append("$name: ") + } + withStyle(SpanStyle(fontFamily = FontFamily.Monospace)) { + append(value) + } + } + + Text( + text = formattedString, + modifier = Modifier + .clickable { onClick() } + .padding(8.dp) + .fillMaxWidth() + ) +} + +@Composable +private fun ClickToCopyRow(name: String, value: String, valueToCopy: String = value) { + val context: Context = LocalContext.current + + DetailRow( + name = name, + value = value, + onClick = { + Util.copyToClipboard(context, valueToCopy) + Toast.makeText(context, "Copied to clipboard", Toast.LENGTH_SHORT).show() + } + ) +} + +@Composable +private fun AttachmentBlock(attachment: AttachmentInfo) { + ClickToCopyRow( + name = "ID", + value = attachment.id.toString() + ) + ClickToCopyRow( + name = "Filename", + value = attachment.fileName.toString() + ) + ClickToCopyRow( + name = "Content Type", + value = attachment.contentType + ) + ClickToCopyRow( + name = "Start Hash", + value = attachment.hashStart ?: "null" + ) + ClickToCopyRow( + name = "End Hash", + value = attachment.hashEnd ?: "null" + ) + ClickToCopyRow( + name = "Transform Properties", + value = attachment.transformProperties ?: "null" + ) +} + +@Preview +@Composable +private fun ContentPreview() { + Content( + ViewState( + id = 1, + sentTimestamp = 2, + receivedTimestamp = 3, + serverSentTimestamp = 4, + to = RecipientId.from(1), + from = RecipientId.from(2), + attachments = emptyList() + ) + ) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagedetails/InternalMessageDetailsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/messagedetails/InternalMessageDetailsViewModel.kt new file mode 100644 index 0000000000..b8d4d7004f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/messagedetails/InternalMessageDetailsViewModel.kt @@ -0,0 +1,72 @@ +/* + * Copyright 2024 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.messagedetails + +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.recipients.RecipientId +import org.whispersystems.signalservice.internal.util.JsonUtil + +class InternalMessageDetailsViewModel(val messageId: Long) : ViewModel() { + + private val _state: MutableState = mutableStateOf(null) + val state: State = _state + + init { + viewModelScope.launch(Dispatchers.IO) { + val messageRecord = SignalDatabase.messages.getMessageRecord(messageId) + val attachments = SignalDatabase.attachments.getAttachmentsForMessage(messageId) + + _state.value = ViewState( + id = messageRecord.id, + sentTimestamp = messageRecord.dateSent, + receivedTimestamp = messageRecord.dateReceived, + serverSentTimestamp = messageRecord.serverTimestamp, + from = messageRecord.fromRecipient.id, + to = messageRecord.toRecipient.id, + attachments = attachments.map { attachment -> + val info = SignalDatabase.attachments.getDataFileInfo(attachment.attachmentId) + + AttachmentInfo( + id = attachment.attachmentId.id, + contentType = attachment.contentType, + size = attachment.size, + fileName = attachment.fileName, + hashStart = info?.hashStart, + hashEnd = info?.hashEnd, + transformProperties = info?.transformProperties?.let { JsonUtil.toJson(it) } ?: "null" + ) + } + ) + } + } + + data class ViewState( + val id: Long, + val sentTimestamp: Long, + val receivedTimestamp: Long, + val serverSentTimestamp: Long, + val from: RecipientId, + val to: RecipientId, + val attachments: List + ) + + data class AttachmentInfo( + val id: Long, + val contentType: String, + val size: Long, + val fileName: String?, + val hashStart: String?, + val hashEnd: String?, + val transformProperties: String? + ) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageDetailsAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageDetailsAdapter.java index 4f9c781f45..b00e1ffbec 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageDetailsAdapter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageDetailsAdapter.java @@ -37,7 +37,7 @@ final class MessageDetailsAdapter extends ListAdapter callbacks.onInternalDetailsClicked(messageRecord)); } private void bindMessageView(@NonNull LifecycleOwner lifecycleOwner, @NonNull ConversationMessage conversationMessage) { diff --git a/app/src/main/res/layout/message_details_header.xml b/app/src/main/res/layout/message_details_header.xml index 906c823bab..aa5fa1bc54 100644 --- a/app/src/main/res/layout/message_details_header.xml +++ b/app/src/main/res/layout/message_details_header.xml @@ -130,6 +130,17 @@ +