Story info page should mirror message details.

This commit is contained in:
Alex Hart 2022-10-06 15:44:48 -03:00 committed by Greyson Parrelli
parent 742d1bece0
commit 2041756513
8 changed files with 122 additions and 174 deletions

View file

@ -12,7 +12,7 @@ import java.util.Comparator;
import java.util.List; import java.util.List;
import java.util.TreeSet; import java.util.TreeSet;
final class MessageDetails { public 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> 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> 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 static final Comparator<RecipientDeliveryStatus> RECIPIENT_COMPARATOR = ComparatorCompat.chain(HAS_DISPLAY_NAME).thenComparing(ALPHABETICAL);
@ -71,33 +71,33 @@ final class MessageDetails {
} }
} }
@NonNull ConversationMessage getConversationMessage() { public @NonNull ConversationMessage getConversationMessage() {
return conversationMessage; return conversationMessage;
} }
@NonNull Collection<RecipientDeliveryStatus> getPending() { public @NonNull Collection<RecipientDeliveryStatus> getPending() {
return pending; return pending;
} }
@NonNull Collection<RecipientDeliveryStatus> getSent() { public @NonNull Collection<RecipientDeliveryStatus> getSent() {
return sent; return sent;
} }
@NonNull Collection<RecipientDeliveryStatus> getSkipped() {return skipped;} public @NonNull Collection<RecipientDeliveryStatus> getSkipped() {return skipped;}
@NonNull Collection<RecipientDeliveryStatus> getDelivered() { public @NonNull Collection<RecipientDeliveryStatus> getDelivered() {
return delivered; return delivered;
} }
@NonNull Collection<RecipientDeliveryStatus> getRead() { public @NonNull Collection<RecipientDeliveryStatus> getRead() {
return read; return read;
} }
@NonNull Collection<RecipientDeliveryStatus> getNotSent() { public @NonNull Collection<RecipientDeliveryStatus> getNotSent() {
return notSent; return notSent;
} }
@NonNull Collection<RecipientDeliveryStatus> getViewed() { public @NonNull Collection<RecipientDeliveryStatus> getViewed() {
return viewed; return viewed;
} }
} }

View file

