Refresh Message Details screen.

master
Cody Henthorne 2020-06-16 16:00:24 -04:00 committed by Greyson Parrelli
parent dfb5562142
commit d9641128a8
21 changed files with 1471 additions and 3 deletions

View File

@ -249,6 +249,12 @@
android:launchMode="singleTask"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
<activity android:name=".messagedetails.MessageDetailsActivity"
android:label="@string/AndroidManifest__message_details"
android:windowSoftInputMode="stateHidden"
android:launchMode="singleTask"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>
<activity android:name=".GroupCreateActivity"
android:windowSoftInputMode="stateVisible"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"/>

View File

@ -690,7 +690,7 @@ public class ConversationFragment extends Fragment {
private void handleDisplayDetails(MessageRecord message) {
Intent intent = new Intent(getActivity(), MessageDetailsActivity.class);
Intent intent = new Intent(getActivity(), org.thoughtcrime.securesms.messagedetails.MessageDetailsActivity.class);
intent.putExtra(MessageDetailsActivity.MESSAGE_ID_EXTRA, message.getId());
intent.putExtra(MessageDetailsActivity.THREAD_ID_EXTRA, threadId);
intent.putExtra(MessageDetailsActivity.TYPE_EXTRA, message.isMms() ? MmsSmsDatabase.MMS_TRANSPORT : MmsSmsDatabase.SMS_TRANSPORT);

View File

@ -1377,7 +1377,7 @@ public class ConversationItem extends LinearLayout implements BindableConversati
if (!shouldInterceptClicks(messageRecord) && parent != null) {
parent.onClick(v);
} else if (messageRecord.isFailed()) {
Intent intent = new Intent(context, MessageDetailsActivity.class);
Intent intent = new Intent(context, org.thoughtcrime.securesms.messagedetails.MessageDetailsActivity.class);
intent.putExtra(MessageDetailsActivity.MESSAGE_ID_EXTRA, messageRecord.getId());
intent.putExtra(MessageDetailsActivity.THREAD_ID_EXTRA, messageRecord.getThreadId());
intent.putExtra(MessageDetailsActivity.TYPE_EXTRA, messageRecord.isMms() ? MmsSmsDatabase.MMS_TRANSPORT : MmsSmsDatabase.SMS_TRANSPORT);

View File

@ -78,6 +78,10 @@ public abstract class DisplayRecord {
!MmsSmsColumns.Types.isIdentityDefault(type);
}
public boolean isSent() {
return MmsSmsColumns.Types.isSentType(type);
}
public boolean isOutgoing() {
return MmsSmsColumns.Types.isOutgoingMessageType(type);
}

View File

@ -0,0 +1,85 @@
package org.thoughtcrime.securesms.messagedetails;
import androidx.annotation.NonNull;
import com.annimon.stream.ComparatorCompat;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import java.util.Collection;
import java.util.Comparator;
import java.util.List;
import java.util.TreeSet;
final class MessageDetails {
private static final Comparator<RecipientDeliveryStatus> HAS_DISPLAY_NAME = (r1, r2) -> Boolean.compare(r2.getRecipient().hasAUserSetDisplayName(ApplicationDependencies.getApplication()), r1.getRecipient().hasAUserSetDisplayName(ApplicationDependencies.getApplication()));
private static final Comparator<RecipientDeliveryStatus> ALPHABETICAL = (r1, r2) -> r1.getRecipient().getDisplayName(ApplicationDependencies.getApplication()).compareToIgnoreCase(r2.getRecipient().getDisplayName(ApplicationDependencies.getApplication()));
private static final Comparator<RecipientDeliveryStatus> RECIPIENT_COMPARATOR = ComparatorCompat.chain(HAS_DISPLAY_NAME).thenComparing(ALPHABETICAL);
private final MessageRecord messageRecord;
private final Collection<RecipientDeliveryStatus> pending;
private final Collection<RecipientDeliveryStatus> sent;
private final Collection<RecipientDeliveryStatus> delivered;
private final Collection<RecipientDeliveryStatus> read;
private final Collection<RecipientDeliveryStatus> notSent;
MessageDetails(MessageRecord messageRecord, List<RecipientDeliveryStatus> recipients) {
this.messageRecord = messageRecord;
pending = new TreeSet<>(RECIPIENT_COMPARATOR);
sent = new TreeSet<>(RECIPIENT_COMPARATOR);
delivered = new TreeSet<>(RECIPIENT_COMPARATOR);
read = new TreeSet<>(RECIPIENT_COMPARATOR);
notSent = new TreeSet<>(RECIPIENT_COMPARATOR);
if (messageRecord.isOutgoing()) {
for (RecipientDeliveryStatus status : recipients) {
switch (status.getDeliveryStatus()) {
case UNKNOWN:
notSent.add(status);
break;
case PENDING:
pending.add(status);
break;
case SENT:
sent.add(status);
break;
case DELIVERED:
delivered.add(status);
break;
case READ:
read.add(status);
break;
}
}
} else {
sent.addAll(recipients);
}
}
@NonNull MessageRecord getMessageRecord() {
return messageRecord;
}
@NonNull Collection<RecipientDeliveryStatus> getPending() {
return pending;
}
@NonNull Collection<RecipientDeliveryStatus> getSent() {
return sent;
}
@NonNull Collection<RecipientDeliveryStatus> getDelivered() {
return delivered;
}
@NonNull Collection<RecipientDeliveryStatus> getRead() {
return read;
}
@NonNull Collection<RecipientDeliveryStatus> getNotSent() {
return notSent;
}
}

View File

@ -0,0 +1,147 @@
package org.thoughtcrime.securesms.messagedetails;
import android.graphics.drawable.ColorDrawable;
import android.os.Build;
import android.os.Bundle;
import android.view.MenuItem;
import androidx.lifecycle.ViewModelProviders;
import androidx.recyclerview.widget.RecyclerView;
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.color.MaterialColor;
import org.thoughtcrime.securesms.messagedetails.MessageDetailsAdapter.MessageDetailsViewState;
import org.thoughtcrime.securesms.messagedetails.MessageDetailsViewModel.Factory;
import org.thoughtcrime.securesms.mms.GlideApp;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.DynamicDarkActionBarTheme;
import org.thoughtcrime.securesms.util.DynamicTheme;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
public final class MessageDetailsActivity extends PassphraseRequiredActionBarActivity {
public static final String MESSAGE_ID_EXTRA = "message_id";
public static final String THREAD_ID_EXTRA = "thread_id";
public static final String TYPE_EXTRA = "type";
public static final String RECIPIENT_EXTRA = "recipient_id";
private GlideRequests glideRequests;
private MessageDetailsViewModel viewModel;
private MessageDetailsAdapter adapter;
private DynamicTheme dynamicTheme = new DynamicDarkActionBarTheme();
@Override
protected void onPreCreate() {
dynamicTheme.onCreate(this);
}
@Override
protected void onCreate(Bundle savedInstanceState, boolean ready) {
super.onCreate(savedInstanceState, ready);
setContentView(R.layout.message_details_2_activity);
glideRequests = GlideApp.with(this);
initializeList();
initializeViewModel();
initializeActionBar();
}
@Override
protected void onResume() {
super.onResume();
dynamicTheme.onResume(this);
adapter.resumeMessageExpirationTimer();
}
@Override
protected void onPause() {
super.onPause();
adapter.pauseMessageExpirationTimer();
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
if (item.getItemId() == android.R.id.home) {
onBackPressed();
return true;
}
return super.onOptionsItemSelected(item);
}
private void initializeList() {
RecyclerView list = findViewById(R.id.message_details_list);
adapter = new MessageDetailsAdapter(glideRequests);
list.setAdapter(adapter);
}
private void initializeViewModel() {
final RecipientId recipientId = getIntent().getParcelableExtra(RECIPIENT_EXTRA);
final String type = getIntent().getStringExtra(TYPE_EXTRA);
final Long messageId = getIntent().getLongExtra(MESSAGE_ID_EXTRA, -1);
final Factory factory = new Factory(recipientId, type, messageId);
viewModel = ViewModelProviders.of(this, factory).get(MessageDetailsViewModel.class);
viewModel.getMessageDetails().observe(this, details -> {
if (details == null) {
finish();
} else {
adapter.submitList(convertToRows(details));
}
});
}
private void initializeActionBar() {
assert getSupportActionBar() != null;
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
viewModel.getRecipientColor().observe(this, this::setActionBarColor);
}
private void setActionBarColor(MaterialColor color) {
assert getSupportActionBar() != null;
getSupportActionBar().setBackgroundDrawable(new ColorDrawable(color.toActionBarColor(this)));
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
getWindow().setStatusBarColor(color.toStatusBarColor(this));
}
}
private List<MessageDetailsViewState<?>> convertToRows(MessageDetails details) {
List<MessageDetailsViewState<?>> list = new ArrayList<>();
list.add(new MessageDetailsViewState<>(details.getMessageRecord(), MessageDetailsViewState.MESSAGE_HEADER));
int headerOrder = 0;
if (details.getMessageRecord().isOutgoing()) {
if (addRecipients(list, RecipientHeader.notSent(headerOrder), details.getNotSent())) headerOrder++;
if (addRecipients(list, RecipientHeader.read(headerOrder), details.getRead())) headerOrder++;
if (addRecipients(list, RecipientHeader.delivered(headerOrder), details.getDelivered())) headerOrder++;
if (addRecipients(list, RecipientHeader.sentTo(headerOrder), details.getSent())) headerOrder++;
addRecipients(list, RecipientHeader.pending(headerOrder), details.getPending());
} else {
addRecipients(list, RecipientHeader.sentFrom(headerOrder), details.getSent());
}
return list;
}
private boolean addRecipients(List<MessageDetailsViewState<?>> list, RecipientHeader header, Collection<RecipientDeliveryStatus> recipients) {
if (recipients.isEmpty()) {
return false;
}
list.add(new MessageDetailsViewState<>(header, MessageDetailsViewState.RECIPIENT_HEADER));
for (RecipientDeliveryStatus status : recipients) {
list.add(new MessageDetailsViewState<>(status, MessageDetailsViewState.RECIPIENT));
}
return true;
}
}

View File

@ -0,0 +1,150 @@
package org.thoughtcrime.securesms.messagedetails;
import android.annotation.SuppressLint;
import android.view.LayoutInflater;
import android.view.ViewGroup;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.DiffUtil;
import androidx.recyclerview.widget.ListAdapter;
import androidx.recyclerview.widget.RecyclerView;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.mms.GlideRequests;
import java.util.List;
final class MessageDetailsAdapter extends ListAdapter<MessageDetailsAdapter.MessageDetailsViewState<?>, RecyclerView.ViewHolder> {
private static final Object EXPIRATION_TIMER_CHANGE_PAYLOAD = new Object();
private final GlideRequests glideRequests;
private boolean running;
MessageDetailsAdapter(GlideRequests glideRequests) {
super(new MessageDetailsDiffer());
this.glideRequests = glideRequests;
running = true;
}
@Override
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_2_header, parent, false), glideRequests);
case MessageDetailsViewState.RECIPIENT_HEADER:
return new RecipientHeaderViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.message_details_2_recipient_header, parent, false));
case MessageDetailsViewState.RECIPIENT:
return new RecipientViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.message_details_2_recipient, parent, false));
default:
throw new AssertionError("unknown view type");
}
}
@Override
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) {
if (holder instanceof MessageHeaderViewHolder) {
((MessageHeaderViewHolder) holder).bind((MessageRecord) getItem(position).data, running);
} else if (holder instanceof RecipientHeaderViewHolder) {
((RecipientHeaderViewHolder) holder).bind((RecipientHeader) getItem(position).data);
} else if (holder instanceof RecipientViewHolder) {
((RecipientViewHolder) holder).bind((RecipientDeliveryStatus) getItem(position).data);
} else {
throw new AssertionError("unknown view holder");
}
}
@Override
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position, @NonNull List<Object> payloads) {
if (payloads.isEmpty()) {
super.onBindViewHolder(holder, position, payloads);
} else if (holder instanceof MessageHeaderViewHolder) {
((MessageHeaderViewHolder) holder).partialBind((MessageRecord) getItem(position).data, running);
}
}
@Override
public int getItemViewType(int position) {
return getItem(position).itemType;
}
void resumeMessageExpirationTimer() {
running = true;
if (getItemCount() > 0) {
notifyItemChanged(0, EXPIRATION_TIMER_CHANGE_PAYLOAD);
}
}
void pauseMessageExpirationTimer() {
running = false;
if (getItemCount() > 0) {
notifyItemChanged(0, EXPIRATION_TIMER_CHANGE_PAYLOAD);
}
}
private static class MessageDetailsDiffer extends DiffUtil.ItemCallback<MessageDetailsViewState<?>> {
@Override
public boolean areItemsTheSame(@NonNull MessageDetailsViewState<?> oldItem, @NonNull MessageDetailsViewState<?> newItem) {
Object oldData = oldItem.data;
Object newData = newItem.data;
if (oldData.getClass() == newData.getClass() && oldItem.itemType == newItem.itemType) {
switch (oldItem.itemType) {
case MessageDetailsViewState.MESSAGE_HEADER:
return true;
case MessageDetailsViewState.RECIPIENT_HEADER:
return ((RecipientHeader) oldData).getHeaderOrder() == ((RecipientHeader) newData).getHeaderOrder();
case MessageDetailsViewState.RECIPIENT:
return ((RecipientDeliveryStatus) oldData).getRecipient().getId().equals(((RecipientDeliveryStatus) newData).getRecipient().getId());
}
}
return false;
}
@SuppressLint("DiffUtilEquals")
@Override
public boolean areContentsTheSame(@NonNull MessageDetailsViewState<?> oldItem, @NonNull MessageDetailsViewState<?> newItem) {
Object oldData = oldItem.data;
Object newData = newItem.data;
if (oldData.getClass() == newData.getClass() && oldItem.itemType == newItem.itemType) {
switch (oldItem.itemType) {
case MessageDetailsViewState.MESSAGE_HEADER:
return areMessageRecordContentsTheSame((MessageRecord) oldData, (MessageRecord) newData);
case MessageDetailsViewState.RECIPIENT_HEADER:
return ((RecipientHeader) oldData).getHeader() == ((RecipientHeader) newData).getHeader();
case MessageDetailsViewState.RECIPIENT:
return true;
}
}
return false;
}
private boolean areMessageRecordContentsTheSame(MessageRecord oldData, MessageRecord newData) {
return oldData.equals(newData) &&
oldData.getDateSent() == newData.getDateSent() &&
oldData.getDateReceived() == newData.getDateReceived() &&
oldData.getType() == newData.getType() &&
oldData.getExpiresIn() == newData.getExpiresIn() &&
oldData.getExpireStarted() == newData.getExpireStarted() &&
oldData.getReactions().equals(newData.getReactions());
}
}
static final class MessageDetailsViewState<T> {
public static final int MESSAGE_HEADER = 0;
public static final int RECIPIENT_HEADER = 1;
public static final int RECIPIENT = 2;
private final T data;
private int itemType;
MessageDetailsViewState(T t, int itemType) {
this.data = t;
this.itemType = itemType;
}
}
}

