Basic Message Failure support + Resend (text only)

+ clean worker inputs when starting new independent task in unique queue
This commit is contained in:
Valere 2019-07-26 16:02:20 +02:00
parent 2a16c36a59
commit 000db4b192
42 changed files with 661 additions and 158 deletions

View File

@ -7,6 +7,7 @@ Features:
Improvements:
- UI for pending edits (#193)
- UX image preview screen transition (#393)
- Basic support for resending failed messages (retry/remove)

Other changes:
-

View File

@ -20,6 +20,9 @@ import android.text.TextUtils
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import im.vector.matrix.android.api.session.crypto.MXCryptoError
import im.vector.matrix.android.api.session.room.model.message.MessageContent
import im.vector.matrix.android.api.session.room.model.message.MessageType
import im.vector.matrix.android.api.session.room.send.SendState
import im.vector.matrix.android.api.util.JsonDict
import im.vector.matrix.android.internal.crypto.algorithms.olm.OlmDecryptionResult
import im.vector.matrix.android.internal.di.MoshiProvider
@ -81,6 +84,7 @@ data class Event(

var mxDecryptionResult: OlmDecryptionResult? = null
var mCryptoError: MXCryptoError.ErrorType? = null
var sendState: SendState = SendState.UNKNOWN


/**
@ -272,6 +276,7 @@ data class Event(
if (redacts != other.redacts) return false
if (mxDecryptionResult != other.mxDecryptionResult) return false
if (mCryptoError != other.mCryptoError) return false
if (sendState != other.sendState) return false

return true
}
@ -289,6 +294,39 @@ data class Event(
result = 31 * result + (redacts?.hashCode() ?: 0)
result = 31 * result + (mxDecryptionResult?.hashCode() ?: 0)
result = 31 * result + (mCryptoError?.hashCode() ?: 0)
result = 31 * result + sendState.hashCode()
return result
}

}


fun Event.isTextMessage(): Boolean {
if (this.getClearType() == EventType.MESSAGE) {
return getClearContent()?.toModel<MessageContent>()?.let {
when (it.type) {
MessageType.MSGTYPE_TEXT,
MessageType.MSGTYPE_EMOTE,
MessageType.MSGTYPE_NOTICE -> {
true
}
else -> false
}
} ?: false
}
return false
}

fun Event.isImageMessage(): Boolean {
if (this.getClearType() == EventType.MESSAGE) {
return getClearContent()?.toModel<MessageContent>()?.let {
when (it.type) {
MessageType.MSGTYPE_IMAGE -> {
true
}
else -> false
}
} ?: false
}
return false
}

View File

@ -19,6 +19,7 @@ package im.vector.matrix.android.api.session.room.send
import im.vector.matrix.android.api.session.content.ContentAttachmentData
import im.vector.matrix.android.api.session.events.model.Event
import im.vector.matrix.android.api.session.room.model.message.MessageType
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.matrix.android.api.util.Cancelable


@ -65,4 +66,31 @@ interface SendService {
*/
fun redactEvent(event: Event, reason: String?): Cancelable


/**
* Schedule this message to be resent
* @param localEcho the unsent local echo
*/
fun resendTextMessage(localEcho: TimelineEvent): Cancelable?

/**
* Schedule this message to be resent
* @param localEcho the unsent local echo
*/
fun resendMediaMessage(localEcho: TimelineEvent): Cancelable?


/**
* Remove this failed message from the timeline
* @param localEcho the unsent local echo
*/
fun deleteFailedEcho(localEcho: TimelineEvent)

fun clearSendingQueue()

/**
* Resend all failed messages one by one (and keep order)
*/
fun resendAllFailedMessages()

}

View File

@ -41,4 +41,8 @@ enum class SendState {
return this == UNDELIVERED || this == FAILED_UNKNOWN_DEVICES
}

fun isSending(): Boolean {
return this == UNSENT || this == ENCRYPTING || this == SENDING
}

}

View File

@ -56,6 +56,9 @@ interface Timeline {
*/
fun paginate(direction: Direction, count: Int)

fun pendingEventCount() : Int

fun failedToDeliverEventCount() : Int

interface Listener {
/**

View File

@ -38,7 +38,6 @@ data class TimelineEvent(
val senderName: String?,
val isUniqueDisplayName: Boolean,
val senderAvatar: String?,
val sendState: SendState,
val annotations: EventAnnotationsSummary? = null
) {


View File

@ -73,6 +73,7 @@ internal object EventMapper {
unsignedData = ud,
redacts = eventEntity.redacts
).also {
it.sendState = eventEntity.sendState
eventEntity.decryptionResultJson?.let { json ->
try {
it.mxDecryptionResult = MoshiProvider.providesMoshi().adapter(OlmDecryptionResult::class.java).fromJson(json)

View File

@ -33,8 +33,7 @@ internal object TimelineEventMapper {
displayIndex = timelineEventEntity.root?.displayIndex ?: 0,
senderName = timelineEventEntity.senderName,
isUniqueDisplayName = timelineEventEntity.isUniqueDisplayName,
senderAvatar = timelineEventEntity.senderAvatar,
sendState = timelineEventEntity.root?.sendState ?: SendState.UNKNOWN
senderAvatar = timelineEventEntity.senderAvatar
)
}


View File

@ -57,9 +57,11 @@ internal class UploadContentWorker(context: Context, params: WorkerParameters) :
override suspend fun doWork(): Result {
val params = WorkerParamsFactory.fromData<Params>(inputData)
?: return Result.success()
Timber.v("Starting upload media work with params $params")

if (params.lastFailureMessage != null) {
// Transmit the error
Timber.v("Stop upload media work due to input failure")
return Result.success(inputData)
}

@ -121,7 +123,11 @@ internal class UploadContentWorker(context: Context, params: WorkerParameters) :

val progressListener = object : ProgressRequestBody.Listener {
override fun onProgress(current: Long, total: Long) {
contentUploadStateTracker.setProgress(eventId, current, total)
if (isStopped) {
contentUploadStateTracker.setFailure(eventId, Throwable("Cancelled"))
} else {
contentUploadStateTracker.setProgress(eventId, current, total)
}
}
}

@ -166,6 +172,7 @@ internal class UploadContentWorker(context: Context, params: WorkerParameters) :
encryptedFileInfo: EncryptedFileInfo?,
thumbnailUrl: String?,
thumbnailEncryptedFileInfo: EncryptedFileInfo?): Result {
Timber.v("handleSuccess $attachmentUrl, work is stopped $isStopped")
contentUploadStateTracker.setSuccess(params.event.eventId!!)
val event = updateEvent(params.event, attachmentUrl, encryptedFileInfo, thumbnailUrl, thumbnailEncryptedFileInfo)
val sendParams = SendEventWorker.Params(params.userId, params.roomId, event)
@ -210,6 +217,7 @@ internal class UploadContentWorker(context: Context, params: WorkerParameters) :
)
}


private fun MessageFileContent.update(url: String,
encryptedFileInfo: EncryptedFileInfo?): MessageFileContent {
return copy(

View File

@ -29,6 +29,7 @@ import im.vector.matrix.android.internal.database.model.TimelineEventEntity
import im.vector.matrix.android.internal.database.query.findWithSenderMembershipEvent
import im.vector.matrix.android.internal.database.query.where
import im.vector.matrix.android.internal.di.MoshiProvider
import im.vector.matrix.android.internal.session.room.send.LocalEchoEventFactory
import im.vector.matrix.android.internal.task.Task
import im.vector.matrix.android.internal.util.tryTransactionSync
import io.realm.Realm
@ -63,7 +64,7 @@ internal class DefaultPruneEventTask @Inject constructor(private val monarchy: M
val redactionEventEntity = EventEntity.where(realm, eventId = redactionEvent.eventId
?: "").findFirst()
?: return
val isLocalEcho = redactionEventEntity.sendState == SendState.UNSENT
val isLocalEcho = LocalEchoEventFactory.isLocalEchoId(redactionEvent.eventId ?: "")
Timber.v("Redact event for ${redactionEvent.redacts} localEcho=$isLocalEcho")

val eventToPrune = EventEntity.where(realm, eventId = redactionEvent.redacts).findFirst()

View File

@ -17,25 +17,36 @@
package im.vector.matrix.android.internal.session.room.send

import android.content.Context
import androidx.work.BackoffPolicy
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequest
import androidx.work.WorkManager
import androidx.work.*
import com.zhuinden.monarchy.Monarchy
import im.vector.matrix.android.api.auth.data.Credentials
import im.vector.matrix.android.api.session.content.ContentAttachmentData
import im.vector.matrix.android.api.session.crypto.CryptoService
import im.vector.matrix.android.api.session.events.model.Event
import im.vector.matrix.android.api.session.events.model.EventType
import im.vector.matrix.android.api.session.events.model.isTextMessage
import im.vector.matrix.android.api.session.events.model.toModel
import im.vector.matrix.android.api.session.room.model.message.MessageContent
import im.vector.matrix.android.api.session.room.model.message.MessageType
import im.vector.matrix.android.api.session.room.send.SendService
import im.vector.matrix.android.api.session.room.send.SendState
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.matrix.android.api.util.Cancelable
import im.vector.matrix.android.api.util.CancelableBag
import im.vector.matrix.android.internal.database.mapper.asDomain
import im.vector.matrix.android.internal.database.model.EventEntity
import im.vector.matrix.android.internal.database.model.RoomEntity
import im.vector.matrix.android.internal.database.model.TimelineEventEntity
import im.vector.matrix.android.internal.database.query.where
import im.vector.matrix.android.internal.session.content.UploadContentWorker
import im.vector.matrix.android.internal.session.room.timeline.TimelineSendEventWorkCommon
import im.vector.matrix.android.internal.util.CancelableWork
import im.vector.matrix.android.internal.util.tryTransactionAsync
import im.vector.matrix.android.internal.worker.WorkManagerUtil
import im.vector.matrix.android.internal.worker.WorkManagerUtil.matrixOneTimeWorkRequestBuilder
import im.vector.matrix.android.internal.worker.WorkerParamsFactory
import timber.log.Timber
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import javax.inject.Inject

@ -50,6 +61,7 @@ internal class DefaultSendService @Inject constructor(private val context: Conte
private val monarchy: Monarchy)
: SendService {

private val workerFutureListenerExecutor = Executors.newSingleThreadExecutor()
override fun sendTextMessage(text: String, msgType: String, autoMarkdown: Boolean): Cancelable {
val event = localEchoEventFactory.createTextEvent(roomId, msgType, text, autoMarkdown).also {
saveLocalEcho(it)
@ -70,7 +82,7 @@ internal class DefaultSendService @Inject constructor(private val context: Conte
// Encrypted room handling
return if (cryptoService.isRoomEncrypted(roomId)) {
Timber.v("Send event in encrypted room")
val encryptWork = createEncryptEventWork(event)
val encryptWork = createEncryptEventWork(event, true)
val sendWork = createSendEventWork(event)
TimelineSendEventWorkCommon.postSequentialWorks(context, roomId, encryptWork, sendWork)
CancelableWork(context, encryptWork.id)
@ -94,25 +106,162 @@ internal class DefaultSendService @Inject constructor(private val context: Conte
return CancelableWork(context, redactWork.id)
}

override fun resendTextMessage(localEcho: TimelineEvent): Cancelable? {
if (localEcho.root.isTextMessage()) {
return sendEvent(localEcho.root)
}
return null

}

override fun resendMediaMessage(localEcho: TimelineEvent): Cancelable? {
//TODO this need a refactoring of attachement sending
// val clearContent = localEcho.root.getClearContent()
// val messageContent = clearContent?.toModel<MessageContent>() ?: return null
// when (messageContent.type) {
// MessageType.MSGTYPE_IMAGE -> {
// val imageContent = clearContent.toModel<MessageImageContent>() ?: return null
// val url = imageContent.url ?: return null
// if (url.startsWith("mxc://")) {
// //TODO
// } else {
// //The image has not yet been sent
// val attachmentData = ContentAttachmentData(
// size = imageContent.info!!.size.toLong(),
// mimeType = imageContent.info.mimeType!!,
// width = imageContent.info.width.toLong(),
// height = imageContent.info.height.toLong(),
// name = imageContent.body,
// path = imageContent.url,
// type = ContentAttachmentData.Type.IMAGE
// )
// monarchy.runTransactionSync {
// EventEntity.where(it,eventId = localEcho.root.eventId ?: "").findFirst()?.let {
// it.sendState = SendState.UNSENT
// }
// }
// return internalSendMedia(localEcho.root,attachmentData)
// }
// }
// }
return null

}

override fun deleteFailedEcho(localEcho: TimelineEvent) {
monarchy.tryTransactionAsync { realm ->
TimelineEventEntity.where(realm, eventId = localEcho.root.eventId
?: "").findFirst()?.let {
it.deleteFromRealm()
}
EventEntity.where(realm, eventId = localEcho.root.eventId
?: "").findFirst()?.let {
it.deleteFromRealm()
}
}
}

override fun clearSendingQueue() {
TimelineSendEventWorkCommon.cancelAllWorks(context, roomId)
WorkManager.getInstance(context).cancelUniqueWork(buildWorkIdentifier(UPLOAD_WORK))

matrixOneTimeWorkRequestBuilder<FakeSendWorker>()
.build().let {
TimelineSendEventWorkCommon.postWork(context, roomId, it, ExistingWorkPolicy.REPLACE)

//need to clear also image sending queue
WorkManager.getInstance(context)
.beginUniqueWork(buildWorkIdentifier(UPLOAD_WORK), ExistingWorkPolicy.REPLACE, it)
.enqueue()
}

monarchy.tryTransactionAsync { realm ->
RoomEntity.where(realm, roomId).findFirst()?.let { room ->
room.sendingTimelineEvents.forEach {
it.root?.sendState = SendState.UNDELIVERED
}
}
}

}

override fun resendAllFailedMessages() {
monarchy.tryTransactionAsync { realm ->
RoomEntity.where(realm, roomId).findFirst()?.let { room ->
room.sendingTimelineEvents.filter {
it.root?.sendState?.hasFailed() ?: false
}.sortedBy { it.root?.originServerTs ?: 0 }.forEach { timelineEventEntity ->
timelineEventEntity.root?.let {
val event = it.asDomain()
when (event.getClearType()) {
EventType.MESSAGE,
EventType.REDACTION,
EventType.REACTION -> {
val content = event.getClearContent().toModel<MessageContent>()
if (content != null) {
when (content.type) {
MessageType.MSGTYPE_EMOTE,
MessageType.MSGTYPE_NOTICE,
MessageType.MSGTYPE_LOCATION,
MessageType.MSGTYPE_TEXT -> {
it.sendState = SendState.UNSENT
sendEvent(event)
}
MessageType.MSGTYPE_FILE,
MessageType.MSGTYPE_VIDEO,
MessageType.MSGTYPE_IMAGE,
MessageType.MSGTYPE_AUDIO -> {
//need to resend the attachement
}
else -> {
Timber.e("Cannot resend message ${event.type} / ${content.type}")
}

}
} else {
Timber.e("Unsupported message to resend ${event.type}")
}
}
else -> {
Timber.e("Unsupported message to resend ${event.type}")
}
}
}
}
}
}
}

override fun sendMedia(attachment: ContentAttachmentData): Cancelable {
// Create an event with the media file path
val event = localEchoEventFactory.createMediaEvent(roomId, attachment).also {
saveLocalEcho(it)
}

return internalSendMedia(event, attachment)
}

private fun internalSendMedia(localEcho: Event, attachment: ContentAttachmentData): CancelableWork {
val isRoomEncrypted = cryptoService.isRoomEncrypted(roomId)

val uploadWork = createUploadMediaWork(event, attachment, isRoomEncrypted)
val sendWork = createSendEventWork(event)
val uploadWork = createUploadMediaWork(localEcho, attachment, isRoomEncrypted, startChain = true)
val sendWork = createSendEventWork(localEcho)

if (isRoomEncrypted) {
val encryptWork = createEncryptEventWork(event)
val encryptWork = createEncryptEventWork(localEcho, false /*not start of chain, take input error*/)

WorkManager.getInstance(context)
val op: Operation = WorkManager.getInstance(context)
.beginUniqueWork(buildWorkIdentifier(UPLOAD_WORK), ExistingWorkPolicy.APPEND, uploadWork)
.then(encryptWork)
.then(sendWork)
.enqueue()
op.result.addListener(Runnable {
if (op.result.isCancelled) {
Timber.e("CHAINE WAS CANCELLED")
} else if (op.state.value is Operation.State.FAILURE) {
Timber.e("CHAINE DID FAIL")
}
}, workerFutureListenerExecutor)
} else {
WorkManager.getInstance(context)
.beginUniqueWork(buildWorkIdentifier(UPLOAD_WORK), ExistingWorkPolicy.APPEND, uploadWork)
@ -131,7 +280,7 @@ internal class DefaultSendService @Inject constructor(private val context: Conte
return "${roomId}_$identifier"
}

private fun createEncryptEventWork(event: Event): OneTimeWorkRequest {
private fun createEncryptEventWork(event: Event, startChain: Boolean = false): OneTimeWorkRequest {
// Same parameter
val params = EncryptEventWorker.Params(credentials.userId, roomId, event)
val sendWorkData = WorkerParamsFactory.toData(params)
@ -139,6 +288,11 @@ internal class DefaultSendService @Inject constructor(private val context: Conte
return matrixOneTimeWorkRequestBuilder<EncryptEventWorker>()
.setConstraints(WorkManagerUtil.workConstraints)
.setInputData(sendWorkData)
.apply {
if (startChain) {
setInputMerger(NoMerger::class.java)
}
}
.setBackoffCriteria(BackoffPolicy.LINEAR, BACKOFF_DELAY, TimeUnit.MILLISECONDS)
.build()
}
@ -159,15 +313,24 @@ internal class DefaultSendService @Inject constructor(private val context: Conte
return TimelineSendEventWorkCommon.createWork<RedactEventWorker>(redactWorkData)
}

private fun createUploadMediaWork(event: Event, attachment: ContentAttachmentData, isRoomEncrypted: Boolean): OneTimeWorkRequest {
private fun createUploadMediaWork(event: Event,
attachment: ContentAttachmentData,
isRoomEncrypted: Boolean,
startChain: Boolean = false): OneTimeWorkRequest {
val uploadMediaWorkerParams = UploadContentWorker.Params(credentials.userId, roomId, event, attachment, isRoomEncrypted)
val uploadWorkData = WorkerParamsFactory.toData(uploadMediaWorkerParams)

return matrixOneTimeWorkRequestBuilder<UploadContentWorker>()
.setConstraints(WorkManagerUtil.workConstraints)
.apply {
if (startChain) {
setInputMerger(NoMerger::class.java)
}
}
.setInputData(uploadWorkData)
.setBackoffCriteria(BackoffPolicy.LINEAR, BACKOFF_DELAY, TimeUnit.MILLISECONDS)
.build()
}

}


View File

@ -29,6 +29,7 @@ import im.vector.matrix.android.internal.crypto.model.MXEncryptEventContentResul
import im.vector.matrix.android.internal.worker.SessionWorkerParams
import im.vector.matrix.android.internal.worker.WorkerParamsFactory
import im.vector.matrix.android.internal.worker.getSessionComponent
import timber.log.Timber
import java.util.concurrent.CountDownLatch
import javax.inject.Inject

@ -49,10 +50,13 @@ internal class EncryptEventWorker(context: Context, params: WorkerParameters)
@Inject lateinit var localEchoUpdater: LocalEchoUpdater

override fun doWork(): Result {

Timber.v("Start Encrypt work")
val params = WorkerParamsFactory.fromData<Params>(inputData)
?: return Result.success()
?: return Result.success().also {
Timber.v("Work cancelled due to input error from parent")
}

Timber.v("Start Encrypt work for event ${params.event.eventId}")
if (params.lastFailureMessage != null) {
// Transmit the error
return Result.success(inputData)
@ -97,7 +101,7 @@ internal class EncryptEventWorker(context: Context, params: WorkerParameters)
latch.await()

if (result != null) {
var modifiedContent = HashMap(result?.eventContent)
val modifiedContent = HashMap(result?.eventContent)
params.keepKeys?.forEach { toKeep ->
localEvent.content?.get(toKeep)?.let {
//put it back in the encrypted thing

View File

@ -0,0 +1,28 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.matrix.android.internal.session.room.send

import android.content.Context
import androidx.work.Worker
import androidx.work.WorkerParameters

internal class FakeSendWorker(context: Context, params: WorkerParameters)
: Worker(context, params) {

override fun doWork(): Result {
return Result.success()
}
}

View File

@ -21,15 +21,21 @@ import im.vector.matrix.android.api.session.room.send.SendState
import im.vector.matrix.android.internal.database.model.EventEntity
import im.vector.matrix.android.internal.database.query.where
import im.vector.matrix.android.internal.util.tryTransactionAsync
import timber.log.Timber
import javax.inject.Inject

internal class LocalEchoUpdater @Inject constructor(private val monarchy: Monarchy) {

fun updateSendState(eventId: String, sendState: SendState) {
Timber.v("Update local state of $eventId to ${sendState.name}")
monarchy.tryTransactionAsync { realm ->
val sendingEventEntity = EventEntity.where(realm, eventId).findFirst()
if (sendingEventEntity != null) {
sendingEventEntity.sendState = sendState
if (sendState == SendState.SENT && sendingEventEntity.sendState == SendState.SYNCED) {
//If already synced, do not put as sent
} else {
sendingEventEntity.sendState = sendState
}
}
}
}

View File

@ -0,0 +1,25 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.matrix.android.internal.session.room.send

import androidx.work.Data
import androidx.work.InputMerger

class NoMerger : InputMerger() {
override fun merge(inputs: MutableList<Data>): Data {
return inputs.first()
}
}

View File

@ -82,7 +82,10 @@ internal class SendEventWorker constructor(context: Context, params: WorkerParam
Result.success()
}
}
}, { Result.success() })
}, {
localEchoUpdater.updateSendState(event.eventId, SendState.SENT)
Result.success()
})
}

}

View File

@ -206,6 +206,23 @@ internal class DefaultTimeline(
}
}

override fun pendingEventCount(): Int {
var count = 0
Realm.getInstance(realmConfiguration).use {
count = RoomEntity.where(it,roomId).findFirst()?.sendingTimelineEvents?.count() ?: 0
}
return count
}

override fun failedToDeliverEventCount(): Int {
var count = 0
Realm.getInstance(realmConfiguration).use {
count = RoomEntity.where(it,roomId).findFirst()?.sendingTimelineEvents?.filter {
it.root?.sendState?.hasFailed() ?: false
}?.count() ?: 0
}
return count
}

override fun start() {
if (isStarted.compareAndSet(false, true)) {

View File

@ -51,9 +51,9 @@ internal object TimelineSendEventWorkCommon {
}
}

fun postWork(context: Context, roomId: String, workRequest: OneTimeWorkRequest) {
fun postWork(context: Context, roomId: String, workRequest: OneTimeWorkRequest, policy: ExistingWorkPolicy = ExistingWorkPolicy.APPEND) {
WorkManager.getInstance(context)
.beginUniqueWork(buildWorkIdentifier(roomId), ExistingWorkPolicy.APPEND, workRequest)
.beginUniqueWork(buildWorkIdentifier(roomId), policy, workRequest)
.enqueue()
}

@ -68,4 +68,8 @@ internal object TimelineSendEventWorkCommon {
private fun buildWorkIdentifier(roomId: String): String {
return "${roomId}_$SEND_WORK"
}

fun cancelAllWorks(context: Context, roomId: String) {
WorkManager.getInstance(context).cancelUniqueWork(buildWorkIdentifier(roomId))
}
}

View File

@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>

<string name="event_status_sending_message">Sending message…</string>
<string name="clear_timeline_send_queue">Clear sending queue</string>
</resources>

View File

@ -21,5 +21,5 @@ import im.vector.matrix.android.api.session.room.timeline.TimelineEvent

fun TimelineEvent.canReact(): Boolean {
// Only event of type Event.EVENT_TYPE_MESSAGE are supported for the moment
return root.getClearType() == EventType.MESSAGE && sendState.isSent() && !root.isRedacted()
return root.getClearType() == EventType.MESSAGE && root.sendState.isSent() && !root.isRedacted()
}

View File

@ -40,6 +40,10 @@ sealed class RoomDetailActions {
data class EnterEditMode(val eventId: String) : RoomDetailActions()
data class EnterQuoteMode(val eventId: String) : RoomDetailActions()
data class EnterReplyMode(val eventId: String) : RoomDetailActions()
data class ResendMessage(val eventId: String) : RoomDetailActions()
data class RemoveFailedEcho(val eventId: String) : RoomDetailActions()
object ClearSendQueue : RoomDetailActions()
object ResendAll : RoomDetailActions()


}

View File

@ -19,7 +19,12 @@ package im.vector.riotx.features.home.room.detail
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import com.airbnb.mvrx.activityViewModel
import androidx.appcompat.widget.Toolbar
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelProviders
import im.vector.riotx.R
import im.vector.riotx.core.extensions.replaceFragment
import im.vector.riotx.core.platform.ToolbarConfigurable

View File

@ -27,9 +27,7 @@ import android.os.Parcelable
import android.text.Editable
import android.text.Spannable
import android.text.TextUtils
import android.view.HapticFeedbackConstants
import android.view.LayoutInflater
import android.view.View
import android.view.*
import android.view.inputmethod.InputMethodManager
import android.widget.TextView
import android.widget.Toast
@ -38,6 +36,7 @@ import androidx.appcompat.app.AlertDialog
import androidx.core.app.ActivityOptionsCompat
import androidx.core.content.ContextCompat
import androidx.core.view.ViewCompat
import androidx.core.view.forEach
import androidx.lifecycle.ViewModelProviders
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager
@ -186,6 +185,8 @@ class RoomDetailFragment :

override fun getLayoutResId() = R.layout.fragment_room_detail

override fun getMenuRes() = R.menu.menu_timeline

private lateinit var actionViewModel: ActionsHandler

@BindView(R.id.composerLayout)
@ -239,6 +240,27 @@ class RoomDetailFragment :
}
}

override fun onPrepareOptionsMenu(menu: Menu) {
menu.forEach {
it.isVisible = roomDetailViewModel.isMenuItemVisible(it.itemId)
}
}

override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (item.itemId == R.id.clear_message_queue) {
//This a temporary option during dev as it is not super stable
//Cancel all pending actions in room queue and post a dummy
//Then mark all sending events as undelivered
roomDetailViewModel.process(RoomDetailActions.ClearSendQueue)
return true
}
if (item.itemId == R.id.resend_all) {
roomDetailViewModel.process(RoomDetailActions.ResendAll)
return true
}
return super.onOptionsItemSelected(item)
}

private fun exitSpecialMode() {
commandAutocompletePolicy.enabled = true
composerLayout.collapse()
@ -805,6 +827,14 @@ class RoomDetailFragment :
showSnackWithMessage(requireContext().getString(R.string.copied_to_clipboard), Snackbar.LENGTH_SHORT)

}
MessageMenuViewModel.ACTION_RESEND -> {
val eventId = actionData.data.toString()
roomDetailViewModel.process(RoomDetailActions.ResendMessage(eventId))
}
MessageMenuViewModel.ACTION_REMOVE -> {
val eventId = actionData.data.toString()
roomDetailViewModel.process(RoomDetailActions.RemoveFailedEcho(eventId))
}
else -> {
Toast.makeText(context, "Action ${actionData.actionId} not implemented", Toast.LENGTH_LONG).show()
}

View File

@ -18,6 +18,7 @@ package im.vector.riotx.features.home.room.detail

import android.net.Uri
import android.text.TextUtils
import androidx.annotation.IdRes
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.airbnb.mvrx.FragmentViewModelContext
@ -30,6 +31,8 @@ import com.squareup.inject.assisted.AssistedInject
import im.vector.matrix.android.api.MatrixCallback
import im.vector.matrix.android.api.session.Session
import im.vector.matrix.android.api.session.content.ContentAttachmentData
import im.vector.matrix.android.api.session.events.model.isImageMessage
import im.vector.matrix.android.api.session.events.model.isTextMessage
import im.vector.matrix.android.api.session.events.model.toModel
import im.vector.matrix.android.api.session.file.FileService
import im.vector.matrix.android.api.session.room.model.Membership
@ -40,6 +43,7 @@ import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.matrix.android.internal.crypto.attachments.toElementToDecrypt
import im.vector.matrix.android.internal.crypto.model.event.EncryptedEventContent
import im.vector.matrix.rx.rx
import im.vector.riotx.R
import im.vector.riotx.core.extensions.postLiveEvent
import im.vector.riotx.core.intent.getFilenameFromUri
import im.vector.riotx.core.platform.VectorViewModel
@ -119,6 +123,10 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
is RoomDetailActions.EnterReplyMode -> handleReplyAction(action)
is RoomDetailActions.DownloadFile -> handleDownloadFile(action)
is RoomDetailActions.NavigateToEvent -> handleNavigateToEvent(action)
is RoomDetailActions.ResendMessage -> handleResendEvent(action)
is RoomDetailActions.RemoveFailedEcho -> handleRemove(action)
is RoomDetailActions.ClearSendQueue -> handleClearSendQueue()
is RoomDetailActions.ResendAll -> handleResendAll()
else -> Timber.e("Unhandled Action: $action")
}
}
@ -157,6 +165,20 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
get() = _downloadedFileEvent