@ -10,9 +10,11 @@ import androidx.lifecycle.MutableLiveData;
import org.signal.core.util.concurrent.SignalExecutors; import org.signal.core.util.concurrent.SignalExecutors;
import org.thoughtcrime.securesms.conversation.ConversationMessage.ConversationMessageFactory; import org.thoughtcrime.securesms.conversation.ConversationMessage.ConversationMessageFactory;
import org.thoughtcrime.securesms.database.DatabaseObserver;
import org.thoughtcrime.securesms.database.GroupDatabase; import org.thoughtcrime.securesms.database.GroupDatabase;
import org.thoughtcrime.securesms.database.GroupReceiptDatabase; import org.thoughtcrime.securesms.database.GroupReceiptDatabase;
import org.thoughtcrime.securesms.database.MmsSmsDatabase; import org.thoughtcrime.securesms.database.MmsSmsDatabase;
import org.thoughtcrime.securesms.database.NoSuchMessageException;
import org.thoughtcrime.securesms.database.SignalDatabase; import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch; import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch;
import org.thoughtcrime.securesms.database.documents.NetworkFailure; import org.thoughtcrime.securesms.database.documents.NetworkFailure;
@ -24,7 +26,10 @@ import org.thoughtcrime.securesms.recipients.Recipient;
import java.util.LinkedList; import java.util.LinkedList;
import java.util.List; import java.util.List;
final class MessageDetailsRepository { import io.reactivex.rxjava3.core.Observable;
import io.reactivex.rxjava3.schedulers.Schedulers;
public final class MessageDetailsRepository {
private final Context context = ApplicationDependencies.getApplication(); private final Context context = ApplicationDependencies.getApplication();
@ -44,11 +49,33 @@ final class MessageDetailsRepository {
return liveData; return liveData;
} }
public @NonNull Observable<MessageDetails> getMessageDetails(@NonNull MessageId messageId) {
return Observable.<MessageDetails>create(emitter -> {
DatabaseObserver.MessageObserver messageObserver = mId -> {
try {
MessageRecord messageRecord = messageId.isMms() ? SignalDatabase.mms().getMessageRecord(messageId.getId())
: SignalDatabase.sms().getMessageRecord(messageId.getId());
MessageDetails messageDetails = getRecipientDeliveryStatusesInternal(messageRecord);
emitter.onNext(messageDetails);
} catch (NoSuchMessageException e) {
emitter.onError(e);
}
};
ApplicationDependencies.getDatabaseObserver().registerMessageUpdateObserver(messageObserver);
emitter.setCancellable(() -> ApplicationDependencies.getDatabaseObserver().unregisterObserver(messageObserver));
messageObserver.onMessageChanged(messageId);
}).observeOn(Schedulers.io());
}
@WorkerThread @WorkerThread
private @NonNull MessageDetails getRecipientDeliveryStatusesInternal(@NonNull MessageRecord messageRecord) { private @NonNull MessageDetails getRecipientDeliveryStatusesInternal(@NonNull MessageRecord messageRecord) {
List<RecipientDeliveryStatus> recipients = new LinkedList<>(); List<RecipientDeliveryStatus> recipients = new LinkedList<>();
if (!messageRecord.getRecipient().isGroup()) { if (!messageRecord.getRecipient().isGroup() && !messageRecord.getRecipient().isDistributionList()) {
recipients.add(new RecipientDeliveryStatus(messageRecord, recipients.add(new RecipientDeliveryStatus(messageRecord,
messageRecord.getRecipient(), messageRecord.getRecipient(),
getStatusFor(messageRecord), getStatusFor(messageRecord),

View file

@ -8,7 +8,7 @@ import org.thoughtcrime.securesms.database.documents.NetworkFailure;
import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.Recipient;
final class RecipientDeliveryStatus { public final class RecipientDeliveryStatus {
enum Status { enum Status {
UNKNOWN, PENDING, SENT, DELIVERED, READ, VIEWED, SKIPPED, UNKNOWN, PENDING, SENT, DELIVERED, READ, VIEWED, SKIPPED,
@ -32,31 +32,31 @@ final class RecipientDeliveryStatus {
this.keyMismatchFailure = keyMismatchFailure; this.keyMismatchFailure = keyMismatchFailure;
} }
@NonNull MessageRecord getMessageRecord() { public @NonNull MessageRecord getMessageRecord() {
return messageRecord; return messageRecord;
} }
@NonNull Status getDeliveryStatus() { public @NonNull Status getDeliveryStatus() {
return deliveryStatus; return deliveryStatus;
} }
boolean isUnidentified() { public boolean isUnidentified() {
return isUnidentified; return isUnidentified;
} }
long getTimestamp() { public long getTimestamp() {
return timestamp; return timestamp;
} }
@NonNull Recipient getRecipient() { public @NonNull Recipient getRecipient() {
return recipient; return recipient;
} }
@Nullable NetworkFailure getNetworkFailure() { public @Nullable NetworkFailure getNetworkFailure() {
return networkFailure; return networkFailure;
} }
@Nullable IdentityKeyMismatch getKeyMismatchFailure() { public @Nullable IdentityKeyMismatch getKeyMismatchFailure() {
return keyMismatchFailure; return keyMismatchFailure;
} }
} }

View file

@ -1,12 +1,14 @@
package org.thoughtcrime.securesms.stories.viewer.info package org.thoughtcrime.securesms.stories.viewer.info
import android.content.DialogInterface import android.content.DialogInterface
import androidx.annotation.StringRes
import androidx.core.os.bundleOf import androidx.core.os.bundleOf
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.DSLConfiguration import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
import org.thoughtcrime.securesms.components.settings.DSLSettingsBottomSheetFragment import org.thoughtcrime.securesms.components.settings.DSLSettingsBottomSheetFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.configure import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.util.LifecycleDisposable import org.thoughtcrime.securesms.util.LifecycleDisposable
import org.thoughtcrime.securesms.util.fragments.findListener import org.thoughtcrime.securesms.util.fragments.findListener
@ -58,23 +60,61 @@ class StoryInfoBottomSheetDialogFragment : DSLSettingsBottomSheetFragment() {
) )
) )
state.sections.map { (section, recipients) -> val details = state.messageDetails!!
renderSection(section, recipients)
if (state.isOutgoing) {
renderSection(
title = R.string.message_details_recipient_header__not_sent,
recipients = details.notSent.map { StoryInfoRecipientRow.Model(it) }
)
renderSection(
title = R.string.message_details_recipient_header__viewed,
recipients = details.viewed.map { StoryInfoRecipientRow.Model(it) }
)
renderSection(
title = R.string.message_details_recipient_header__read_by,
recipients = details.read.map { StoryInfoRecipientRow.Model(it) }
)
renderSection(
title = R.string.message_details_recipient_header__delivered_to,
recipients = details.delivered.map { StoryInfoRecipientRow.Model(it) }
)
renderSection(
title = R.string.message_details_recipient_header__sent_to,
recipients = details.sent.map { StoryInfoRecipientRow.Model(it) }
)
renderSection(
title = R.string.message_details_recipient_header__pending_send,
recipients = details.pending.map { StoryInfoRecipientRow.Model(it) }
)
renderSection(
title = R.string.message_details_recipient_header__skipped,
recipients = details.skipped.map { StoryInfoRecipientRow.Model(it) }
)
} else {
renderSection(
title = R.string.message_details_recipient_header__sent_from,
recipients = details.sent.map { StoryInfoRecipientRow.Model(it) }
)
} }
} }
} }
private fun DSLConfiguration.renderSection(sectionKey: StoryInfoState.SectionKey, recipients: List<StoryInfoRecipientRow.Model>) { private fun DSLConfiguration.renderSection(@StringRes title: Int, recipients: List<StoryInfoRecipientRow.Model>) {
sectionHeaderPref( if (recipients.isNotEmpty()) {
title = when (sectionKey) { sectionHeaderPref(
StoryInfoState.SectionKey.FAILED -> R.string.StoryInfoBottomSheetDialogFragment__failed title = DSLSettingsText.from(title)
StoryInfoState.SectionKey.SENT_TO -> R.string.StoryInfoBottomSheetDialogFragment__sent_to )
StoryInfoState.SectionKey.SENT_FROM -> R.string.StoryInfoBottomSheetDialogFragment__sent_from
}
)
recipients.forEach { recipients.forEach {
customPref(it) customPref(it)
}
} }
} }

View file

@ -4,7 +4,7 @@ import android.view.View
import android.widget.TextView import android.widget.TextView
import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.AvatarImageView import org.thoughtcrime.securesms.components.AvatarImageView
import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.messagedetails.RecipientDeliveryStatus
import org.thoughtcrime.securesms.util.DateUtils import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
@ -21,17 +21,15 @@ object StoryInfoRecipientRow {
} }
class Model( class Model(
val recipient: Recipient, val recipientDeliveryStatus: RecipientDeliveryStatus
val date: Long,
val status: Int,
val isFailed: Boolean
) : MappingModel<Model> { ) : MappingModel<Model> {
override fun areItemsTheSame(newItem: Model): Boolean { override fun areItemsTheSame(newItem: Model): Boolean {
return recipient.id == newItem.recipient.id return recipientDeliveryStatus.recipient.id == newItem.recipientDeliveryStatus.recipient.id
} }
override fun areContentsTheSame(newItem: Model): Boolean { override fun areContentsTheSame(newItem: Model): Boolean {
return recipient.hasSameContent(newItem.recipient) && date == newItem.date return recipientDeliveryStatus.recipient.hasSameContent(newItem.recipientDeliveryStatus.recipient) &&
recipientDeliveryStatus.timestamp == newItem.recipientDeliveryStatus.timestamp
} }
} }
@ -42,9 +40,9 @@ object StoryInfoRecipientRow {
private val timestampView: TextView = itemView.findViewById(R.id.story_info_timestamp) private val timestampView: TextView = itemView.findViewById(R.id.story_info_timestamp)
override fun bind(model: Model) { override fun bind(model: Model) {
avatarView.setRecipient(model.recipient) avatarView.setRecipient(model.recipientDeliveryStatus.recipient)
nameView.text = model.recipient.getDisplayName(context) nameView.text = model.recipientDeliveryStatus.recipient.getDisplayName(context)
timestampView.text = DateUtils.getTimeString(context, Locale.getDefault(), model.date) timestampView.text = DateUtils.getTimeString(context, Locale.getDefault(), model.recipientDeliveryStatus.timestamp)
} }
} }
} }