View File

@ -0,0 +1,132 @@
package org.thoughtcrime.securesms.messagedetails;
import android.content.Context;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.WorkerThread;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.GroupDatabase;
import org.thoughtcrime.securesms.database.GroupReceiptDatabase;
import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch;
import org.thoughtcrime.securesms.database.documents.NetworkFailure;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
import java.util.LinkedList;
import java.util.List;
final class MessageDetailsRepository {
private final Context context = ApplicationDependencies.getApplication();
@NonNull LiveData<MessageRecord> getMessageRecord(String type, Long messageId) {
return new MessageRecordLiveData(context, type, messageId);
}
@NonNull LiveData<MessageDetails> getMessageDetails(@Nullable MessageRecord messageRecord) {
final MutableLiveData<MessageDetails> liveData = new MutableLiveData<>();
if (messageRecord != null) {
SignalExecutors.BOUNDED.execute(() -> liveData.postValue(getRecipientDeliveryStatusesInternal(messageRecord)));
} else {
liveData.setValue(null);
}
return liveData;
}
@WorkerThread
private @NonNull MessageDetails getRecipientDeliveryStatusesInternal(@NonNull MessageRecord messageRecord) {
List<RecipientDeliveryStatus> recipients = new LinkedList<>();
if (!messageRecord.getRecipient().isGroup()) {
recipients.add(new RecipientDeliveryStatus(messageRecord,
messageRecord.getRecipient(),
getStatusFor(messageRecord),
messageRecord.isUnidentified(),
-1,
getNetworkFailure(messageRecord, messageRecord.getRecipient()),
getKeyMismatchFailure(messageRecord, messageRecord.getRecipient())));
} else {
List<GroupReceiptDatabase.GroupReceiptInfo> receiptInfoList = DatabaseFactory.getGroupReceiptDatabase(context).getGroupReceiptInfo(messageRecord.getId());
if (receiptInfoList.isEmpty()) {
List<Recipient> group = DatabaseFactory.getGroupDatabase(context).getGroupMembers(messageRecord.getRecipient().requireGroupId(), GroupDatabase.MemberSet.FULL_MEMBERS_EXCLUDING_SELF);
for (Recipient recipient : group) {
recipients.add(new RecipientDeliveryStatus(messageRecord,
recipient,
RecipientDeliveryStatus.Status.UNKNOWN,
false,
-1,
getNetworkFailure(messageRecord, recipient),
getKeyMismatchFailure(messageRecord, recipient)));
}
} else {
for (GroupReceiptDatabase.GroupReceiptInfo info : receiptInfoList) {
Recipient recipient = Recipient.resolved(info.getRecipientId());
NetworkFailure failure = getNetworkFailure(messageRecord, recipient);
IdentityKeyMismatch mismatch = getKeyMismatchFailure(messageRecord, recipient);
boolean recipientFailure = failure != null || mismatch != null;
recipients.add(new RecipientDeliveryStatus(messageRecord,
recipient,
getStatusFor(info.getStatus(), messageRecord.isPending(), recipientFailure),
info.isUnidentified(),
info.getTimestamp(),
failure,
mismatch));
}
}
}
return new MessageDetails(messageRecord, recipients);
}
private @Nullable NetworkFailure getNetworkFailure(MessageRecord messageRecord, Recipient recipient) {
if (messageRecord.hasNetworkFailures()) {
for (final NetworkFailure failure : messageRecord.getNetworkFailures()) {
if (failure.getRecipientId(context).equals(recipient.getId())) {
return failure;
}
}
}
return null;
}
private @Nullable IdentityKeyMismatch getKeyMismatchFailure(MessageRecord messageRecord, Recipient recipient) {
if (messageRecord.isIdentityMismatchFailure()) {
for (final IdentityKeyMismatch mismatch : messageRecord.getIdentityKeyMismatches()) {
if (mismatch.getRecipientId(context).equals(recipient.getId())) {
return mismatch;
}
}
}
return null;
}
private @NonNull RecipientDeliveryStatus.Status getStatusFor(MessageRecord messageRecord) {
if (messageRecord.isRemoteRead()) return RecipientDeliveryStatus.Status.READ;
if (messageRecord.isDelivered()) return RecipientDeliveryStatus.Status.DELIVERED;
if (messageRecord.isSent()) return RecipientDeliveryStatus.Status.SENT;
if (messageRecord.isPending()) return RecipientDeliveryStatus.Status.PENDING;
return RecipientDeliveryStatus.Status.UNKNOWN;
}
private @NonNull RecipientDeliveryStatus.Status getStatusFor(int groupStatus, boolean pending, boolean failed) {
if (groupStatus == GroupReceiptDatabase.STATUS_READ) return RecipientDeliveryStatus.Status.READ;
else if (groupStatus == GroupReceiptDatabase.STATUS_DELIVERED) return RecipientDeliveryStatus.Status.DELIVERED;
else if (groupStatus == GroupReceiptDatabase.STATUS_UNDELIVERED && failed) return RecipientDeliveryStatus.Status.UNKNOWN;
else if (groupStatus == GroupReceiptDatabase.STATUS_UNDELIVERED && !pending) return RecipientDeliveryStatus.Status.SENT;
else if (groupStatus == GroupReceiptDatabase.STATUS_UNDELIVERED) return RecipientDeliveryStatus.Status.PENDING;
else if (groupStatus == GroupReceiptDatabase.STATUS_UNKNOWN) return RecipientDeliveryStatus.Status.UNKNOWN;
throw new AssertionError();
}
}

