Display failure state in story info and other places.

This commit is contained in:
Alex Hart 2022-09-21 16:57:20 -03:00 committed by Cody Henthorne
parent 25c0dc801f
commit ea3fb774f8
13 changed files with 233 additions and 40 deletions

View file

@ -10,13 +10,14 @@ import org.thoughtcrime.securesms.R
object StoryDialogs {
fun resendStory(context: Context, resend: () -> Unit) {
MaterialAlertDialogBuilder(context)
.setMessage(R.string.StoryDialogs__story_could_not_be_sent)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.StoryDialogs__send) { _, _ -> resend() }
.show()
}
fun resendStory(context: Context, onDismiss: () -> Unit = {}, resend: () -> Unit) {
MaterialAlertDialogBuilder(context)
.setMessage(R.string.StoryDialogs__story_could_not_be_sent)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.StoryDialogs__send) { _, _ -> resend() }
.setOnDismissListener { onDismiss() }
.show()
}
fun displayStoryOrProfileImage(
context: Context,

View file

@ -72,9 +72,20 @@ object MyStoriesItem {
val oldRecord = distributionStory.messageRecord
val newRecord = newItem.distributionStory.messageRecord
val oldRecordHasIdentityMismatch = distributionStory.messageRecord.identityKeyMismatches.isNotEmpty()
val newRecordHasIdentityMismatch = newItem.distributionStory.messageRecord.identityKeyMismatches.isNotEmpty()
val oldRecordHasNetworkFailures = distributionStory.messageRecord.hasNetworkFailures()
val newRecordHasNetworkFailures = newItem.distributionStory.messageRecord.hasNetworkFailures()
return oldRecord.isOutgoing &&
newRecord.isOutgoing &&
(oldRecord.isPending != newRecord.isPending || oldRecord.isSent != newRecord.isSent || oldRecord.isFailed != newRecord.isFailed)
(
oldRecord.isPending != newRecord.isPending ||
oldRecord.isSent != newRecord.isSent ||
oldRecord.isFailed != newRecord.isFailed ||
oldRecordHasIdentityMismatch != newRecordHasIdentityMismatch ||
oldRecordHasNetworkFailures != newRecordHasNetworkFailures
)
}
}
@ -157,6 +168,11 @@ object MyStoriesItem {
date.visible = true
viewCount.setText(R.string.StoriesLandingItem__send_failed)
date.setText(R.string.StoriesLandingItem__tap_to_retry)
} else if (model.distributionStory.messageRecord.isIdentityMismatchFailure) {
errorIndicator.visible = true
date.visible = true
viewCount.setText(R.string.StoriesLandingItem__partially_sent)
date.setText(R.string.StoriesLandingItem__tap_to_retry)
} else {
errorIndicator.visible = false
date.visible = true

View file

@ -58,20 +58,26 @@ class StoryInfoBottomSheetDialogFragment : DSLSettingsBottomSheetFragment() {
)
)
sectionHeaderPref(
title = if (state.isOutgoing) {
R.string.StoryInfoBottomSheetDialogFragment__sent_to
} else {
R.string.StoryInfoBottomSheetDialogFragment__sent_from
}
)
state.recipients.forEach {
customPref(it)
state.sections.map { (section, recipients) ->
renderSection(section, recipients)
}
}
}
private fun DSLConfiguration.renderSection(sectionKey: StoryInfoState.SectionKey, recipients: List<StoryInfoRecipientRow.Model>) {
sectionHeaderPref(
title = when (sectionKey) {
StoryInfoState.SectionKey.FAILED -> R.string.StoryInfoBottomSheetDialogFragment__failed
StoryInfoState.SectionKey.SENT_TO -> R.string.StoryInfoBottomSheetDialogFragment__sent_to
StoryInfoState.SectionKey.SENT_FROM -> R.string.StoryInfoBottomSheetDialogFragment__sent_from
}
)
recipients.forEach {
customPref(it)
}
}
override fun onDismiss(dialog: DialogInterface) {
super.onDismiss(dialog)
findListener<OnInfoSheetDismissedListener>()?.onInfoSheetDismissed()

View file

@ -23,7 +23,8 @@ object StoryInfoRecipientRow {
class Model(
val recipient: Recipient,
val date: Long,
val status: Int
val status: Int,
val isFailed: Boolean
) : MappingModel<Model> {
override fun areItemsTheSame(newItem: Model): Boolean {
return recipient.id == newItem.recipient.id

View file

@ -8,6 +8,12 @@ data class StoryInfoState(
val receivedMillis: Long = -1L,
val size: Long = -1L,
val isOutgoing: Boolean = false,
val recipients: List<StoryInfoRecipientRow.Model> = emptyList(),
val sections: Map<SectionKey, List<StoryInfoRecipientRow.Model>> = emptyMap(),
val isLoaded: Boolean = false
)
) {
enum class SectionKey {
FAILED,
SENT_TO,
SENT_FROM
}
}

View file

@ -7,8 +7,11 @@ import io.reactivex.rxjava3.core.BackpressureStrategy
import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
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
/**
@ -29,31 +32,47 @@ class StoryInfoViewModel(storyId: Long, repository: StoryInfoRepository = StoryI
receivedMillis = storyInfo.messageRecord.dateReceived,
size = (storyInfo.messageRecord as? MmsMessageRecord)?.let { it.slideDeck.firstSlide?.fileSize } ?: -1L,
isOutgoing = storyInfo.messageRecord.isOutgoing,
recipients = buildRecipients(storyInfo)
sections = buildSections(storyInfo)
)
}
}
private fun buildRecipients(storyInfo: StoryInfoRepository.StoryInfo): List<StoryInfoRecipientRow.Model> {
private fun buildSections(storyInfo: StoryInfoRepository.StoryInfo): Map<StoryInfoState.SectionKey, List<StoryInfoRecipientRow.Model>> {
return if (storyInfo.messageRecord.isOutgoing) {
storyInfo.receiptInfo.map {
storyInfo.receiptInfo.map { groupReceiptInfo ->
StoryInfoRecipientRow.Model(
recipient = Recipient.resolved(it.recipientId),
date = it.timestamp,
status = it.status
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 {
listOf(
StoryInfoRecipientRow.Model(
recipient = storyInfo.messageRecord.individualRecipient,
date = storyInfo.messageRecord.dateSent,
status = -1
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() {
disposables.clear()
}

View file

@ -5,6 +5,7 @@ import android.animation.AnimatorSet
import android.animation.ObjectAnimator
import android.annotation.SuppressLint
import android.content.Context
import android.content.res.ColorStateList
import android.graphics.RenderEffect
import android.graphics.Shader
import android.graphics.drawable.Drawable
@ -14,7 +15,6 @@ import android.os.Build
import android.os.Bundle
import android.text.method.ScrollingMovementMethod
import android.view.GestureDetector
import android.view.GestureDetector.SimpleOnGestureListener
import android.view.MotionEvent
import android.view.ScaleGestureDetector
import android.view.View
@ -24,6 +24,7 @@ import android.widget.TextView
import androidx.cardview.widget.CardView
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.constraintlayout.widget.ConstraintSet
import androidx.core.content.ContextCompat
import androidx.core.os.bundleOf
import androidx.core.view.GestureDetectorCompat
import androidx.core.view.animation.PathInterpolatorCompat
@ -32,9 +33,12 @@ import androidx.fragment.app.DialogFragment
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import com.google.android.material.button.MaterialButton
import com.google.android.material.progressindicator.CircularProgressIndicatorSpec
import com.google.android.material.progressindicator.IndeterminateDrawable
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Observable
import org.signal.core.util.DimensionUnit
import org.signal.core.util.dp
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.animation.AnimationCompleteListener
@ -44,6 +48,7 @@ import org.thoughtcrime.securesms.components.segmentedprogressbar.SegmentedProgr
import org.thoughtcrime.securesms.contacts.avatars.FallbackContactPhoto
import org.thoughtcrime.securesms.contacts.avatars.FallbackPhoto20dp
import org.thoughtcrime.securesms.contacts.avatars.GeneratedContactPhoto
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
import org.thoughtcrime.securesms.conversation.ConversationIntents
import org.thoughtcrime.securesms.conversation.colors.AvatarColor
import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardBottomSheet
@ -57,6 +62,7 @@ import org.thoughtcrime.securesms.mediapreview.VideoControlsDelegate
import org.thoughtcrime.securesms.mms.GlideApp
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.safety.SafetyNumberBottomSheet
import org.thoughtcrime.securesms.stories.StoryFirstTimeNavigationView
import org.thoughtcrime.securesms.stories.StorySlateView
import org.thoughtcrime.securesms.stories.StoryVolumeOverlayView
@ -94,7 +100,8 @@ class StoryViewerPageFragment :
StorySlateView.Callback,
StoryTextPostPreviewFragment.Callback,
StoryFirstTimeNavigationView.Callback,
StoryInfoBottomSheetDialogFragment.OnInfoSheetDismissedListener {
StoryInfoBottomSheetDialogFragment.OnInfoSheetDismissedListener,
SafetyNumberBottomSheet.Callbacks {
private val storyVolumeViewModel: StoryVolumeViewModel by viewModels(ownerProducer = { requireActivity() })
@ -140,6 +147,8 @@ class StoryViewerPageFragment :
private val lifecycleDisposable = LifecycleDisposable()
private val timeoutDisposable = LifecycleDisposable()
private var sendingProgressDrawable: IndeterminateDrawable<CircularProgressIndicatorSpec>? = null
private val storyRecipientId: RecipientId
get() = requireArguments().getParcelable(ARG_STORY_RECIPIENT_ID)!!
@ -374,7 +383,7 @@ class StoryViewerPageFragment :
if (state.posts.isNotEmpty() && state.selectedPostIndex in state.posts.indices) {
val post = state.posts[state.selectedPostIndex]
presentViewsAndReplies(post, state.replyState, state.isReceiptsEnabled)
presentBottomBar(post, state.replyState, state.isReceiptsEnabled)
presentSenderAvatar(senderAvatar, post)
presentGroupAvatar(groupAvatar, post)
presentFrom(from, post)
@ -649,6 +658,15 @@ class StoryViewerPageFragment :
isFromNotification,
groupReplyStartPosition
)
StoryViewerPageState.ReplyState.PARTIAL_SEND -> {
handleResend(storyPost)
return
}
StoryViewerPageState.ReplyState.SEND_FAILURE -> {
handleResend(storyPost)
return
}
StoryViewerPageState.ReplyState.SENDING -> return
}
if (viewModel.getSwipeToReplyState() == StoryViewerPageState.ReplyState.PRIVATE) {
@ -660,6 +678,19 @@ class StoryViewerPageFragment :
replyFragment.showNow(childFragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
}
private fun handleResend(storyPost: StoryPost) {
viewModel.setIsDisplayingPartialSendDialog(true)
if (storyPost.conversationMessage.messageRecord.isIdentityMismatchFailure) {
SafetyNumberBottomSheet
.forMessageRecord(requireContext(), storyPost.conversationMessage.messageRecord)
.show(childFragmentManager)
} else {
StoryDialogs.resendStory(requireContext(), { viewModel.setIsDisplayingPartialSendDialog(false) }) {
lifecycleDisposable += viewModel.resend(storyPost).subscribe()
}
}
}
private fun showInfo(storyPost: StoryPost) {
viewModel.setIsDisplayingInfoDialog(true)
StoryInfoBottomSheetDialogFragment.create(storyPost.id).show(childFragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
@ -889,7 +920,7 @@ class StoryViewerPageFragment :
}
}
private fun presentViewsAndReplies(post: StoryPost, replyState: StoryViewerPageState.ReplyState, isReceiptsEnabled: Boolean) {
private fun presentBottomBar(post: StoryPost, replyState: StoryViewerPageState.ReplyState, isReceiptsEnabled: Boolean) {
if (replyState == StoryViewerPageState.ReplyState.NONE) {
viewsAndReplies.visible = false
return
@ -897,6 +928,51 @@ class StoryViewerPageFragment :
viewsAndReplies.visible = true
}
viewsAndReplies.iconTint = ColorStateList.valueOf(ContextCompat.getColor(requireContext(), R.color.signal_colorOnSurface))
when (replyState) {
StoryViewerPageState.ReplyState.SENDING -> presentSendingBottomBar()
StoryViewerPageState.ReplyState.PARTIAL_SEND -> presentPartialSendBottomBar()
StoryViewerPageState.ReplyState.SEND_FAILURE -> presentSendFailureBottomBar()
else -> presentViewsAndRepliesBottomBar(post, isReceiptsEnabled)
}
}
private fun presentSendingBottomBar() {
if (sendingProgressDrawable == null) {
sendingProgressDrawable = IndeterminateDrawable.createCircularDrawable(
requireContext(),
CircularProgressIndicatorSpec(requireContext(), null).apply {
indicatorSize = 18.dp
indicatorInset = 2.dp
trackColor = ContextCompat.getColor(requireContext(), R.color.transparent_white_40)
indicatorColors = intArrayOf(ContextCompat.getColor(requireContext(), R.color.signal_dark_colorNeutralInverse))
trackThickness = 2.dp
}
)
}
viewsAndReplies.icon = sendingProgressDrawable
viewsAndReplies.iconGravity = MaterialButton.ICON_GRAVITY_TEXT_START
viewsAndReplies.iconSize = 20.dp
viewsAndReplies.setText(R.string.StoriesLandingItem__sending)
}
private fun presentPartialSendBottomBar() {
viewsAndReplies.setIconResource(R.drawable.ic_error_outline_24)
viewsAndReplies.iconTint = ColorStateList.valueOf(ContextCompat.getColor(requireContext(), R.color.signal_light_colorError))
viewsAndReplies.iconSize = 20.dp
viewsAndReplies.setText(R.string.StoryViewerPageFragment__partially_sent)
}
private fun presentSendFailureBottomBar() {
viewsAndReplies.setIconResource(R.drawable.ic_error_outline_24)
viewsAndReplies.iconTint = ColorStateList.valueOf(ContextCompat.getColor(requireContext(), R.color.signal_light_colorError))
viewsAndReplies.iconSize = 20.dp
viewsAndReplies.setText(R.string.StoryViewerPageFragment__send_failed)
}
private fun presentViewsAndRepliesBottomBar(post: StoryPost, isReceiptsEnabled: Boolean) {
val views = resources.getQuantityString(R.plurals.StoryViewerFragment__d_views, post.viewCount, post.viewCount)
val replies = resources.getQuantityString(R.plurals.StoryViewerFragment__d_replies, post.replyCount, post.replyCount)
@ -1280,4 +1356,16 @@ class StoryViewerPageFragment :
override fun onInfoSheetDismissed() {
viewModel.setIsDisplayingInfoDialog(false)
}
override fun sendAnywayAfterSafetyNumberChangedInBottomSheet(destinations: List<ContactSearchKey.RecipientSearchKey>) {
error("Not supported, we handed a message record to the bottom sheet.")
}
override fun onMessageResentAfterSafetyNumberChangeInBottomSheet() {
viewModel.setIsDisplayingPartialSendDialog(false)
}
override fun onCanceled() {
viewModel.setIsDisplayingPartialSendDialog(false)
}
}

View file

@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.stories.viewer.page
import android.content.Context
import android.net.Uri
import androidx.annotation.CheckResult
import io.reactivex.rxjava3.core.Completable
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.schedulers.Schedulers
@ -22,6 +23,7 @@ import org.thoughtcrime.securesms.jobs.SendViewedReceiptJob
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.sms.MessageSender
import org.thoughtcrime.securesms.stories.Stories
import org.thoughtcrime.securesms.util.Base64
import org.thoughtcrime.securesms.util.TextSecurePreferences
@ -194,6 +196,13 @@ open class StoryViewerPageRepository(context: Context) {
}
}
@CheckResult
fun resend(messageRecord: MessageRecord): Completable {
return Completable.fromAction {
MessageSender.resend(ApplicationDependencies.getApplication(), messageRecord)
}.subscribeOn(Schedulers.io())
}
private fun getContent(record: MmsMessageRecord): StoryPost.Content {
return if (record.storyType.isTextStory || record.slideDeck.asAttachments().isEmpty()) {
StoryPost.Content.TextContent(

View file

@ -36,7 +36,22 @@ data class StoryViewerPageState(
/**
* Story is from self and in a group
*/
GROUP_SELF;
GROUP_SELF,
/**
* Story was not sent to all recipients.
*/
PARTIAL_SEND,
/**
* Story failed to send.
*/
SEND_FAILURE,
/**
* Story is currently being sent.
*/
SENDING;
companion object {
fun resolve(isFromSelf: Boolean, isToGroup: Boolean): ReplyState {

View file

@ -1,8 +1,10 @@
package org.thoughtcrime.securesms.stories.viewer.page
import androidx.annotation.CheckResult
import androidx.lifecycle.LiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Completable
import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.core.Observable
@ -266,16 +268,27 @@ class StoryViewerPageViewModel(
storyViewerPlaybackStore.update { it.copy(isUserScaling = isUserScaling) }
}
fun setIsDisplayingPartialSendDialog(isDisplayingPartialSendDialog: Boolean) {
storyViewerPlaybackStore.update { it.copy(isDisplayingPartialSendDialog = isDisplayingPartialSendDialog) }
}
private fun resolveSwipeToReplyState(state: StoryViewerPageState, index: Int): StoryViewerPageState.ReplyState {
if (index !in state.posts.indices) {
return StoryViewerPageState.ReplyState.NONE
}
val post = state.posts[index]
val message = post.conversationMessage.messageRecord
val isFromSelf = post.sender.isSelf
val isToGroup = post.group != null
val isFailed = message.isFailed
val isPartialSend = message.isIdentityMismatchFailure
val isInProgress = !post.conversationMessage.messageRecord.isSent
return when {
isFromSelf && isPartialSend -> StoryViewerPageState.ReplyState.PARTIAL_SEND
isFromSelf && isFailed -> StoryViewerPageState.ReplyState.SEND_FAILURE
isFromSelf && isInProgress -> StoryViewerPageState.ReplyState.SENDING
post.allowsReplies -> StoryViewerPageState.ReplyState.resolve(isFromSelf, isToGroup)
isFromSelf -> StoryViewerPageState.ReplyState.SELF
else -> StoryViewerPageState.ReplyState.NONE
@ -290,6 +303,13 @@ class StoryViewerPageViewModel(
return store.state.posts.getOrNull(index)
}
@CheckResult
fun resend(storyPost: StoryPost): Completable {
return repository
.resend(storyPost.conversationMessage.messageRecord)
.observeOn(AndroidSchedulers.mainThread())
}
class Factory(
private val recipientId: RecipientId,
private val initialStoryId: Long,

View file

@ -21,7 +21,8 @@ data class StoryViewerPlaybackState(
val isDisplayingInfoDialog: Boolean = false,
val isUserLongTouching: Boolean = false,
val isUserScrollingChild: Boolean = false,
val isUserScaling: Boolean = false
val isUserScaling: Boolean = false,
val isDisplayingPartialSendDialog: Boolean = false
) {
val hideChromeImmediate: Boolean = isRunningSharedElementAnimation
@ -49,5 +50,6 @@ data class StoryViewerPlaybackState(
isDisplayingFirstTimeNavigation ||
isDisplayingInfoDialog ||
isUserScaling ||
isDisplayingHideDialog
isDisplayingHideDialog ||
isDisplayingPartialSendDialog
}

View file

@ -4756,6 +4756,10 @@
<string name="StoryViewerPageFragment__s_to_s">%1$s to %2$s</string>
<!-- Displayed when viewing a post from another user with no replies -->
<string name="StoryViewerPageFragment__reply">Reply</string>
<!-- Displayed when viewing a post that has failed to send to some users -->
<string name="StoryViewerPageFragment__partially_sent">Partially sent. Tap for details</string>
<!-- Displayed when viewing a post that has failed to send -->
<string name="StoryViewerPageFragment__send_failed">Send failed. Tap to retry</string>
<!-- Label for the reply button in story viewer, which will launch the group story replies bottom sheet. -->
<string name="StoryViewerPageFragment__reply_to_group">Reply to group</string>
<!-- Displayed when a story has no views -->
@ -5162,6 +5166,8 @@
<string name="StoryInfoBottomSheetDialogFragment__sent_to">Sent to</string>
<!-- Story info "Sent from" header -->
<string name="StoryInfoBottomSheetDialogFragment__sent_from">Sent from</string>
<!-- Story info "Failed" header -->
<string name="StoryInfoBottomSheetDialogFragment__failed">Failed</string>
<!-- Story Info context menu label -->
<string name="StoryInfoBottomSheetDialogFragment__info">Info</string>

View file

@ -14,6 +14,7 @@ import org.mockito.kotlin.mock
import org.mockito.kotlin.whenever
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.thoughtcrime.securesms.database.FakeMessageRecords
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
@ -186,7 +187,10 @@ class StoryViewerPageViewModelTest {
conversationMessage = mock(),
allowsReplies = true,
hasSelfViewed = isViewed(it)
)
).apply {
val messageRecord = FakeMessageRecords.buildMediaMmsMessageRecord()
whenever(conversationMessage.messageRecord).thenReturn(messageRecord)
}
}
}
}