View file

@ -1,73 +0,0 @@
package org.thoughtcrime.securesms.stories.viewer.info
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.database.DatabaseObserver
import org.thoughtcrime.securesms.database.GroupReceiptDatabase
import org.thoughtcrime.securesms.database.NoSuchMessageException
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
/**
* Gathers necessary message record and receipt data for a given story id.
*/
class StoryInfoRepository {
companion object {
private val TAG = Log.tag(StoryInfoRepository::class.java)
}
/**
* Retrieves the StoryInfo for a given ID and emits a new item whenever the underlying
* message record changes.
*/
fun getStoryInfo(storyId: Long): Observable<StoryInfo> {
return observeMessageRecord(storyId)
.switchMap { record ->
getReceiptInfo(storyId).map { receiptInfo ->
StoryInfo(record, receiptInfo)
}.toObservable()
}
.subscribeOn(Schedulers.io())
}
private fun observeMessageRecord(storyId: Long): Observable<MessageRecord> {
return Observable.create { emitter ->
fun refresh() {
try {
emitter.onNext(SignalDatabase.mms.getMessageRecord(storyId))
} catch (e: NoSuchMessageException) {
Log.w(TAG, "The story message disappeared. Terminating emission.")
emitter.onComplete()
}
}
val observer = DatabaseObserver.MessageObserver {
if (it.mms && it.id == storyId) {
refresh()
}
}
ApplicationDependencies.getDatabaseObserver().registerMessageUpdateObserver(observer)
emitter.setCancellable {
ApplicationDependencies.getDatabaseObserver().unregisterObserver(observer)
}
refresh()
}
}
private fun getReceiptInfo(storyId: Long): Single<List<GroupReceiptDatabase.GroupReceiptInfo>> {
return Single.fromCallable {
SignalDatabase.groupReceipts.getGroupReceiptInfo(storyId)
}
}
/**
* The message record and receipt info for a given story id.
*/
data class StoryInfo(val messageRecord: MessageRecord, val receiptInfo: List<GroupReceiptDatabase.GroupReceiptInfo>)
}

