Refresh Message Details screen.
parent
dfb5562142
commit
d9641128a8
|
@ -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"/>
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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)));
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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("");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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" />
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
Loading…
Reference in New Issue