Add an 'internal details' screen for message details.
This commit is contained in:
parent
de86c5622d
commit
6854f7eb2a
8 changed files with 389 additions and 13 deletions
|
@ -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()
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
)
|
||||
)
|
||||
}
|
|
@ -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?
|
||||
)
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Add table
Reference in a new issue