View file

@ -1,19 +1,19 @@
package org.thoughtcrime.securesms.stories.viewer.info package org.thoughtcrime.securesms.stories.viewer.info
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord
import org.thoughtcrime.securesms.messagedetails.MessageDetails
/** /**
* Contains the needed information to render the story info sheet. * Contains the needed information to render the story info sheet.
*/ */
data class StoryInfoState( data class StoryInfoState(
val sentMillis: Long = -1L, val messageDetails: MessageDetails? = null
val receivedMillis: Long = -1L,
val size: Long = -1L,
val isOutgoing: Boolean = false,
val sections: Map<SectionKey, List<StoryInfoRecipientRow.Model>> = emptyMap(),
val isLoaded: Boolean = false
) { ) {
enum class SectionKey { private val mediaMessage = messageDetails?.conversationMessage?.messageRecord as? MediaMmsMessageRecord
FAILED,
SENT_TO, val sentMillis: Long = mediaMessage?.dateSent ?: -1L
SENT_FROM val receivedMillis: Long = mediaMessage?.dateReceived ?: -1L
} val size: Long = mediaMessage?.slideDeck?.thumbnailSlide?.fileSize ?: 0
val isOutgoing: Boolean = mediaMessage?.isOutgoing ?: false
val isLoaded: Boolean = mediaMessage != null
} }