fun isMenuItemVisible(@IdRes itemId: Int): Boolean {
if (itemId == R.id.clear_message_queue) {
//For now always disable, woker cancellation is not working properly
return false//timeline.pendingEventCount() > 0
}
if (itemId == R.id.resend_all) {
return timeline.failedToDeliverEventCount() > 0
}
if (itemId == R.id.clear_all) {
return timeline.failedToDeliverEventCount() > 0
}
return false
}

// PRIVATE METHODS *****************************************************************************

private fun handleSendMessage(action: RoomDetailActions.SendMessage) {
@ -390,7 +412,7 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
}

private fun handleEventDisplayed(action: RoomDetailActions.EventDisplayed) {
if (action.event.sendState.isSent()) { //ignore pending/local events
if (action.event.root.sendState.isSent()) { //ignore pending/local events
displayedEventsObservable.accept(action)
}
//We need to update this with the related m.replace also (to move read receipt)
@ -524,6 +546,46 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
}
}

private fun handleResendEvent(action: RoomDetailActions.ResendMessage) {
val targetEventId = action.eventId
room.getTimeLineEvent(targetEventId)?.let {
//State must be UNDELIVERED or Failed
if (!it.root.sendState.hasFailed()) {
Timber.e("Cannot resend message, it is not failed, Cancel first")
return
}
if (it.root.isTextMessage()) {
room.resendTextMessage(it)
} else if (it.root.isImageMessage()) {
room.resendMediaMessage(it)
} else {
//TODO
}
}

}

