Add an 'internal details' screen for message details.

This commit is contained in:
Greyson Parrelli 2024-03-20 13:18:45 -04:00 committed by Nicholas Tinsley
parent de86c5622d
commit 6854f7eb2a
8 changed files with 389 additions and 13 deletions

View file

@ -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()
}

View file

@ -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)

View file

@ -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()
)
)
}

View file

@ -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<ViewState?> = mutableStateOf(null)
val state: State<ViewState?> = _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<AttachmentInfo>
)
data class AttachmentInfo(
val id: Long,
val contentType: String,
val size: Long,
val fileName: String?,
val hashStart: String?,
val hashEnd: String?,
val transformProperties: String?
)
}

View file

@ -37,7 +37,7 @@ final class MessageDetailsAdapter extends ListAdapter<MessageDetailsAdapter.Mess
public @NonNull RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
switch (viewType) {
case MessageDetailsViewState.MESSAGE_HEADER:
return new MessageHeaderViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.message_details_header, parent, false), requestManager, colorizer);
return new MessageHeaderViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.message_details_header, parent, false), requestManager, colorizer, callbacks);
case MessageDetailsViewState.RECIPIENT_HEADER:
return new RecipientHeaderViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.message_details_recipient_header, parent, false));
case MessageDetailsViewState.RECIPIENT:
@ -130,5 +130,6 @@ final class MessageDetailsAdapter extends ListAdapter<MessageDetailsAdapter.Mess
interface Callbacks {
void onErrorClicked(@NonNull MessageRecord messageRecord);
void onViewEditHistoryClicked(MessageRecord record);
void onInternalDetailsClicked(MessageRecord record);
}
}

View file

@ -177,6 +177,11 @@ public final class MessageDetailsFragment extends FullScreenDialogFragment imple
}
}
@Override
public void onInternalDetailsClicked(MessageRecord record) {
InternalMessageDetailsFragment.create(record).show(getParentFragmentManager(), InternalMessageDetailsFragment.class.getSimpleName());
}
public interface Callback {
void onMessageDetailsFragmentDismissed();
}

View file

@ -32,9 +32,11 @@ import org.thoughtcrime.securesms.conversation.ConversationItemDisplayMode;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4Playable;
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackPolicyEnforcer;
import org.thoughtcrime.securesms.messagedetails.MessageDetailsAdapter.Callbacks;
import org.thoughtcrime.securesms.sms.MessageSender;
import org.thoughtcrime.securesms.util.DateUtils;
import org.thoughtcrime.securesms.util.ExpirationUtil;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.Projection;
import org.thoughtcrime.securesms.util.ProjectionList;
@ -53,30 +55,34 @@ final class MessageHeaderViewHolder extends RecyclerView.ViewHolder implements G
private final TextView errorText;
private final View resendButton;
private final View messageMetadata;
private final View internalDetailsButton;
private final ViewStub updateStub;
private final ViewStub sentStub;
private final ViewStub receivedStub;
private final Colorizer colorizer;
private final RequestManager requestManager;
private final Callbacks callbacks;
private ConversationItem conversationItem;
private CountDownTimer expiresUpdater;
MessageHeaderViewHolder(@NonNull View itemView, RequestManager requestManager, @NonNull Colorizer colorizer) {
MessageHeaderViewHolder(@NonNull View itemView, RequestManager requestManager, @NonNull Colorizer colorizer, @NonNull Callbacks callbacks) {
super(itemView);
this.requestManager = requestManager;
this.colorizer = colorizer;
this.callbacks = callbacks;
sentDate = itemView.findViewById(R.id.message_details_header_sent_time);
receivedDate = itemView.findViewById(R.id.message_details_header_received_time);
expiresIn = itemView.findViewById(R.id.message_details_header_expires_in);
transport = itemView.findViewById(R.id.message_details_header_transport);
errorText = itemView.findViewById(R.id.message_details_header_error_text);
resendButton = itemView.findViewById(R.id.message_details_header_resend_button);
messageMetadata = itemView.findViewById(R.id.message_details_header_message_metadata);
updateStub = itemView.findViewById(R.id.message_details_header_message_view_update);
sentStub = itemView.findViewById(R.id.message_details_header_message_view_sent_multimedia);
receivedStub = itemView.findViewById(R.id.message_details_header_message_view_received_multimedia);
sentDate = itemView.findViewById(R.id.message_details_header_sent_time);
receivedDate = itemView.findViewById(R.id.message_details_header_received_time);
expiresIn = itemView.findViewById(R.id.message_details_header_expires_in);
transport = itemView.findViewById(R.id.message_details_header_transport);
errorText = itemView.findViewById(R.id.message_details_header_error_text);
resendButton = itemView.findViewById(R.id.message_details_header_resend_button);
messageMetadata = itemView.findViewById(R.id.message_details_header_message_metadata);
internalDetailsButton = itemView.findViewById(R.id.message_details_header_internal_details_button);
updateStub = itemView.findViewById(R.id.message_details_header_message_view_update);
sentStub = itemView.findViewById(R.id.message_details_header_message_view_sent_multimedia);
receivedStub = itemView.findViewById(R.id.message_details_header_message_view_received_multimedia);
}
void bind(@NonNull LifecycleOwner lifecycleOwner, @NonNull ConversationMessage conversationMessage) {
@ -86,6 +92,17 @@ final class MessageHeaderViewHolder extends RecyclerView.ViewHolder implements G
bindSentReceivedDates(messageRecord);
bindExpirationTime(lifecycleOwner, messageRecord);
bindTransport(messageRecord);
bindInternalDetails(messageRecord);
}
private void bindInternalDetails(MessageRecord messageRecord) {
if (!FeatureFlags.internalUser()) {
internalDetailsButton.setVisibility(View.GONE);
return;
}
internalDetailsButton.setVisibility(View.VISIBLE);
internalDetailsButton.setOnClickListener(v -> callbacks.onInternalDetailsClicked(messageRecord));
}
private void bindMessageView(@NonNull LifecycleOwner lifecycleOwner, @NonNull ConversationMessage conversationMessage) {

View file

@ -130,6 +130,17 @@
</androidx.constraintlayout.widget.ConstraintLayout>
<Button
android:id="@+id/message_details_header_internal_details_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:text="Internal Details"
android:layout_gravity="center"
android:visibility="gone"
style="@style/Signal.Widget.Button.Medium.OutlinedButton"
tools:ignore="HardcodedText" />
<include
layout="@layout/dsl_divider_item"
android:layout_width="match_parent"