View File

@ -0,0 +1,55 @@
package org.thoughtcrime.securesms.messagedetails;
import androidx.annotation.NonNull;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.Transformations;
import androidx.lifecycle.ViewModel;
import androidx.lifecycle.ViewModelProvider;
import org.thoughtcrime.securesms.color.MaterialColor;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import java.util.Objects;
final class MessageDetailsViewModel extends ViewModel {
private final LiveData<Recipient> recipient;
private final LiveData<MessageDetails> messageDetails;
private MessageDetailsViewModel(RecipientId recipientId, String type, Long messageId) {
recipient = Recipient.live(recipientId).getLiveData();
MessageDetailsRepository repository = new MessageDetailsRepository();
LiveData<MessageRecord> messageRecord = repository.getMessageRecord(type, messageId);
messageDetails = Transformations.switchMap(messageRecord, repository::getMessageDetails);
}
@NonNull LiveData<MaterialColor> getRecipientColor() {
return Transformations.distinctUntilChanged(Transformations.map(recipient, Recipient::getColor));
}
@NonNull LiveData<MessageDetails> getMessageDetails() {
return messageDetails;
}
static final class Factory implements ViewModelProvider.Factory {
private final RecipientId recipientId;
private final String type;
private final Long messageId;
Factory(RecipientId recipientId, String type, Long messageId) {
this.recipientId = recipientId;
this.type = type;
this.messageId = messageId;
}
@Override
public @NonNull <T extends ViewModel> T create(@NonNull Class<T> modelClass) {
return Objects.requireNonNull(modelClass.cast(new MessageDetailsViewModel(recipientId, type, messageId)));
}
}
}