private fun handleRemove(action: RoomDetailActions.RemoveFailedEcho) {
val targetEventId = action.eventId
room.getTimeLineEvent(targetEventId)?.let {
//State must be UNDELIVERED or Failed
if (!it.root.sendState.hasFailed()) {
Timber.e("Cannot resend message, it is not failed, Cancel first")
return
}
room.deleteFailedEcho(it)
}
}

private fun handleClearSendQueue() {
room.clearSendingQueue()
}

private fun handleResendAll() {
room.resendAllFailedMessages()
}


private fun observeEventDisplayedActions() {
// We are buffering scroll events for one second
// and keep the most recent one to set the read receipt on.

View File

@ -130,8 +130,7 @@ class RoomMessageTouchHelperCallback(private val context: Context,


private fun drawReplyButton(canvas: Canvas, itemView: View) {

Timber.v("drawReplyButton")
//Timber.v("drawReplyButton")
val translationX = Math.abs(itemView.translationX)
val newTime = System.currentTimeMillis()
val dt = Math.min(17, newTime - lastReplyButtonAnimationTime)

View File

@ -138,6 +138,19 @@ class MessageActionsBottomSheet : VectorBaseBottomSheetDialogFragment() {
}
quickReactBottomDivider.isVisible = it.canReact()
bottom_sheet_quick_reaction_container.isVisible = it.canReact()
if (it.informationData.sendState.isSending()) {
messageStatusInfo.isVisible = true
messageStatusProgress.isVisible = true
messageStatusText.text = getString(R.string.event_status_sending_message)
messageStatusText.setCompoundDrawables(null, null, null, null)
} else if (it.informationData.sendState.hasFailed()) {
messageStatusInfo.isVisible = true
messageStatusProgress.isVisible = false
messageStatusText.setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_warning_small, 0, 0, 0)
messageStatusText.text = getString(R.string.unable_to_send_message)
} else {
messageStatusInfo.isVisible = false
}
return@withState
}


View File

@ -20,6 +20,7 @@ import com.squareup.inject.assisted.Assisted
import com.squareup.inject.assisted.AssistedInject
import im.vector.matrix.android.api.session.Session
import im.vector.matrix.android.api.session.events.model.EventType
import im.vector.matrix.android.api.session.events.model.isTextMessage
import im.vector.matrix.android.api.session.events.model.toModel
import im.vector.matrix.android.api.session.room.model.message.MessageContent
import im.vector.matrix.android.api.session.room.model.message.MessageImageContent
@ -75,7 +76,9 @@ class MessageMenuViewModel @AssistedInject constructor(@Assisted initialState: M
const val ACTION_REPLY = "reply"
const val ACTION_SHARE = "share"
const val ACTION_RESEND = "resend"
const val ACTION_REMOVE = "remove"
const val ACTION_DELETE = "delete"
const val ACTION_CANCEL = "cancel"
const val VIEW_SOURCE = "VIEW_SOURCE"
const val VIEW_DECRYPTED_SOURCE = "VIEW_DECRYPTED_SOURCE"
const val ACTION_COPY_PERMALINK = "ACTION_COPY_PERMALINK"
@ -110,56 +113,57 @@ class MessageMenuViewModel @AssistedInject constructor(@Assisted initialState: M
?: event.root.getClearContent().toModel()
val type = messageContent?.type

val actions = if (!event.sendState.isSent()) {
//Resend and Delete
listOf<SimpleAction>(
// SimpleAction(ACTION_RESEND, R.string.resend, R.drawable.ic_send, event.root.eventId),
// //TODO delete icon
// SimpleAction(ACTION_DELETE, R.string.delete, R.drawable.ic_delete, event.root.eventId)
)
return if (event.root.sendState.hasFailed()) {
arrayListOf<SimpleAction>().apply {
if (canRetry(event)) {
this.add(SimpleAction(ACTION_RESEND, R.string.global_retry, R.drawable.ic_refresh_cw, eventId))
}
this.add(SimpleAction(ACTION_REMOVE, R.string.remove, R.drawable.ic_trash, eventId))
}
} else if (event.root.sendState.isSending()) {
//TODO is uploading attachment?
arrayListOf<SimpleAction>().apply {
if (canCancel(event)) {
this.add(SimpleAction(ACTION_CANCEL, R.string.cancel, R.drawable.ic_close_round, eventId))
}
}
} else {
arrayListOf<SimpleAction>().apply {

if (event.sendState == SendState.SENDING) {
//TODO add cancel?
return@apply
}
//TODO is downloading attachement?

if (!event.root.isRedacted()) {

if (canReply(event, messageContent)) {
this.add(SimpleAction(ACTION_REPLY, R.string.reply, R.drawable.ic_reply, eventId))
add(SimpleAction(ACTION_REPLY, R.string.reply, R.drawable.ic_reply, eventId))
}

if (canEdit(event, session.myUserId)) {
this.add(SimpleAction(ACTION_EDIT, R.string.edit, R.drawable.ic_edit, eventId))
add(SimpleAction(ACTION_EDIT, R.string.edit, R.drawable.ic_edit, eventId))
}

if (canRedact(event, session.myUserId)) {
this.add(SimpleAction(ACTION_DELETE, R.string.delete, R.drawable.ic_delete, eventId))
add(SimpleAction(ACTION_DELETE, R.string.delete, R.drawable.ic_delete, eventId))
}

if (canCopy(type)) {
//TODO copy images? html? see ClipBoard
this.add(SimpleAction(ACTION_COPY, R.string.copy, R.drawable.ic_copy, messageContent!!.body))
add(SimpleAction(ACTION_COPY, R.string.copy, R.drawable.ic_copy, messageContent!!.body))
}

if (event.canReact()) {
this.add(SimpleAction(ACTION_ADD_REACTION, R.string.message_add_reaction, R.drawable.ic_add_reaction, eventId))
add(SimpleAction(ACTION_ADD_REACTION, R.string.message_add_reaction, R.drawable.ic_add_reaction, eventId))
}

if (canQuote(event, messageContent)) {
this.add(SimpleAction(ACTION_QUOTE, R.string.quote, R.drawable.ic_quote, eventId))
add(SimpleAction(ACTION_QUOTE, R.string.quote, R.drawable.ic_quote, eventId))
}

if (canViewReactions(event)) {
this.add(SimpleAction(ACTION_VIEW_REACTIONS, R.string.message_view_reaction, R.drawable.ic_view_reactions, informationData))
add(SimpleAction(ACTION_VIEW_REACTIONS, R.string.message_view_reaction, R.drawable.ic_view_reactions, informationData))
}

if (canShare(type)) {
if (messageContent is MessageImageContent) {
this.add(
add(
SimpleAction(ACTION_SHARE,
R.string.share, R.drawable.ic_share,
session.contentUrlResolver().resolveFullSize(messageContent.url))
@ -169,7 +173,7 @@ class MessageMenuViewModel @AssistedInject constructor(@Assisted initialState: M
}


if (event.sendState == SendState.SENT) {
if (event.root.sendState == SendState.SENT) {

//TODO Can be redacted

@ -177,23 +181,25 @@ class MessageMenuViewModel @AssistedInject constructor(@Assisted initialState: M
}
}

this.add(SimpleAction(VIEW_SOURCE, R.string.view_source, R.drawable.ic_view_source, event.root.toContentStringWithIndent()))
add(SimpleAction(VIEW_SOURCE, R.string.view_source, R.drawable.ic_view_source, event.root.toContentStringWithIndent()))
if (event.isEncrypted()) {
val decryptedContent = event.root.toClearContentStringWithIndent()
?: stringProvider.getString(R.string.encryption_information_decryption_error)
this.add(SimpleAction(VIEW_DECRYPTED_SOURCE, R.string.view_decrypted_source, R.drawable.ic_view_source, decryptedContent))
add(SimpleAction(VIEW_DECRYPTED_SOURCE, R.string.view_decrypted_source, R.drawable.ic_view_source, decryptedContent))
}
this.add(SimpleAction(ACTION_COPY_PERMALINK, R.string.permalink, R.drawable.ic_permalink, event.root.eventId))
add(SimpleAction(ACTION_COPY_PERMALINK, R.string.permalink, R.drawable.ic_permalink, event.root.eventId))

if (session.myUserId != event.root.senderId && event.root.getClearType() == EventType.MESSAGE) {
//not sent by me
this.add(SimpleAction(ACTION_FLAG, R.string.report_content, R.drawable.ic_flag, event.root.eventId))
add(SimpleAction(ACTION_FLAG, R.string.report_content, R.drawable.ic_flag, event.root.eventId))
}
}
}
return actions
}

private fun canCancel(event: TimelineEvent): Boolean {
return false
}

private fun canReply(event: TimelineEvent, messageContent: MessageContent?): Boolean {
//Only event of type Event.EVENT_TYPE_MESSAGE are supported for the moment
@ -232,6 +238,11 @@ class MessageMenuViewModel @AssistedInject constructor(@Assisted initialState: M
return event.root.senderId == myUserId
}

private fun canRetry(event: TimelineEvent): Boolean {
return event.root.sendState.hasFailed() && event.root.isTextMessage()
}


private fun canViewReactions(event: TimelineEvent): Boolean {
//Only event of type Event.EVENT_TYPE_MESSAGE are supported for the moment
if (event.root.getClearType() != EventType.MESSAGE) return false

View File

@ -43,7 +43,7 @@ class EncryptionItemFactory @Inject constructor(private val stringProvider: Stri
val informationData = MessageInformationData(
eventId = event.root.eventId ?: "?",
senderId = event.root.senderId ?: "",
sendState = event.sendState,
sendState = event.root.sendState,
avatarUrl = event.senderAvatar(),
memberName = event.senderName(),
showInformation = false

View File

@ -97,7 +97,7 @@ class MessageItemFactory @Inject constructor(
val informationData = MessageInformationData(
eventId = event.root.eventId ?: "?",
senderId = event.root.senderId ?: "",
sendState = event.sendState,
sendState = event.root.sendState,
time = "",
avatarUrl = event.senderAvatar(),
memberName = "",
@ -121,7 +121,7 @@ class MessageItemFactory @Inject constructor(
event.annotations?.editSummary,
highlight,
callback)
is MessageTextContent -> buildTextMessageItem(event.sendState,
is MessageTextContent -> buildTextMessageItem(event.root.sendState,
messageContent,
informationData,
event.annotations?.editSummary,

View File

@ -37,7 +37,7 @@ class NoticeItemFactory @Inject constructor(private val eventFormatter: NoticeEv
val informationData = MessageInformationData(
eventId = event.root.eventId ?: "?",
senderId = event.root.senderId ?: "",
sendState = event.sendState,
sendState = event.root.sendState,
avatarUrl = event.senderAvatar(),
memberName = event.senderName(),
showInformation = false

View File

@ -74,7 +74,7 @@ class TimelineItemFactory @Inject constructor(private val messageItemFactory: Me
val informationData = MessageInformationData(
eventId = event.root.eventId ?: "?",
senderId = event.root.senderId ?: "",
sendState = event.sendState,
sendState = event.root.sendState,
time = "",
avatarUrl = event.senderAvatar(),
memberName = "",

View File

@ -162,10 +162,15 @@ abstract class AbsMessageItem<H : AbsMessageItem.Holder> : BaseEventItem<H>() {
return true
}

protected fun renderSendState(root: View, textView: TextView?) {
protected open fun renderSendState(root: View, textView: TextView?, failureIndicator: ImageView? = null) {
root.isClickable = informationData.sendState.isSent()
val state = if (informationData.hasPendingEdits) SendState.UNSENT else informationData.sendState
textView?.setTextColor(colorProvider.getMessageTextColor(state))
failureIndicator?.isVisible = when (informationData.sendState) {
SendState.UNDELIVERED,
SendState.FAILED_UNKNOWN_DEVICES -> true
else -> false
}
}

abstract class Holder(@IdRes stubId: Int) : BaseHolder(stubId) {

View File

@ -43,14 +43,20 @@ abstract class MessageImageVideoItem : AbsMessageItem<MessageImageVideoItem.Hold
override fun bind(holder: Holder) {
super.bind(holder)
imageContentRenderer.render(mediaData, ImageContentRenderer.Mode.THUMBNAIL, holder.imageView)
contentUploadStateTrackerBinder.bind(informationData.eventId, mediaData, holder.progressLayout)
if (!informationData.sendState.hasFailed()) {
contentUploadStateTrackerBinder.bind(informationData.eventId, mediaData, holder.progressLayout)
}
holder.imageView.setOnClickListener(clickListener)
holder.imageView.setOnLongClickListener(longClickListener)
ViewCompat.setTransitionName(holder.imageView,"imagePreview_${id()}")
holder.mediaContentView.setOnClickListener(cellClickListener)
holder.mediaContentView.setOnLongClickListener(longClickListener)
// The sending state color will be apply to the progress text
renderSendState(holder.imageView, null)
renderSendState(holder.imageView, null, holder.failedToSendIndicator)
holder.progressLayout
if (informationData.sendState.hasFailed()) {

}
holder.playContentView.visibility = if (playable) View.VISIBLE else View.GONE
}

@ -67,6 +73,7 @@ abstract class MessageImageVideoItem : AbsMessageItem<MessageImageVideoItem.Hold
val playContentView by bind<ImageView>(R.id.messageMediaPlayView)

val mediaContentView by bind<ViewGroup>(R.id.messageContentMedia)
val failedToSendIndicator by bind<ImageView>(R.id.messageFailToSendIndicator)
}

companion object {

View File

@ -64,7 +64,7 @@ class MessageInformationDataFactory @Inject constructor(private val timelineDate
return MessageInformationData(
eventId = eventId,
senderId = event.root.senderId ?: "",
sendState = event.sendState,
sendState = event.root.sendState,
time = time,
avatarUrl = avatarUrl,
memberName = formattedMemberName,

View File

@ -0,0 +1,22 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="22dp"
android:height="19dp"
android:viewportWidth="22"
android:viewportHeight="19">
<path
android:pathData="M21,2.741v5.333h-5.455M1,16.963V11.63h5.455"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:fillType="evenOdd"
android:strokeColor="#9E9E9E"
android:strokeLineCap="round"/>
<path
android:pathData="M3.282,7.185c0.937,-2.589 3.167,-4.527 5.907,-5.133 2.74,-0.607 5.607,0.204 7.593,2.147L21,8.074M1,11.63l4.218,3.875c1.986,1.943 4.853,2.754 7.593,2.148 2.74,-0.606 4.97,-2.545 5.907,-5.134"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:fillType="evenOdd"
android:strokeColor="#9E9E9E"
android:strokeLineCap="round"/>
</vector>

View File

@ -0,0 +1,14 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="20dp"
android:height="23dp"
android:viewportWidth="20"
android:viewportHeight="23">
<path
android:pathData="M1,5.852h18M17,5.852v14a2,2 0,0 1,-2 2H5a2,2 0,0 1,-2 -2v-14m3,0v-2a2,2 0,0 1,2 -2h4a2,2 0,0 1,2 2v2M8,10.852v6M12,10.852v6"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:fillType="evenOdd"
android:strokeColor="#9E9E9E"
android:strokeLineCap="round"/>
</vector>

View File

@ -0,0 +1,14 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="14dp"
android:height="14dp"
android:viewportWidth="14"
android:viewportHeight="14">
<path
android:pathData="M7,12.852A6,6 0,1 0,7 0.852a6,6 0,0 0,0 12zM7,1.452a1.8,1.8 0,0 1,1.8 1.8L8.8,6.852a1.8,1.8 0,1 1,-3.6 0L5.2,3.252A1.8,1.8 0,0 1,7 1.452zM7,12.252a1.8,1.8 0,1 0,0 -3.6,1.8 1.8,0 0,0 0,3.6z"
android:strokeLineJoin="round"
android:strokeWidth="1.44"
android:fillColor="#FF4B55"
android:fillType="evenOdd"
android:strokeColor="#FF4B55"
android:strokeLineCap="round"/>
</vector>

View File

@ -87,6 +87,38 @@
tools:text="Friday 8pm" />
</androidx.constraintlayout.widget.ConstraintLayout>

<LinearLayout
android:id="@+id/messageStatusInfo"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginBottom="4dp"
android:layout_marginEnd="16dp">

<ProgressBar
android:id="@+id/messageStatusProgress"
style="?android:attr/progressBarStyleSmall"
android:layout_width="12dp"
android:layout_height="12dp"
android:layout_gravity="center"
android:layout_marginEnd="4dp"
android:visibility="gone"
tools:visibility="visible" />

<TextView
android:id="@+id/messageStatusText"
android:textColor="?riotx_text_secondary"
android:textStyle="bold"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="16dp"
android:layout_weight="1"
android:drawableStart="@drawable/ic_warning_small"
android:drawablePadding="4dp"
tools:text="@string/unable_to_send_message" />

</LinearLayout>

<View
android:id="@+id/quickReactTopDivider"
android:layout_width="match_parent"

View File

@ -15,7 +15,19 @@
app:layout_constraintHorizontal_bias="0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:layout_height="300dp" />
tools:layout_height="300dp"
tools:src="@tools:sample/backgrounds/scenic" />

<ImageView
android:id="@+id/messageFailToSendIndicator"
android:layout_width="14dp"
android:layout_height="14dp"
android:layout_marginStart="2dp"
android:src="@drawable/ic_warning_small"
android:visibility="gone"
app:layout_constraintStart_toEndOf="@id/messageThumbnailView"
app:layout_constraintTop_toTopOf="@id/messageThumbnailView"
tools:visibility="visible" />

<ImageView
android:id="@+id/messageMediaPlayView"

View File

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<menu 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">

<item
android:id="@+id/resend_all"
android:icon="@drawable/ic_refresh_cw"
android:title="@string/room_prompt_resend"
android:visible="false"
app:showAsAction="never"
tools:visible="true" />

<item
android:id="@+id/clear_all"
android:icon="@drawable/ic_trash"
android:title="@string/room_prompt_cancel"
android:visible="false"
app:showAsAction="never"
tools:visible="true" />

<item
android:id="@+id/clear_message_queue"
android:title="@string/clear_timeline_send_queue"
android:visible="false"
app:showAsAction="never"
tools:visible="true" />

</menu>

View File

@ -1,89 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">

<item
android:id="@+id/ic_action_vector_resend_message"
android:icon="@drawable/ic_material_send_black"
android:title="@string/resend"
app:showAsAction="never" />

<item
android:id="@+id/ic_action_vector_cancel_upload"
android:icon="@drawable/vector_cancel_upload_download"
android:title="@string/room_event_action_cancel_upload"
app:showAsAction="never" />

<item
android:id="@+id/ic_action_vector_cancel_download"
android:icon="@drawable/vector_cancel_upload_download"
android:title="@string/room_event_action_cancel_download"
app:showAsAction="never" />

<item
android:id="@+id/ic_action_vector_redact_message"
android:icon="@drawable/ic_material_delete"
android:title="@string/redact"
app:showAsAction="never" />

<item
android:id="@+id/ic_action_vector_copy"
android:icon="@drawable/ic_material_copy"
android:title="@string/copy"
app:showAsAction="never" />

<item
android:id="@+id/ic_action_vector_quote"
android:icon="@drawable/ic_material_quote"
android:title="@string/quote"
app:showAsAction="never" />

<item
android:id="@+id/ic_action_vector_share"
android:icon="@drawable/ic_material_share"
android:title="@string/share"
app:showAsAction="never" />

<item
android:id="@+id/ic_action_vector_forward"
android:icon="@drawable/ic_material_forward"
android:title="@string/forward"
app:showAsAction="never" />

<item
android:id="@+id/ic_action_vector_save"
android:icon="@drawable/ic_material_save"
android:title="@string/save"
app:showAsAction="never" />

<item
android:id="@+id/ic_action_view_source"
android:icon="@drawable/ic_material_message_black"
android:title="@string/view_source"
app:showAsAction="never" />

<item
android:id="@+id/ic_action_view_decrypted_source"
android:icon="@drawable/ic_material_message_black"
android:title="@string/view_decrypted_source"
app:showAsAction="never" />

<item
android:id="@+id/ic_action_vector_permalink"
android:icon="@drawable/ic_material_link_black"
android:title="@string/permalink"
app:showAsAction="never" />

<item
android:id="@+id/ic_action_vector_report"
android:icon="@drawable/ic_report_black"
android:title="@string/report_content"
app:showAsAction="never" />

<item
android:id="@+id/ic_action_device_verification"
android:icon="@drawable/ic_perm_device_information_black"
android:title="@string/device_information"
app:showAsAction="never" />

</menu>

View File

@ -507,7 +507,7 @@
<string name="room_unsent_messages_notification">Messages not sent. %1$s or %2$s now?</string>
<string name="room_unknown_devices_messages_notification">Messages not sent due to unknown devices being present. %1$s or %2$s now?</string>
<string name="room_prompt_resend">Resend all</string>
<string name="room_prompt_cancel">cancel all</string>
<string name="room_prompt_cancel">Cancel all</string>
<string name="room_resend_unsent_messages">Resend unsent messages</string>
<string name="room_delete_unsent_messages">Delete unsent messages</string>
<string name="room_message_file_not_found">File not found</string>