View file

@ -7,17 +7,14 @@ import io.reactivex.rxjava3.core.BackpressureStrategy
import io.reactivex.rxjava3.core.Flowable import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.disposables.CompositeDisposable import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign import io.reactivex.rxjava3.kotlin.plusAssign
import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.MessageId
import org.thoughtcrime.securesms.database.model.MmsMessageRecord import org.thoughtcrime.securesms.messagedetails.MessageDetailsRepository
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.rx.RxStore import org.thoughtcrime.securesms.util.rx.RxStore
/** /**
* Gathers and stores the StoryInfoState which is used to render the story info sheet. * Gathers and stores the StoryInfoState which is used to render the story info sheet.
*/ */
class StoryInfoViewModel(storyId: Long, repository: StoryInfoRepository = StoryInfoRepository()) : ViewModel() { class StoryInfoViewModel(storyId: Long, repository: MessageDetailsRepository = MessageDetailsRepository()) : ViewModel() {
private val store = RxStore(StoryInfoState()) private val store = RxStore(StoryInfoState())
private val disposables = CompositeDisposable() private val disposables = CompositeDisposable()
@ -25,54 +22,13 @@ class StoryInfoViewModel(storyId: Long, repository: StoryInfoRepository = StoryI
val state: Flowable<StoryInfoState> = store.stateFlowable.observeOn(AndroidSchedulers.mainThread()) val state: Flowable<StoryInfoState> = store.stateFlowable.observeOn(AndroidSchedulers.mainThread())
init { init {
disposables += store.update(repository.getStoryInfo(storyId).toFlowable(BackpressureStrategy.LATEST)) { storyInfo, storyInfoState -> disposables += store.update(repository.getMessageDetails(MessageId(storyId, true)).toFlowable(BackpressureStrategy.LATEST)) { messageDetails, storyInfoState ->
storyInfoState.copy( storyInfoState.copy(
isLoaded = true, messageDetails = messageDetails
sentMillis = storyInfo.messageRecord.dateSent,
receivedMillis = storyInfo.messageRecord.dateReceived,
size = (storyInfo.messageRecord as? MmsMessageRecord)?.let { it.slideDeck.firstSlide?.fileSize } ?: -1L,
isOutgoing = storyInfo.messageRecord.isOutgoing,
sections = buildSections(storyInfo)
) )
} }
} }
private fun buildSections(storyInfo: StoryInfoRepository.StoryInfo): Map<StoryInfoState.SectionKey, List<StoryInfoRecipientRow.Model>> {
return if (storyInfo.messageRecord.isOutgoing) {
storyInfo.receiptInfo.map { groupReceiptInfo ->
StoryInfoRecipientRow.Model(
recipient = Recipient.resolved(groupReceiptInfo.recipientId),
date = groupReceiptInfo.timestamp,
status = groupReceiptInfo.status,
isFailed = hasFailure(storyInfo.messageRecord, groupReceiptInfo.recipientId)
)
}.groupBy {
when {
it.isFailed -> StoryInfoState.SectionKey.FAILED
else -> StoryInfoState.SectionKey.SENT_TO
}
}
} else {
mapOf(
StoryInfoState.SectionKey.SENT_FROM to listOf(
StoryInfoRecipientRow.Model(
recipient = storyInfo.messageRecord.individualRecipient,
date = storyInfo.messageRecord.dateSent,
status = -1,
isFailed = false
)
)
)
}
}
private fun hasFailure(messageRecord: MessageRecord, recipientId: RecipientId): Boolean {
val hasNetworkFailure = messageRecord.networkFailures.any { it.getRecipientId(ApplicationDependencies.getApplication()) == recipientId }
val hasIdentityFailure = messageRecord.identityKeyMismatches.any { it.getRecipientId(ApplicationDependencies.getApplication()) == recipientId }
return hasNetworkFailure || hasIdentityFailure
}
override fun onCleared() { override fun onCleared() {
disposables.clear() disposables.clear()
store.dispose() store.dispose()