View File

@ -0,0 +1,210 @@
package org.thoughtcrime.securesms.messagedetails;
import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.Context;
import android.view.View;
import android.view.ViewStub;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.conversation.ConversationItem;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.sms.MessageSender;
import org.thoughtcrime.securesms.util.DateUtils;
import org.thoughtcrime.securesms.util.ExpirationUtil;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
import org.whispersystems.libsignal.util.guava.Optional;
import java.sql.Date;
import java.text.SimpleDateFormat;
import java.util.HashSet;
import java.util.Locale;
final class MessageHeaderViewHolder extends RecyclerView.ViewHolder {
private final TextView sentDate;
private final TextView receivedDate;
private final TextView expiresIn;
private final TextView transport;
private final View expiresGroup;
private final View receivedGroup;
private final TextView errorText;
private final View resendButton;
private final View messageMetadata;
private final ViewStub updateStub;
private final ViewStub sentStub;
private final ViewStub receivedStub;
private GlideRequests glideRequests;
private ConversationItem conversationItem;
private ExpiresUpdater expiresUpdater;
MessageHeaderViewHolder(@NonNull View itemView, GlideRequests glideRequests) {
super(itemView);
this.glideRequests = glideRequests;
sentDate = itemView.findViewById(R.id.sent_time);
receivedDate = itemView.findViewById(R.id.received_time);
receivedGroup = itemView.findViewById(R.id.received_group);
expiresIn = itemView.findViewById(R.id.expires_in);
expiresGroup = itemView.findViewById(R.id.expires_group);
transport = itemView.findViewById(R.id.transport);
errorText = itemView.findViewById(R.id.error_text);
resendButton = itemView.findViewById(R.id.resend_button);
messageMetadata = itemView.findViewById(R.id.message_metadata);
updateStub = itemView.findViewById(R.id.message_view_update);
sentStub = itemView.findViewById(R.id.message_view_sent_multimedia);
receivedStub = itemView.findViewById(R.id.message_view_received_multimedia);
}
void bind(MessageRecord messageRecord, boolean running) {
bindMessageView(messageRecord);
bindErrorState(messageRecord);
bindSentReceivedDates(messageRecord);
bindExpirationTime(messageRecord, running);
bindTransport(messageRecord);
}
void partialBind(MessageRecord messageRecord, boolean running) {
bindExpirationTime(messageRecord, running);
}
private void bindMessageView(MessageRecord messageRecord) {
if (conversationItem == null) {
if (messageRecord.isGroupAction()) conversationItem = (ConversationItem) updateStub.inflate();
else if (messageRecord.isOutgoing()) conversationItem = (ConversationItem) sentStub.inflate();
else conversationItem = (ConversationItem) receivedStub.inflate();
}
conversationItem.bind(messageRecord, Optional.absent(), Optional.absent(), glideRequests, Locale.getDefault(), new HashSet<>(), messageRecord.getRecipient(), null, false);
}
private void bindErrorState(MessageRecord messageRecord) {
boolean isPushGroup = messageRecord.getRecipient().isPushGroup();
boolean isGroupNetworkFailure = messageRecord.isFailed() && !messageRecord.getNetworkFailures().isEmpty();
boolean isIndividualNetworkFailure = messageRecord.isFailed() && !isPushGroup && messageRecord.getIdentityKeyMismatches().isEmpty();
if (isGroupNetworkFailure || isIndividualNetworkFailure) {
errorText.setVisibility(View.VISIBLE);
resendButton.setVisibility(View.VISIBLE);
resendButton.setOnClickListener(unused -> {
resendButton.setOnClickListener(null);
SignalExecutors.BOUNDED.execute(() -> MessageSender.resend(itemView.getContext().getApplicationContext(), messageRecord));
});
messageMetadata.setVisibility(View.GONE);
} else if (messageRecord.isFailed()) {
errorText.setVisibility(View.VISIBLE);
resendButton.setVisibility(View.GONE);
resendButton.setOnClickListener(null);
messageMetadata.setVisibility(View.GONE);
} else {
errorText.setVisibility(View.GONE);
resendButton.setVisibility(View.GONE);
resendButton.setOnClickListener(null);
messageMetadata.setVisibility(View.VISIBLE);
}
}
private void bindSentReceivedDates(MessageRecord messageRecord) {
sentDate.setOnLongClickListener(null);
receivedDate.setOnLongClickListener(null);
if (messageRecord.isPending() || messageRecord.isFailed()) {
sentDate.setText("-");
receivedGroup.setVisibility(View.GONE);
} else {
Locale dateLocale = Locale.getDefault();
SimpleDateFormat dateFormatter = DateUtils.getDetailedDateFormatter(itemView.getContext(), dateLocale);
sentDate.setText(dateFormatter.format(new Date(messageRecord.getDateSent())));
sentDate.setOnLongClickListener(v -> {
copyToClipboard(String.valueOf(messageRecord.getDateSent()));
return true;
});
if (messageRecord.getDateReceived() != messageRecord.getDateSent() && !messageRecord.isOutgoing()) {
receivedDate.setText(dateFormatter.format(new Date(messageRecord.getDateReceived())));
receivedDate.setOnLongClickListener(v -> {
copyToClipboard(String.valueOf(messageRecord.getDateReceived()));
return true;
});
receivedGroup.setVisibility(View.VISIBLE);
} else {
receivedGroup.setVisibility(View.GONE);
}
}
}
private void bindExpirationTime(final MessageRecord messageRecord, boolean running) {
if (expiresUpdater != null) {
expiresUpdater.stop();
expiresUpdater = null;
}
if (messageRecord.getExpiresIn() <= 0 || messageRecord.getExpireStarted() <= 0) {
expiresGroup.setVisibility(View.GONE);
return;
}
expiresGroup.setVisibility(View.VISIBLE);
if (running) {
expiresUpdater = new ExpiresUpdater(messageRecord);
Util.runOnMain(expiresUpdater);
}
}
private void bindTransport(MessageRecord messageRecord) {
final String transportText;
if (messageRecord.isOutgoing() && messageRecord.isFailed()) {
transportText = "-";
} else if (messageRecord.isPending()) {
transportText = itemView.getContext().getString(R.string.ConversationFragment_pending);
} else if (messageRecord.isPush()) {
transportText = itemView.getContext().getString(R.string.ConversationFragment_push);
} else if (messageRecord.isMms()) {
transportText = itemView.getContext().getString(R.string.ConversationFragment_mms);
} else {
transportText = itemView.getContext().getString(R.string.ConversationFragment_sms);
}
transport.setText(transportText);
}
private void copyToClipboard(String text) {
((ClipboardManager) itemView.getContext().getSystemService(Context.CLIPBOARD_SERVICE)).setPrimaryClip(ClipData.newPlainText("text", text));
}
private class ExpiresUpdater implements Runnable {
private final long expireStartedTimestamp;
private final long expiresInTimestamp;
private boolean running;
ExpiresUpdater(MessageRecord messageRecord) {
expireStartedTimestamp = messageRecord.getExpireStarted();
expiresInTimestamp = messageRecord.getExpiresIn();
running = true;
}
@Override
public void run() {
long elapsed = System.currentTimeMillis() - expireStartedTimestamp;
long remaining = expiresInTimestamp - elapsed;
int expirationTime = Math.max((int) (remaining / 1000), 1);
String duration = ExpirationUtil.getExpirationDisplayValue(itemView.getContext(), expirationTime);
expiresIn.setText(duration);
if (running && expirationTime > 1) {
Util.runOnMainDelayed(this, 500);
}
}
void stop() {
running = false;
}
}
}

View File

@ -0,0 +1,107 @@
package org.thoughtcrime.securesms.messagedetails;
import android.content.Context;
import android.database.ContentObserver;
import android.database.Cursor;
import androidx.annotation.Nullable;
import androidx.annotation.WorkerThread;
import androidx.lifecycle.LiveData;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.MmsDatabase;
import org.thoughtcrime.securesms.database.MmsSmsDatabase;
import org.thoughtcrime.securesms.database.SmsDatabase;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
final class MessageRecordLiveData extends LiveData<MessageRecord> {
private final Context context;
private final String type;
private final Long messageId;
private final ContentObserver obs;
private @Nullable Cursor cursor;
MessageRecordLiveData(Context context, String type, Long messageId) {
this.context = context;
this.type = type;
this.messageId = messageId;
obs = new ContentObserver(null) {
@Override
public void onChange(boolean selfChange) {
SignalExecutors.BOUNDED.execute(() -> resetCursor());
}
};
}
@Override
protected void onActive() {
retrieveMessageRecord();
}
@Override
protected void onInactive() {
SignalExecutors.BOUNDED.execute(this::destroyCursor);
}
private void retrieveMessageRecord() {
SignalExecutors.BOUNDED.execute(this::retrieveMessageRecordActual);
}
@WorkerThread
private synchronized void destroyCursor() {
if (cursor != null) {
cursor.unregisterContentObserver(obs);
cursor.close();
cursor = null;
}
}
@WorkerThread
private synchronized void resetCursor() {
destroyCursor();
retrieveMessageRecord();
}
@WorkerThread
private synchronized void retrieveMessageRecordActual() {
if (cursor != null) {
return;
}
switch (type) {
case MmsSmsDatabase.SMS_TRANSPORT:
handleSms();
break;
case MmsSmsDatabase.MMS_TRANSPORT:
handleMms();
break;
default:
throw new AssertionError("no valid message type specified");
}
}
@WorkerThread
private synchronized void handleSms() {
final SmsDatabase db = DatabaseFactory.getSmsDatabase(context);
final Cursor cursor = db.getVerboseMessageCursor(messageId);
final MessageRecord record = db.readerFor(cursor).getNext();
postValue(record);
cursor.registerContentObserver(obs);
this.cursor = cursor;
}
@WorkerThread
private synchronized void handleMms() {
final MmsDatabase db = DatabaseFactory.getMmsDatabase(context);
final Cursor cursor = db.getVerboseMessage(messageId);
final MessageRecord record = db.readerFor(cursor).getNext();
postValue(record);
cursor.registerContentObserver(obs);
this.cursor = cursor;
}
}

View File

@ -0,0 +1,62 @@
package org.thoughtcrime.securesms.messagedetails;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch;
import org.thoughtcrime.securesms.database.documents.NetworkFailure;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.recipients.Recipient;
final class RecipientDeliveryStatus {
enum Status {
UNKNOWN, PENDING, SENT, DELIVERED, READ
}
private final MessageRecord messageRecord;
private final Recipient recipient;
private final Status deliveryStatus;
private final boolean isUnidentified;
private final long timestamp;
private final NetworkFailure networkFailure;
private final IdentityKeyMismatch keyMismatchFailure;
RecipientDeliveryStatus(@NonNull MessageRecord messageRecord, @NonNull Recipient recipient, @NonNull Status deliveryStatus, boolean isUnidentified, long timestamp, @Nullable NetworkFailure networkFailure, @Nullable IdentityKeyMismatch keyMismatchFailure) {
this.messageRecord = messageRecord;
this.recipient = recipient;
this.deliveryStatus = deliveryStatus;
this.isUnidentified = isUnidentified;
this.timestamp = timestamp;
this.networkFailure = networkFailure;
this.keyMismatchFailure = keyMismatchFailure;
}
@NonNull MessageRecord getMessageRecord() {
return messageRecord;
}
@NonNull Status getDeliveryStatus() {
return deliveryStatus;
}
boolean isUnidentified() {
return isUnidentified;
}
long getTimestamp() {
return timestamp;
}
@NonNull Recipient getRecipient() {
return recipient;
}
@Nullable NetworkFailure getNetworkFailure() {
return networkFailure;
}
@Nullable IdentityKeyMismatch getKeyMismatchFailure() {
return keyMismatchFailure;
}
}

View File

@ -0,0 +1,64 @@
package org.thoughtcrime.securesms.messagedetails;
import androidx.annotation.NonNull;
import androidx.annotation.StringRes;
import org.thoughtcrime.securesms.R;
final class RecipientHeader {
private final int headerOrder;
private final int headerText;
private final HeaderStatus status;
private RecipientHeader(int headerOrder, @StringRes int headerText, @NonNull HeaderStatus headerStatus) {
this.headerOrder = headerOrder;
this.headerText = headerText;
this.status = headerStatus;
}
int getHeaderOrder() {
return headerOrder;
}
@StringRes int getHeader() {
return headerText;
}
@NonNull HeaderStatus getHeaderStatus() {
return status;
}
static RecipientHeader pending(int idx) {
return new RecipientHeader(idx, R.string.message_details_recipient_header__pending_send, HeaderStatus.PENDING);
}
static RecipientHeader sentTo(int idx) {
return new RecipientHeader(idx, R.string.message_details_recipient_header__sent_to, HeaderStatus.SENT_TO);
}
static RecipientHeader sentFrom(int idx) {
return new RecipientHeader(idx, R.string.message_details_recipient_header__sent_from, HeaderStatus.SENT_FROM);
}
static RecipientHeader delivered(int idx) {
return new RecipientHeader(idx, R.string.message_details_recipient_header__delivered_to, HeaderStatus.DELIVERED);
}
static RecipientHeader read(int idx) {
return new RecipientHeader(idx, R.string.message_details_recipient_header__read_by, HeaderStatus.READ);
}
static RecipientHeader notSent(int idx) {
return new RecipientHeader(idx, R.string.message_details_recipient_header__not_sent, HeaderStatus.NOT_SENT);
}
enum HeaderStatus {
PENDING,
SENT_TO,
SENT_FROM,
DELIVERED,
READ,
NOT_SENT
}
}

View File

@ -0,0 +1,42 @@
package org.thoughtcrime.securesms.messagedetails;
import android.view.View;
import android.widget.TextView;
import androidx.recyclerview.widget.RecyclerView;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.DeliveryStatusView;
final class RecipientHeaderViewHolder extends RecyclerView.ViewHolder {
private final TextView header;
private final DeliveryStatusView deliveryStatus;
RecipientHeaderViewHolder(View itemView) {
super(itemView);
header = itemView.findViewById(R.id.recipient_header_text);
deliveryStatus = itemView.findViewById(R.id.recipient_header_delivery_status);
}
void bind(RecipientHeader recipientHeader) {
header.setText(recipientHeader.getHeader());
switch (recipientHeader.getHeaderStatus()) {
case PENDING:
deliveryStatus.setPending();
break;
case SENT_TO:
deliveryStatus.setSent();
break;
case DELIVERED:
deliveryStatus.setDelivered();
break;
case READ:
deliveryStatus.setRead();
break;
default:
deliveryStatus.setNone();
break;
}
}
}

View File

@ -0,0 +1,68 @@
package org.thoughtcrime.securesms.messagedetails;
import android.view.View;
import android.widget.TextView;
import androidx.recyclerview.widget.RecyclerView;
import org.thoughtcrime.securesms.ConfirmIdentityDialog;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.AvatarImageView;
import org.thoughtcrime.securesms.components.FromTextView;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.util.DateUtils;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import java.sql.Date;
import java.text.SimpleDateFormat;
import java.util.Locale;
final class RecipientViewHolder extends RecyclerView.ViewHolder {
private final AvatarImageView avatar;
private final FromTextView fromView;
private final TextView timestamp;
private final TextView error;
private final View conflictButton;
private final View unidentifiedDeliveryIcon;
RecipientViewHolder(View itemView) {
super(itemView);
fromView = itemView.findViewById(R.id.recipient_name);
avatar = itemView.findViewById(R.id.recipient_avatar);
timestamp = itemView.findViewById(R.id.recipient_timestamp);
error = itemView.findViewById(R.id.error_description);
conflictButton = itemView.findViewById(R.id.conflict_button);
unidentifiedDeliveryIcon = itemView.findViewById(R.id.ud_indicator);
}
void bind(RecipientDeliveryStatus data) {
unidentifiedDeliveryIcon.setVisibility(TextSecurePreferences.isShowUnidentifiedDeliveryIndicatorsEnabled(itemView.getContext()) && data.isUnidentified() ? View.VISIBLE : View.GONE);
fromView.setText(data.getRecipient());
avatar.setRecipient(data.getRecipient());
if (data.getKeyMismatchFailure() != null) {
timestamp.setVisibility(View.GONE);
error.setVisibility(View.VISIBLE);
conflictButton.setVisibility(View.VISIBLE);
error.setText(itemView.getContext().getString(R.string.MessageDetailsRecipient_new_safety_number));
conflictButton.setOnClickListener(unused -> new ConfirmIdentityDialog(itemView.getContext(), data.getMessageRecord(), data.getKeyMismatchFailure()).show());
} else if ((data.getNetworkFailure() != null && !data.getMessageRecord().isPending()) || (!data.getMessageRecord().getRecipient().isPushGroup() && data.getMessageRecord().isFailed())) {
timestamp.setVisibility(View.GONE);
error.setVisibility(View.VISIBLE);
conflictButton.setVisibility(View.GONE);
error.setText(itemView.getContext().getString(R.string.MessageDetailsRecipient_failed_to_send));
} else {
timestamp.setVisibility(View.VISIBLE);
error.setVisibility(View.GONE);
conflictButton.setVisibility(View.GONE);
if (data.getTimestamp() > 0) {
Locale dateLocale = Locale.getDefault();
timestamp.setText(DateUtils.getTimeString(itemView.getContext(), dateLocale, data.getTimestamp()));
} else {
timestamp.setText("");
}
}
}
}

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.recyclerview.widget.RecyclerView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/message_details_list"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?pref_divider"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" />

View File

@ -0,0 +1,189 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/group_media_card"
style="@style/Widget.Signal.CardView.PreferenceRow"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingStart="16dp"
android:paddingTop="24dp"
android:paddingEnd="16dp"
android:paddingBottom="24dp">
<FrameLayout
android:id="@+id/message_view"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<ViewStub
android:id="@+id/message_view_update"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout="@layout/conversation_item_update"/>
<ViewStub
android:id="@+id/message_view_sent_multimedia"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout="@layout/conversation_item_sent_multimedia"/>
<ViewStub
android:id="@+id/message_view_received_multimedia"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout="@layout/conversation_item_received_multimedia"/>
</FrameLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/error_text"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:visibility="gone"
android:textSize="16sp"
android:padding="5dp"
tools:visibility="visible"
android:text="@string/message_details_header__issues_need_your_attention"
android:drawablePadding="4dp"
app:drawableStartCompat="@drawable/ic_info_outline_message_details_24"
android:gravity="center_vertical" />
<Button
android:id="@+id/resend_button"
android:layout_width="wrap_content"
android:layout_height="38sp"
style="@style/InfoButton"
android:paddingStart="10dp"
android:paddingEnd="10dp"
android:paddingTop="5dp"
android:paddingBottom="5dp"
android:layout_gravity="center_vertical"
android:drawableStart="@drawable/ic_refresh_white_18dp"
android:text="@string/message_recipients_list_item__resend"
android:visibility="gone"
tools:visibility="visible" />
</LinearLayout>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/message_metadata"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<TextView
android:id="@+id/sent_time_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:gravity="end"
android:text="@string/message_details_header__sent"
android:textStyle="bold"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/sent_time"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/message_details_table_row_pad"
android:background="?selectableItemBackground"
app:layout_constraintBottom_toBottomOf="@+id/sent_time_label"
app:layout_constraintStart_toEndOf="@+id/label_barrier"
tools:text="Jan 18, 2015, 12:29:37 AM GMT-08:00" />
<TextView
android:id="@+id/received_time_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:gravity="end"
android:text="@string/message_details_header__received"
android:textStyle="bold"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/sent_time_label" />
<TextView
android:id="@+id/received_time"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/message_details_table_row_pad"
android:background="?selectableItemBackground"
app:layout_constraintBottom_toBottomOf="@+id/received_time_label"
app:layout_constraintStart_toEndOf="@id/label_barrier"
tools:text="Jan 18, 2015, 12:31:15 AM GMT-08:00" />
<androidx.constraintlayout.widget.Group
android:id="@+id/received_group"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:constraint_referenced_ids="received_time_label,received_time" />
<TextView
android:id="@+id/expires_in_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:gravity="end"
android:text="@string/message_details_header__disappears"
android:textStyle="bold"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/received_time_label" />
<TextView
android:id="@+id/expires_in"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/message_details_table_row_pad"
app:layout_constraintBottom_toBottomOf="@+id/expires_in_label"
app:layout_constraintStart_toEndOf="@id/label_barrier"
tools:text="1 week" />
<androidx.constraintlayout.widget.Group
android:id="@+id/expires_group"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:constraint_referenced_ids="expires_in_label,expires_in" />
<TextView
android:id="@+id/transport_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:gravity="end"
android:text="@string/message_details_header__via"
android:textStyle="bold"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/expires_in_label" />
<TextView
android:id="@+id/transport"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/message_details_table_row_pad"
app:layout_constraintBottom_toBottomOf="@+id/transport_label"
app:layout_constraintStart_toEndOf="@id/label_barrier"
tools:text="Push (TextSecure)" />
<androidx.constraintlayout.widget.Barrier
android:id="@+id/label_barrier"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:barrierDirection="end"
app:constraint_referenced_ids="sent_time_label,received_time_label,expires_in_label" />
</androidx.constraintlayout.widget.ConstraintLayout>
</LinearLayout>
</androidx.cardview.widget.CardView>

View File

@ -0,0 +1,97 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/group_media_card"
style="@style/Widget.Signal.CardView.PreferenceRow"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp">
<org.thoughtcrime.securesms.components.AvatarImageView
android:id="@+id/recipient_avatar"
android:foreground="@drawable/contact_photo_background"
android:layout_width="36dp"
android:layout_height="36dp"
android:layout_marginEnd="8dp"
android:cropToPadding="true"
tools:src="@drawable/ic_contact_picture"
android:contentDescription="@string/SingleContactSelectionActivity_contact_photo"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toStartOf="@+id/recipient_name"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"/>
<org.thoughtcrime.securesms.components.FromTextView
android:id="@+id/recipient_name"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="4dp"
android:textAppearance="?android:attr/textAppearanceMedium"
android:textColor="?attr/conversation_list_item_contact_color"
android:singleLine="true"
tools:text="Jules Bonnot"
android:ellipsize="marquee"
app:layout_constraintStart_toEndOf="@+id/recipient_avatar"
app:layout_constraintEnd_toStartOf="@+id/recipient_timestamp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toTopOf="@+id/error_description"/>
<TextView android:id="@+id/error_description"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="#FFF44336"
android:visibility="gone"
tools:visibility="visible"
tools:text="New identity"
app:layout_constraintStart_toStartOf="@+id/recipient_name"
app:layout_constraintTop_toBottomOf="@+id/recipient_name"
app:layout_constraintBottom_toBottomOf="parent"/>
<TextView
android:id="@+id/recipient_timestamp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
tools:text="05/27/20 1:32 PM"
app:layout_constraintStart_toEndOf="@+id/recipient_name"
app:layout_constraintEnd_toStartOf="@+id/conflict_button"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"/>
<Button
android:id="@+id/conflict_button"
android:layout_width="wrap_content"
android:layout_height="36dp"
style="@style/ErrorButton"
android:paddingStart="12dp"
android:paddingEnd="12dp"
android:paddingTop="8dp"
android:paddingBottom="8dp"
android:drawableStart="@drawable/ic_error_white_18dp"
android:text="@string/message_recipients_list_item__view"
android:visibility="gone"
app:layout_constraintStart_toEndOf="@+id/recipient_timestamp"
app:layout_constraintEnd_toStartOf="@+id/ud_indicator"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
tools:visibility="gone" />
<ImageView android:id="@+id/ud_indicator"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginEnd="4dp"
android:src="@drawable/ic_unidentified_delivery"
android:tint="?attr/conversation_item_sent_text_secondary_color"
app:layout_constraintStart_toEndOf="@+id/conflict_button"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"/>
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.cardview.widget.CardView>

View File

@ -0,0 +1,34 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/group_media_card"
style="@style/Widget.Signal.CardView.PreferenceRow"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp">
<FrameLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingStart="16dp"
android:paddingTop="12dp"
android:paddingEnd="16dp"
android:paddingBottom="4dp">
<TextView
android:id="@+id/recipient_header_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="start"
tools:text="Read by" />
<org.thoughtcrime.securesms.components.DeliveryStatusView
android:id="@+id/recipient_header_delivery_status"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:iconColor="?attr/conversation_item_sent_text_secondary_color"
android:layout_gravity="end|center_vertical" />
</FrameLayout>
</androidx.cardview.widget.CardView>

View File

@ -85,7 +85,7 @@
<dimen name="quote_thumb_size">60dp</dimen>
<integer name="media_overview_cols">3</integer>
<dimen name="message_details_table_row_pad">10dp</dimen>
<dimen name="message_details_table_row_pad">8dp</dimen>
<dimen name="sticker_page_item_padding">8dp</dimen>
<dimen name="sticker_page_item_divisor">88dp</dimen>

View File

@ -1686,6 +1686,14 @@
<string name="message_details_header__from">From:</string>
<string name="message_details_header__with">With:</string>
<!-- message_details_recipient_header -->
<string name="message_details_recipient_header__pending_send">Pending</string>
<string name="message_details_recipient_header__sent_to">Sent to</string>
<string name="message_details_recipient_header__sent_from">Sent from</string>
<string name="message_details_recipient_header__delivered_to">Delivered to</string>
<string name="message_details_recipient_header__read_by">Read by</string>
<string name="message_details_recipient_header__not_sent">Not sent</string>
<!-- AndroidManifest.xml -->
<string name="AndroidManifest__create_passphrase">Create passphrase</string>
<string name="AndroidManifest__select_contacts">Select contacts</string>