Merge branch 'develop' into feature/room_update

This commit is contained in:
ganfra 2019-07-31 14:27:12 +02:00
commit 77c4355aed
51 changed files with 746 additions and 217 deletions

View File

@ -7,6 +7,7 @@ Features:
Improvements: Improvements:
- UI for pending edits (#193) - UI for pending edits (#193)
- UX image preview screen transition (#393) - UX image preview screen transition (#393)
- Basic support for resending failed messages (retry/remove)
Other changes: Other changes:
- -
@ -15,6 +16,7 @@ Bugfix:
- Edited message: link confusion when (edited) appears in body (#398) - Edited message: link confusion when (edited) appears in body (#398)
- Close detail room screen when the room is left with another client (#256) - Close detail room screen when the room is left with another client (#256)
- Clear notification for a room left on another client - Clear notification for a room left on another client
- Fix messages with empty `in_reply_to` not rendering (#447)
Translations: Translations:
- -

View File

@ -20,6 +20,9 @@ import android.text.TextUtils
import com.squareup.moshi.Json import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass import com.squareup.moshi.JsonClass
import im.vector.matrix.android.api.session.crypto.MXCryptoError 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.api.util.JsonDict
import im.vector.matrix.android.internal.crypto.algorithms.olm.OlmDecryptionResult import im.vector.matrix.android.internal.crypto.algorithms.olm.OlmDecryptionResult
import im.vector.matrix.android.internal.di.MoshiProvider import im.vector.matrix.android.internal.di.MoshiProvider
@ -81,6 +84,7 @@ data class Event(
var mxDecryptionResult: OlmDecryptionResult? = null var mxDecryptionResult: OlmDecryptionResult? = null
var mCryptoError: MXCryptoError.ErrorType? = 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 (redacts != other.redacts) return false
if (mxDecryptionResult != other.mxDecryptionResult) return false if (mxDecryptionResult != other.mxDecryptionResult) return false
if (mCryptoError != other.mCryptoError) return false if (mCryptoError != other.mCryptoError) return false
if (sendState != other.sendState) return false
return true return true
} }
@ -289,6 +294,39 @@ data class Event(
result = 31 * result + (redacts?.hashCode() ?: 0) result = 31 * result + (redacts?.hashCode() ?: 0)
result = 31 * result + (mxDecryptionResult?.hashCode() ?: 0) result = 31 * result + (mxDecryptionResult?.hashCode() ?: 0)
result = 31 * result + (mCryptoError?.hashCode() ?: 0) result = 31 * result + (mCryptoError?.hashCode() ?: 0)
result = 31 * result + sendState.hashCode()
return result 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

@ -29,5 +29,5 @@ interface MessageContent {
fun MessageContent?.isReply(): Boolean { fun MessageContent?.isReply(): Boolean {
return this?.relatesTo?.inReplyTo != null return this?.relatesTo?.inReplyTo?.eventId != null
} }

View File

@ -21,5 +21,5 @@ import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)
data class ReplyToContent( data class ReplyToContent(
@Json(name = "event_id") val eventId: String @Json(name = "event_id") val eventId: String? = null
) )

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.content.ContentAttachmentData
import im.vector.matrix.android.api.session.events.model.Event 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.model.message.MessageType
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.Cancelable
@ -65,4 +66,31 @@ interface SendService {
*/ */
fun redactEvent(event: Event, reason: String?): Cancelable 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 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 paginate(direction: Direction, count: Int)
fun pendingEventCount() : Int
fun failedToDeliverEventCount() : Int
interface Listener { interface Listener {
/** /**

View File

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

View File

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

View File

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

View File

@ -57,9 +57,11 @@ internal class UploadContentWorker(context: Context, params: WorkerParameters) :
override suspend fun doWork(): Result { override suspend fun doWork(): Result {
val params = WorkerParamsFactory.fromData<Params>(inputData) val params = WorkerParamsFactory.fromData<Params>(inputData)
?: return Result.success() ?: return Result.success()
Timber.v("Starting upload media work with params $params")
if (params.lastFailureMessage != null) { if (params.lastFailureMessage != null) {
// Transmit the error // Transmit the error
Timber.v("Stop upload media work due to input failure")
return Result.success(inputData) return Result.success(inputData)
} }
@ -121,7 +123,11 @@ internal class UploadContentWorker(context: Context, params: WorkerParameters) :
val progressListener = object : ProgressRequestBody.Listener { val progressListener = object : ProgressRequestBody.Listener {
override fun onProgress(current: Long, total: Long) { 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?, encryptedFileInfo: EncryptedFileInfo?,
thumbnailUrl: String?, thumbnailUrl: String?,
thumbnailEncryptedFileInfo: EncryptedFileInfo?): Result { thumbnailEncryptedFileInfo: EncryptedFileInfo?): Result {
Timber.v("handleSuccess $attachmentUrl, work is stopped $isStopped")
contentUploadStateTracker.setSuccess(params.event.eventId!!) contentUploadStateTracker.setSuccess(params.event.eventId!!)
val event = updateEvent(params.event, attachmentUrl, encryptedFileInfo, thumbnailUrl, thumbnailEncryptedFileInfo) val event = updateEvent(params.event, attachmentUrl, encryptedFileInfo, thumbnailUrl, thumbnailEncryptedFileInfo)
val sendParams = SendEventWorker.Params(params.userId, params.roomId, event) 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, private fun MessageFileContent.update(url: String,
encryptedFileInfo: EncryptedFileInfo?): MessageFileContent { encryptedFileInfo: EncryptedFileInfo?): MessageFileContent {
return copy( return copy(

View File

@ -62,9 +62,7 @@ internal class RoomSummaryUpdater @Inject constructor(private val credentials: C
roomId: String, roomId: String,
membership: Membership? = null, membership: Membership? = null,
roomSummary: RoomSyncSummary? = null, roomSummary: RoomSyncSummary? = null,
unreadNotifications: RoomSyncUnreadNotifications? = null, unreadNotifications: RoomSyncUnreadNotifications? = null) {
isDirect: Boolean? = null,
directUserId: String? = null) {
val roomSummaryEntity = RoomSummaryEntity.where(realm, roomId).findFirst() val roomSummaryEntity = RoomSummaryEntity.where(realm, roomId).findFirst()
?: realm.createObject(roomId) ?: realm.createObject(roomId)
@ -97,10 +95,6 @@ internal class RoomSummaryUpdater @Inject constructor(private val credentials: C
.asSequence() .asSequence()
.map { it.stateKey } .map { it.stateKey }
if (isDirect != null) {
roomSummaryEntity.isDirect = isDirect
roomSummaryEntity.directUserId = directUserId
}
roomSummaryEntity.displayName = roomDisplayNameResolver.resolve(roomId).toString() roomSummaryEntity.displayName = roomDisplayNameResolver.resolve(roomId).toString()
roomSummaryEntity.avatarUrl = roomAvatarResolver.resolve(roomId) roomSummaryEntity.avatarUrl = roomAvatarResolver.resolve(roomId)
roomSummaryEntity.topic = lastTopicEvent?.content.toModel<RoomTopicContent>()?.topic roomSummaryEntity.topic = lastTopicEvent?.content.toModel<RoomTopicContent>()?.topic

View File

@ -86,7 +86,7 @@ internal class DefaultCreateRoomTask @Inject constructor(private val roomAPI: Ro
this.isDirect = true this.isDirect = true
} }
}.flatMap { }.flatMap {
val directChats = directChatsHelper.getDirectChats() val directChats = directChatsHelper.getLocalUserAccount()
updateUserAccountDataTask.execute(UpdateUserAccountDataTask.DirectChatParams(directMessages = directChats)) updateUserAccountDataTask.execute(UpdateUserAccountDataTask.DirectChatParams(directMessages = directChats))
}.flatMap { }.flatMap {
Try.just(roomId) Try.just(roomId)

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.findWithSenderMembershipEvent
import im.vector.matrix.android.internal.database.query.where import im.vector.matrix.android.internal.database.query.where
import im.vector.matrix.android.internal.di.MoshiProvider 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.task.Task
import im.vector.matrix.android.internal.util.tryTransactionSync import im.vector.matrix.android.internal.util.tryTransactionSync
import io.realm.Realm 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 val redactionEventEntity = EventEntity.where(realm, eventId = redactionEvent.eventId
?: "").findFirst() ?: "").findFirst()
?: return ?: return
val isLocalEcho = redactionEventEntity.sendState == SendState.UNSENT val isLocalEcho = LocalEchoEventFactory.isLocalEchoId(redactionEvent.eventId ?: "")
Timber.v("Redact event for ${redactionEvent.redacts} localEcho=$isLocalEcho") Timber.v("Redact event for ${redactionEvent.redacts} localEcho=$isLocalEcho")
val eventToPrune = EventEntity.where(realm, eventId = redactionEvent.redacts).findFirst() val eventToPrune = EventEntity.where(realm, eventId = redactionEvent.redacts).findFirst()

View File

@ -17,25 +17,36 @@
package im.vector.matrix.android.internal.session.room.send package im.vector.matrix.android.internal.session.room.send
import android.content.Context import android.content.Context
import androidx.work.BackoffPolicy import androidx.work.*
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequest
import androidx.work.WorkManager
import com.zhuinden.monarchy.Monarchy import com.zhuinden.monarchy.Monarchy
import im.vector.matrix.android.api.auth.data.Credentials 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.content.ContentAttachmentData
import im.vector.matrix.android.api.session.crypto.CryptoService 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.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.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.Cancelable
import im.vector.matrix.android.api.util.CancelableBag 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.content.UploadContentWorker
import im.vector.matrix.android.internal.session.room.timeline.TimelineSendEventWorkCommon 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.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
import im.vector.matrix.android.internal.worker.WorkManagerUtil.matrixOneTimeWorkRequestBuilder import im.vector.matrix.android.internal.worker.WorkManagerUtil.matrixOneTimeWorkRequestBuilder
import im.vector.matrix.android.internal.worker.WorkerParamsFactory import im.vector.matrix.android.internal.worker.WorkerParamsFactory
import timber.log.Timber import timber.log.Timber
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import javax.inject.Inject import javax.inject.Inject
@ -50,6 +61,7 @@ internal class DefaultSendService @Inject constructor(private val context: Conte
private val monarchy: Monarchy) private val monarchy: Monarchy)
: SendService { : SendService {
private val workerFutureListenerExecutor = Executors.newSingleThreadExecutor()
override fun sendTextMessage(text: String, msgType: String, autoMarkdown: Boolean): Cancelable { override fun sendTextMessage(text: String, msgType: String, autoMarkdown: Boolean): Cancelable {
val event = localEchoEventFactory.createTextEvent(roomId, msgType, text, autoMarkdown).also { val event = localEchoEventFactory.createTextEvent(roomId, msgType, text, autoMarkdown).also {
saveLocalEcho(it) saveLocalEcho(it)
@ -70,7 +82,7 @@ internal class DefaultSendService @Inject constructor(private val context: Conte
// Encrypted room handling // Encrypted room handling
return if (cryptoService.isRoomEncrypted(roomId)) { return if (cryptoService.isRoomEncrypted(roomId)) {
Timber.v("Send event in encrypted room") Timber.v("Send event in encrypted room")
val encryptWork = createEncryptEventWork(event) val encryptWork = createEncryptEventWork(event, true)
val sendWork = createSendEventWork(event) val sendWork = createSendEventWork(event)
TimelineSendEventWorkCommon.postSequentialWorks(context, roomId, encryptWork, sendWork) TimelineSendEventWorkCommon.postSequentialWorks(context, roomId, encryptWork, sendWork)
CancelableWork(context, encryptWork.id) CancelableWork(context, encryptWork.id)
@ -94,25 +106,162 @@ internal class DefaultSendService @Inject constructor(private val context: Conte
return CancelableWork(context, redactWork.id) 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 { override fun sendMedia(attachment: ContentAttachmentData): Cancelable {
// Create an event with the media file path // Create an event with the media file path
val event = localEchoEventFactory.createMediaEvent(roomId, attachment).also { val event = localEchoEventFactory.createMediaEvent(roomId, attachment).also {
saveLocalEcho(it) saveLocalEcho(it)
} }
return internalSendMedia(event, attachment)
}
private fun internalSendMedia(localEcho: Event, attachment: ContentAttachmentData): CancelableWork {
val isRoomEncrypted = cryptoService.isRoomEncrypted(roomId) val isRoomEncrypted = cryptoService.isRoomEncrypted(roomId)
val uploadWork = createUploadMediaWork(event, attachment, isRoomEncrypted) val uploadWork = createUploadMediaWork(localEcho, attachment, isRoomEncrypted, startChain = true)
val sendWork = createSendEventWork(event) val sendWork = createSendEventWork(localEcho)
if (isRoomEncrypted) { 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) .beginUniqueWork(buildWorkIdentifier(UPLOAD_WORK), ExistingWorkPolicy.APPEND, uploadWork)
.then(encryptWork) .then(encryptWork)
.then(sendWork) .then(sendWork)
.enqueue() .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 { } else {
WorkManager.getInstance(context) WorkManager.getInstance(context)
.beginUniqueWork(buildWorkIdentifier(UPLOAD_WORK), ExistingWorkPolicy.APPEND, uploadWork) .beginUniqueWork(buildWorkIdentifier(UPLOAD_WORK), ExistingWorkPolicy.APPEND, uploadWork)
@ -131,7 +280,7 @@ internal class DefaultSendService @Inject constructor(private val context: Conte
return "${roomId}_$identifier" return "${roomId}_$identifier"
} }
private fun createEncryptEventWork(event: Event): OneTimeWorkRequest { private fun createEncryptEventWork(event: Event, startChain: Boolean = false): OneTimeWorkRequest {
// Same parameter // Same parameter
val params = EncryptEventWorker.Params(credentials.userId, roomId, event) val params = EncryptEventWorker.Params(credentials.userId, roomId, event)
val sendWorkData = WorkerParamsFactory.toData(params) val sendWorkData = WorkerParamsFactory.toData(params)
@ -139,6 +288,11 @@ internal class DefaultSendService @Inject constructor(private val context: Conte
return matrixOneTimeWorkRequestBuilder<EncryptEventWorker>() return matrixOneTimeWorkRequestBuilder<EncryptEventWorker>()
.setConstraints(WorkManagerUtil.workConstraints) .setConstraints(WorkManagerUtil.workConstraints)
.setInputData(sendWorkData) .setInputData(sendWorkData)
.apply {
if (startChain) {
setInputMerger(NoMerger::class.java)
}
}
.setBackoffCriteria(BackoffPolicy.LINEAR, BACKOFF_DELAY, TimeUnit.MILLISECONDS) .setBackoffCriteria(BackoffPolicy.LINEAR, BACKOFF_DELAY, TimeUnit.MILLISECONDS)
.build() .build()
} }
@ -159,15 +313,24 @@ internal class DefaultSendService @Inject constructor(private val context: Conte
return TimelineSendEventWorkCommon.createWork<RedactEventWorker>(redactWorkData) 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 uploadMediaWorkerParams = UploadContentWorker.Params(credentials.userId, roomId, event, attachment, isRoomEncrypted)
val uploadWorkData = WorkerParamsFactory.toData(uploadMediaWorkerParams) val uploadWorkData = WorkerParamsFactory.toData(uploadMediaWorkerParams)
return matrixOneTimeWorkRequestBuilder<UploadContentWorker>() return matrixOneTimeWorkRequestBuilder<UploadContentWorker>()
.setConstraints(WorkManagerUtil.workConstraints) .setConstraints(WorkManagerUtil.workConstraints)
.apply {
if (startChain) {
setInputMerger(NoMerger::class.java)
}
}
.setInputData(uploadWorkData) .setInputData(uploadWorkData)
.setBackoffCriteria(BackoffPolicy.LINEAR, BACKOFF_DELAY, TimeUnit.MILLISECONDS) .setBackoffCriteria(BackoffPolicy.LINEAR, BACKOFF_DELAY, TimeUnit.MILLISECONDS)
.build() .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.SessionWorkerParams
import im.vector.matrix.android.internal.worker.WorkerParamsFactory import im.vector.matrix.android.internal.worker.WorkerParamsFactory
import im.vector.matrix.android.internal.worker.getSessionComponent import im.vector.matrix.android.internal.worker.getSessionComponent
import timber.log.Timber
import java.util.concurrent.CountDownLatch import java.util.concurrent.CountDownLatch
import javax.inject.Inject import javax.inject.Inject
@ -49,10 +50,13 @@ internal class EncryptEventWorker(context: Context, params: WorkerParameters)
@Inject lateinit var localEchoUpdater: LocalEchoUpdater @Inject lateinit var localEchoUpdater: LocalEchoUpdater
override fun doWork(): Result { override fun doWork(): Result {
Timber.v("Start Encrypt work")
val params = WorkerParamsFactory.fromData<Params>(inputData) 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) { if (params.lastFailureMessage != null) {
// Transmit the error // Transmit the error
return Result.success(inputData) return Result.success(inputData)
@ -97,7 +101,7 @@ internal class EncryptEventWorker(context: Context, params: WorkerParameters)
latch.await() latch.await()
if (result != null) { if (result != null) {
var modifiedContent = HashMap(result?.eventContent) val modifiedContent = HashMap(result?.eventContent)
params.keepKeys?.forEach { toKeep -> params.keepKeys?.forEach { toKeep ->
localEvent.content?.get(toKeep)?.let { localEvent.content?.get(toKeep)?.let {
//put it back in the encrypted thing //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.model.EventEntity
import im.vector.matrix.android.internal.database.query.where import im.vector.matrix.android.internal.database.query.where
import im.vector.matrix.android.internal.util.tryTransactionAsync import im.vector.matrix.android.internal.util.tryTransactionAsync
import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
internal class LocalEchoUpdater @Inject constructor(private val monarchy: Monarchy) { internal class LocalEchoUpdater @Inject constructor(private val monarchy: Monarchy) {
fun updateSendState(eventId: String, sendState: SendState) { fun updateSendState(eventId: String, sendState: SendState) {
Timber.v("Update local state of $eventId to ${sendState.name}")
monarchy.tryTransactionAsync { realm -> monarchy.tryTransactionAsync { realm ->
val sendingEventEntity = EventEntity.where(realm, eventId).findFirst() val sendingEventEntity = EventEntity.where(realm, eventId).findFirst()
if (sendingEventEntity != null) { 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()
} }
} }
}, { 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() { override fun start() {
if (isStarted.compareAndSet(false, true)) { 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) WorkManager.getInstance(context)
.beginUniqueWork(buildWorkIdentifier(roomId), ExistingWorkPolicy.APPEND, workRequest) .beginUniqueWork(buildWorkIdentifier(roomId), policy, workRequest)
.enqueue() .enqueue()
} }
@ -68,4 +68,8 @@ internal object TimelineSendEventWorkCommon {
private fun buildWorkIdentifier(roomId: String): String { private fun buildWorkIdentifier(roomId: String): String {
return "${roomId}_$SEND_WORK" return "${roomId}_$SEND_WORK"
} }
fun cancelAllWorks(context: Context, roomId: String) {
WorkManager.getInstance(context).cancelUniqueWork(buildWorkIdentifier(roomId))
}
} }

View File

@ -35,14 +35,10 @@ import im.vector.matrix.android.internal.database.mapper.asDomain
import im.vector.matrix.android.internal.database.model.ChunkEntity import im.vector.matrix.android.internal.database.model.ChunkEntity
import im.vector.matrix.android.internal.database.model.EventEntityFields import im.vector.matrix.android.internal.database.model.EventEntityFields
import im.vector.matrix.android.internal.database.model.RoomEntity import im.vector.matrix.android.internal.database.model.RoomEntity
import im.vector.matrix.android.internal.database.model.RoomSummaryEntity
import im.vector.matrix.android.internal.database.query.find import im.vector.matrix.android.internal.database.query.find
import im.vector.matrix.android.internal.database.query.findLastLiveChunkFromRoom import im.vector.matrix.android.internal.database.query.findLastLiveChunkFromRoom
import im.vector.matrix.android.internal.database.query.isDirect
import im.vector.matrix.android.internal.database.query.where import im.vector.matrix.android.internal.database.query.where
import im.vector.matrix.android.internal.session.DefaultInitialSyncProgressService import im.vector.matrix.android.internal.session.DefaultInitialSyncProgressService
import im.vector.matrix.android.internal.session.user.accountdata.DirectChatsHelper
import im.vector.matrix.android.internal.session.user.accountdata.UpdateUserAccountDataTask
import im.vector.matrix.android.internal.session.mapWithProgress import im.vector.matrix.android.internal.session.mapWithProgress
import im.vector.matrix.android.internal.session.notification.DefaultPushRuleService import im.vector.matrix.android.internal.session.notification.DefaultPushRuleService
import im.vector.matrix.android.internal.session.notification.ProcessEventForPushTask import im.vector.matrix.android.internal.session.notification.ProcessEventForPushTask
@ -70,9 +66,7 @@ internal class RoomSyncHandler @Inject constructor(private val monarchy: Monarch
private val tokenStore: SyncTokenStore, private val tokenStore: SyncTokenStore,
private val pushRuleService: DefaultPushRuleService, private val pushRuleService: DefaultPushRuleService,
private val processForPushTask: ProcessEventForPushTask, private val processForPushTask: ProcessEventForPushTask,
private val updateUserAccountDataTask: UpdateUserAccountDataTask,
private val credentials: Credentials, private val credentials: Credentials,
private val directChatsHelper: DirectChatsHelper,
private val taskExecutor: TaskExecutor) { private val taskExecutor: TaskExecutor) {
sealed class HandlingStrategy { sealed class HandlingStrategy {
@ -192,21 +186,7 @@ internal class RoomSyncHandler @Inject constructor(private val monarchy: Monarch
val chunkEntity = handleTimelineEvents(realm, roomEntity, roomSync.inviteState.events) val chunkEntity = handleTimelineEvents(realm, roomEntity, roomSync.inviteState.events)
roomEntity.addOrUpdate(chunkEntity) roomEntity.addOrUpdate(chunkEntity)
} }
val myUserStateEvent = RoomMembers(realm, roomId).getStateEvent(credentials.userId) roomSummaryUpdater.update(realm, roomId, Membership.INVITE)
val inviterId = myUserStateEvent?.sender
val myUserRoomMember: RoomMember? = myUserStateEvent?.let { it.asDomain().content?.toModel() }
val isDirect = myUserRoomMember?.isDirect
if (isDirect == true && inviterId != null) {
val isAlreadyDirect = RoomSummaryEntity.isDirect(realm, roomId)
if (!isAlreadyDirect) {
val directChatsMap = directChatsHelper.getDirectChats(include = Pair(inviterId, roomId))
val updateUserAccountParams = UpdateUserAccountDataTask.DirectChatParams(
directMessages = directChatsMap
)
updateUserAccountDataTask.configureWith(updateUserAccountParams).executeBy(taskExecutor)
}
}
roomSummaryUpdater.update(realm, roomId, Membership.INVITE, isDirect = isDirect, directUserId = inviterId)
return roomEntity return roomEntity
} }

View File

@ -17,11 +17,23 @@
package im.vector.matrix.android.internal.session.sync package im.vector.matrix.android.internal.session.sync
import arrow.core.Try import arrow.core.Try
import com.zhuinden.monarchy.Monarchy
import im.vector.matrix.android.R import im.vector.matrix.android.R
import im.vector.matrix.android.api.auth.data.Credentials
import im.vector.matrix.android.api.session.events.model.toModel
import im.vector.matrix.android.api.session.room.model.RoomMember
import im.vector.matrix.android.internal.crypto.CryptoManager import im.vector.matrix.android.internal.crypto.CryptoManager
import im.vector.matrix.android.internal.database.mapper.asDomain
import im.vector.matrix.android.internal.database.model.RoomSummaryEntity
import im.vector.matrix.android.internal.database.query.isDirect
import im.vector.matrix.android.internal.session.DefaultInitialSyncProgressService import im.vector.matrix.android.internal.session.DefaultInitialSyncProgressService
import im.vector.matrix.android.internal.session.reportSubtask import im.vector.matrix.android.internal.session.reportSubtask
import im.vector.matrix.android.internal.session.room.membership.RoomMembers
import im.vector.matrix.android.internal.session.sync.model.SyncResponse import im.vector.matrix.android.internal.session.sync.model.SyncResponse
import im.vector.matrix.android.internal.session.user.accountdata.DirectChatsHelper
import im.vector.matrix.android.internal.session.user.accountdata.UpdateUserAccountDataTask
import im.vector.matrix.android.internal.task.TaskExecutor
import im.vector.matrix.android.internal.task.configureWith
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
import kotlin.system.measureTimeMillis import kotlin.system.measureTimeMillis
@ -88,9 +100,7 @@ internal class SyncResponseHandler @Inject constructor(private val roomSyncHandl
measureTimeMillis { measureTimeMillis {
reportSubtask(reporter, R.string.initial_sync_start_importing_account_data, 100, 0.1f) { reportSubtask(reporter, R.string.initial_sync_start_importing_account_data, 100, 0.1f) {
Timber.v("Handle accountData") Timber.v("Handle accountData")
if (syncResponse.accountData != null) { userAccountDataSyncHandler.handle(syncResponse.accountData, syncResponse.rooms?.invite)
userAccountDataSyncHandler.handle(syncResponse.accountData)
}
} }
}.also { }.also {
Timber.v("Finish handling accountData in $it ms") Timber.v("Finish handling accountData in $it ms")
@ -98,7 +108,6 @@ internal class SyncResponseHandler @Inject constructor(private val roomSyncHandl
Timber.v("On sync completed") Timber.v("On sync completed")
cryptoSyncHandler.onSyncCompleted(syncResponse) cryptoSyncHandler.onSyncCompleted(syncResponse)
} }
Timber.v("Finish handling sync in $measure ms") Timber.v("Finish handling sync in $measure ms")
syncResponse syncResponse

View File

@ -17,28 +17,45 @@
package im.vector.matrix.android.internal.session.sync package im.vector.matrix.android.internal.session.sync
import com.zhuinden.monarchy.Monarchy import com.zhuinden.monarchy.Monarchy
import im.vector.matrix.android.api.auth.data.Credentials
import im.vector.matrix.android.api.session.events.model.toModel
import im.vector.matrix.android.api.session.room.model.RoomMember
import im.vector.matrix.android.internal.database.mapper.asDomain
import im.vector.matrix.android.internal.database.model.RoomSummaryEntity import im.vector.matrix.android.internal.database.model.RoomSummaryEntity
import im.vector.matrix.android.internal.database.model.RoomSummaryEntityFields
import im.vector.matrix.android.internal.database.query.getDirectRooms import im.vector.matrix.android.internal.database.query.getDirectRooms
import im.vector.matrix.android.internal.database.query.where import im.vector.matrix.android.internal.database.query.where
import im.vector.matrix.android.internal.session.room.membership.RoomMembers
import im.vector.matrix.android.internal.session.sync.model.InvitedRoomSync
import im.vector.matrix.android.internal.session.sync.model.UserAccountDataDirectMessages import im.vector.matrix.android.internal.session.sync.model.UserAccountDataDirectMessages
import im.vector.matrix.android.internal.session.sync.model.UserAccountDataSync import im.vector.matrix.android.internal.session.sync.model.UserAccountDataSync
import im.vector.matrix.android.internal.session.user.accountdata.DirectChatsHelper
import im.vector.matrix.android.internal.session.user.accountdata.UpdateUserAccountDataTask
import im.vector.matrix.android.internal.task.TaskExecutor
import im.vector.matrix.android.internal.task.configureWith
import io.realm.Realm
import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
internal class UserAccountDataSyncHandler @Inject constructor(private val monarchy: Monarchy) { internal class UserAccountDataSyncHandler @Inject constructor(private val monarchy: Monarchy,
private val credentials: Credentials,
private val directChatsHelper: DirectChatsHelper,
private val updateUserAccountDataTask: UpdateUserAccountDataTask,
private val taskExecutor: TaskExecutor) {
fun handle(accountData: UserAccountDataSync) { fun handle(accountData: UserAccountDataSync?, invites: Map<String, InvitedRoomSync>?) {
accountData.list.forEach { accountData?.list?.forEach {
when (it) { when (it) {
is UserAccountDataDirectMessages -> handleDirectChatRooms(it) is UserAccountDataDirectMessages -> handleDirectChatRooms(it)
else -> return@forEach else -> return@forEach
} }
} }
monarchy.doWithRealm { realm ->
synchronizeWithServerIfNeeded(realm, invites)
}
} }
private fun handleDirectChatRooms(directMessages: UserAccountDataDirectMessages) { private fun handleDirectChatRooms(directMessages: UserAccountDataDirectMessages) {
monarchy.runTransactionSync { realm -> monarchy.runTransactionSync { realm ->
val oldDirectRooms = RoomSummaryEntity.getDirectRooms(realm) val oldDirectRooms = RoomSummaryEntity.getDirectRooms(realm)
oldDirectRooms.forEach { oldDirectRooms.forEach {
it.isDirect = false it.isDirect = false
@ -57,4 +74,35 @@ internal class UserAccountDataSyncHandler @Inject constructor(private val monarc
} }
} }
} }
// If we get some direct chat invites, we synchronize the user account data including those.
private fun synchronizeWithServerIfNeeded(realm: Realm, invites: Map<String, InvitedRoomSync>?) {
if (invites.isNullOrEmpty()) return
val directChats = directChatsHelper.getLocalUserAccount()
var hasUpdate = false
invites.forEach { (roomId, _) ->
val myUserStateEvent = RoomMembers(realm, roomId).getStateEvent(credentials.userId)
val inviterId = myUserStateEvent?.sender
val myUserRoomMember: RoomMember? = myUserStateEvent?.let { it.asDomain().content?.toModel() }
val isDirect = myUserRoomMember?.isDirect
if (inviterId != null && inviterId != credentials.userId && isDirect == true) {
directChats
.getOrPut(inviterId, { arrayListOf() })
.apply {
if (contains(roomId)) {
Timber.v("Direct chats already include room $roomId with user $inviterId")
} else {
add(roomId)
hasUpdate = true
}
}
}
}
if (hasUpdate) {
val updateUserAccountParams = UpdateUserAccountDataTask.DirectChatParams(
directMessages = directChats
)
updateUserAccountDataTask.configureWith(updateUserAccountParams).executeBy(taskExecutor)
}
}
} }

View File

@ -24,27 +24,24 @@ import io.realm.RealmConfiguration
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
internal class DirectChatsHelper @Inject constructor(@SessionDatabase private val realmConfiguration: RealmConfiguration) { internal class DirectChatsHelper @Inject constructor(@SessionDatabase
private val realmConfiguration: RealmConfiguration) {
fun getDirectChats(include: Pair<String, String>? = null, filterRoomId: String? = null): Map<String, List<String>> { /**
* @return a map of userId <-> list of roomId
*/
fun getLocalUserAccount(filterRoomId: String? = null): MutableMap<String, MutableList<String>> {
return Realm.getInstance(realmConfiguration).use { realm -> return Realm.getInstance(realmConfiguration).use { realm ->
val currentDirectRooms = RoomSummaryEntity.getDirectRooms(realm) val currentDirectRooms = RoomSummaryEntity.getDirectRooms(realm)
val directChatsMap = mutableMapOf<String, MutableList<String>>() val directChatsMap = mutableMapOf<String, MutableList<String>>()
for (directRoom in currentDirectRooms) { for (directRoom in currentDirectRooms) {
if (directRoom.roomId == filterRoomId) continue if (directRoom.roomId == filterRoomId) continue
val directUserId = directRoom.directUserId ?: continue val directUserId = directRoom.directUserId ?: continue
directChatsMap.getOrPut(directUserId, { arrayListOf() }).apply { directChatsMap
add(directRoom.roomId) .getOrPut(directUserId, { arrayListOf() })
} .apply {
} add(directRoom.roomId)
if (include != null) { }
directChatsMap.getOrPut(include.first, { arrayListOf() }).apply {
if (contains(include.second)) {
Timber.v("Direct chats already include room ${include.second} with user ${include.first}")
} else {
add(include.second)
}
}
} }
directChatsMap directChatsMap
} }

View File

@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<string name="event_status_sending_message">Sending message…</string>
<string name="clear_timeline_send_queue">Clear sending queue</string>
</resources> </resources>

View File

@ -21,5 +21,5 @@ import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
fun TimelineEvent.canReact(): Boolean { fun TimelineEvent.canReact(): Boolean {
// Only event of type Event.EVENT_TYPE_MESSAGE are supported for the moment // 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

@ -42,6 +42,10 @@ sealed class RoomDetailActions {
data class EnterEditMode(val eventId: String) : RoomDetailActions() data class EnterEditMode(val eventId: String) : RoomDetailActions()
data class EnterQuoteMode(val eventId: String) : RoomDetailActions() data class EnterQuoteMode(val eventId: String) : RoomDetailActions()
data class EnterReplyMode(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.Context
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import com.airbnb.mvrx.activityViewModel
import androidx.appcompat.widget.Toolbar import androidx.appcompat.widget.Toolbar
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelProviders
import im.vector.riotx.R import im.vector.riotx.R
import im.vector.riotx.core.extensions.replaceFragment import im.vector.riotx.core.extensions.replaceFragment
import im.vector.riotx.core.platform.ToolbarConfigurable import im.vector.riotx.core.platform.ToolbarConfigurable

View File

@ -27,9 +27,7 @@ import android.os.Parcelable
import android.text.Editable import android.text.Editable
import android.text.Spannable import android.text.Spannable
import android.text.TextUtils import android.text.TextUtils
import android.view.HapticFeedbackConstants import android.view.*
import android.view.LayoutInflater
import android.view.View
import android.view.inputmethod.InputMethodManager import android.view.inputmethod.InputMethodManager
import android.widget.TextView import android.widget.TextView
import android.widget.Toast import android.widget.Toast
@ -38,6 +36,7 @@ import androidx.appcompat.app.AlertDialog
import androidx.core.app.ActivityOptionsCompat import androidx.core.app.ActivityOptionsCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.view.ViewCompat import androidx.core.view.ViewCompat
import androidx.core.view.forEach
import androidx.lifecycle.ViewModelProviders import androidx.lifecycle.ViewModelProviders
import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
@ -188,6 +187,8 @@ class RoomDetailFragment :
override fun getLayoutResId() = R.layout.fragment_room_detail override fun getLayoutResId() = R.layout.fragment_room_detail
override fun getMenuRes() = R.menu.menu_timeline
private lateinit var actionViewModel: ActionsHandler private lateinit var actionViewModel: ActionsHandler
@BindView(R.id.composerLayout) @BindView(R.id.composerLayout)
@ -271,6 +272,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() { private fun exitSpecialMode() {
commandAutocompletePolicy.enabled = true commandAutocompletePolicy.enabled = true
composerLayout.collapse() composerLayout.collapse()
@ -874,6 +896,14 @@ class RoomDetailFragment :
showSnackWithMessage(requireContext().getString(R.string.copied_to_clipboard), Snackbar.LENGTH_SHORT) 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 -> { else -> {
Toast.makeText(context, "Action ${actionData.actionId} not implemented", Toast.LENGTH_LONG).show() 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.net.Uri
import android.text.TextUtils import android.text.TextUtils
import androidx.annotation.IdRes
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import com.airbnb.mvrx.FragmentViewModelContext import com.airbnb.mvrx.FragmentViewModelContext
@ -32,6 +33,8 @@ import im.vector.matrix.android.api.MatrixPatterns
import im.vector.matrix.android.api.session.Session import im.vector.matrix.android.api.session.Session
import im.vector.matrix.android.api.session.content.ContentAttachmentData import im.vector.matrix.android.api.session.content.ContentAttachmentData
import im.vector.matrix.android.api.session.events.model.EventType import im.vector.matrix.android.api.session.events.model.EventType
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.events.model.toModel
import im.vector.matrix.android.api.session.file.FileService import im.vector.matrix.android.api.session.file.FileService
import im.vector.matrix.android.api.session.room.model.Membership import im.vector.matrix.android.api.session.room.model.Membership
@ -43,6 +46,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.attachments.toElementToDecrypt
import im.vector.matrix.android.internal.crypto.model.event.EncryptedEventContent import im.vector.matrix.android.internal.crypto.model.event.EncryptedEventContent
import im.vector.matrix.rx.rx import im.vector.matrix.rx.rx
import im.vector.riotx.R
import im.vector.riotx.core.extensions.postLiveEvent import im.vector.riotx.core.extensions.postLiveEvent
import im.vector.riotx.core.intent.getFilenameFromUri import im.vector.riotx.core.intent.getFilenameFromUri
import im.vector.riotx.core.platform.VectorViewModel import im.vector.riotx.core.platform.VectorViewModel
@ -123,6 +127,10 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
is RoomDetailActions.DownloadFile -> handleDownloadFile(action) is RoomDetailActions.DownloadFile -> handleDownloadFile(action)
is RoomDetailActions.NavigateToEvent -> handleNavigateToEvent(action) is RoomDetailActions.NavigateToEvent -> handleNavigateToEvent(action)
is RoomDetailActions.HandleTombstoneEvent -> handleTombstoneEvent(action) is RoomDetailActions.HandleTombstoneEvent -> handleTombstoneEvent(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") else -> Timber.e("Unhandled Action: $action")
} }
} }
@ -186,6 +194,20 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
get() = _downloadedFileEvent 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 METHODS *****************************************************************************
private fun handleSendMessage(action: RoomDetailActions.SendMessage) { private fun handleSendMessage(action: RoomDetailActions.SendMessage) {
@ -419,7 +441,7 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
} }
private fun handleEventDisplayed(action: RoomDetailActions.EventDisplayed) { 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) displayedEventsObservable.accept(action)
} }
//We need to update this with the related m.replace also (to move read receipt) //We need to update this with the related m.replace also (to move read receipt)
@ -553,6 +575,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() { private fun observeEventDisplayedActions() {
// We are buffering scroll events for one second // We are buffering scroll events for one second
// and keep the most recent one to set the read receipt on. // 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) { private fun drawReplyButton(canvas: Canvas, itemView: View) {
//Timber.v("drawReplyButton")
Timber.v("drawReplyButton")
val translationX = Math.abs(itemView.translationX) val translationX = Math.abs(itemView.translationX)
val newTime = System.currentTimeMillis() val newTime = System.currentTimeMillis()
val dt = Math.min(17, newTime - lastReplyButtonAnimationTime) val dt = Math.min(17, newTime - lastReplyButtonAnimationTime)

View File

@ -138,6 +138,19 @@ class MessageActionsBottomSheet : VectorBaseBottomSheetDialogFragment() {
} }
quickReactBottomDivider.isVisible = it.canReact() quickReactBottomDivider.isVisible = it.canReact()
bottom_sheet_quick_reaction_container.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 return@withState
} }

View File

@ -20,6 +20,7 @@ import com.squareup.inject.assisted.Assisted
import com.squareup.inject.assisted.AssistedInject import com.squareup.inject.assisted.AssistedInject
import im.vector.matrix.android.api.session.Session 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.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.events.model.toModel
import im.vector.matrix.android.api.session.room.model.message.MessageContent import im.vector.matrix.android.api.session.room.model.message.MessageContent
import im.vector.matrix.android.api.session.room.model.message.MessageImageContent 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_REPLY = "reply"
const val ACTION_SHARE = "share" const val ACTION_SHARE = "share"
const val ACTION_RESEND = "resend" const val ACTION_RESEND = "resend"
const val ACTION_REMOVE = "remove"
const val ACTION_DELETE = "delete" const val ACTION_DELETE = "delete"
const val ACTION_CANCEL = "cancel"
const val VIEW_SOURCE = "VIEW_SOURCE" const val VIEW_SOURCE = "VIEW_SOURCE"
const val VIEW_DECRYPTED_SOURCE = "VIEW_DECRYPTED_SOURCE" const val VIEW_DECRYPTED_SOURCE = "VIEW_DECRYPTED_SOURCE"
const val ACTION_COPY_PERMALINK = "ACTION_COPY_PERMALINK" const val ACTION_COPY_PERMALINK = "ACTION_COPY_PERMALINK"
@ -110,56 +113,57 @@ class MessageMenuViewModel @AssistedInject constructor(@Assisted initialState: M
?: event.root.getClearContent().toModel() ?: event.root.getClearContent().toModel()
val type = messageContent?.type val type = messageContent?.type
val actions = if (!event.sendState.isSent()) { return if (event.root.sendState.hasFailed()) {
//Resend and Delete arrayListOf<SimpleAction>().apply {
listOf<SimpleAction>( if (canRetry(event)) {
// SimpleAction(ACTION_RESEND, R.string.resend, R.drawable.ic_send, event.root.eventId), this.add(SimpleAction(ACTION_RESEND, R.string.global_retry, R.drawable.ic_refresh_cw, eventId))
// //TODO delete icon }
// SimpleAction(ACTION_DELETE, R.string.delete, R.drawable.ic_delete, event.root.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 { } else {
arrayListOf<SimpleAction>().apply { arrayListOf<SimpleAction>().apply {
if (event.sendState == SendState.SENDING) {
//TODO add cancel?
return@apply
}
//TODO is downloading attachement?
if (!event.root.isRedacted()) { if (!event.root.isRedacted()) {
if (canReply(event, messageContent)) { 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)) { 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)) { 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)) { if (canCopy(type)) {
//TODO copy images? html? see ClipBoard //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()) { 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)) { 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)) { 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 (canShare(type)) {
if (messageContent is MessageImageContent) { if (messageContent is MessageImageContent) {
this.add( add(
SimpleAction(ACTION_SHARE, SimpleAction(ACTION_SHARE,
R.string.share, R.drawable.ic_share, R.string.share, R.drawable.ic_share,
session.contentUrlResolver().resolveFullSize(messageContent.url)) 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 //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()) { if (event.isEncrypted()) {
val decryptedContent = event.root.toClearContentStringWithIndent() val decryptedContent = event.root.toClearContentStringWithIndent()
?: stringProvider.getString(R.string.encryption_information_decryption_error) ?: 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) { if (session.myUserId != event.root.senderId && event.root.getClearType() == EventType.MESSAGE) {
//not sent by me //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 { private fun canReply(event: TimelineEvent, messageContent: MessageContent?): Boolean {
//Only event of type Event.EVENT_TYPE_MESSAGE are supported for the moment //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 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 { private fun canViewReactions(event: TimelineEvent): Boolean {
//Only event of type Event.EVENT_TYPE_MESSAGE are supported for the moment //Only event of type Event.EVENT_TYPE_MESSAGE are supported for the moment
if (event.root.getClearType() != EventType.MESSAGE) return false 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( val informationData = MessageInformationData(
eventId = event.root.eventId ?: "?", eventId = event.root.eventId ?: "?",
senderId = event.root.senderId ?: "", senderId = event.root.senderId ?: "",
sendState = event.sendState, sendState = event.root.sendState,
avatarUrl = event.senderAvatar(), avatarUrl = event.senderAvatar(),
memberName = event.senderName(), memberName = event.senderName(),
showInformation = false showInformation = false

View File

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

View File

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

View File

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

View File

@ -162,10 +162,15 @@ abstract class AbsMessageItem<H : AbsMessageItem.Holder> : BaseEventItem<H>() {
return true 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() root.isClickable = informationData.sendState.isSent()
val state = if (informationData.hasPendingEdits) SendState.UNSENT else informationData.sendState val state = if (informationData.hasPendingEdits) SendState.UNSENT else informationData.sendState
textView?.setTextColor(colorProvider.getMessageTextColor(state)) 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) { 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) { override fun bind(holder: Holder) {
super.bind(holder) super.bind(holder)
imageContentRenderer.render(mediaData, ImageContentRenderer.Mode.THUMBNAIL, holder.imageView) 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.setOnClickListener(clickListener)
holder.imageView.setOnLongClickListener(longClickListener) holder.imageView.setOnLongClickListener(longClickListener)
ViewCompat.setTransitionName(holder.imageView,"imagePreview_${id()}") ViewCompat.setTransitionName(holder.imageView,"imagePreview_${id()}")
holder.mediaContentView.setOnClickListener(cellClickListener) holder.mediaContentView.setOnClickListener(cellClickListener)
holder.mediaContentView.setOnLongClickListener(longClickListener) holder.mediaContentView.setOnLongClickListener(longClickListener)
// The sending state color will be apply to the progress text // 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 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 playContentView by bind<ImageView>(R.id.messageMediaPlayView)
val mediaContentView by bind<ViewGroup>(R.id.messageContentMedia) val mediaContentView by bind<ViewGroup>(R.id.messageContentMedia)
val failedToSendIndicator by bind<ImageView>(R.id.messageFailToSendIndicator)
} }
companion object { companion object {

View File

@ -64,7 +64,7 @@ class MessageInformationDataFactory @Inject constructor(private val timelineDate
return MessageInformationData( return MessageInformationData(
eventId = eventId, eventId = eventId,
senderId = event.root.senderId ?: "", senderId = event.root.senderId ?: "",
sendState = event.sendState, sendState = event.root.sendState,
time = time, time = time,
avatarUrl = avatarUrl, avatarUrl = avatarUrl,
memberName = formattedMemberName, 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" /> tools:text="Friday 8pm" />
</androidx.constraintlayout.widget.ConstraintLayout> </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 <View
android:id="@+id/quickReactTopDivider" android:id="@+id/quickReactTopDivider"
android:layout_width="match_parent" android:layout_width="match_parent"

View File

@ -93,8 +93,6 @@
android:paddingTop="16dp" android:paddingTop="16dp"
android:paddingBottom="16dp" android:paddingBottom="16dp"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/chipGroupScrollView" /> app:layout_constraintTop_toBottomOf="@+id/chipGroupScrollView" />
@ -113,7 +111,6 @@
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginStart="16dp" android:layout_marginStart="16dp"
android:layout_marginLeft="16dp"
android:layout_marginTop="8dp" android:layout_marginTop="8dp"
android:layout_marginBottom="8dp" android:layout_marginBottom="8dp"
android:minHeight="@dimen/layout_touch_size" android:minHeight="@dimen/layout_touch_size"

View File

@ -15,7 +15,19 @@
app:layout_constraintHorizontal_bias="0" app:layout_constraintHorizontal_bias="0"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="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 <ImageView
android:id="@+id/messageMediaPlayView" 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_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_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_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_resend_unsent_messages">Resend unsent messages</string>
<string name="room_delete_unsent_messages">Delete unsent messages</string> <string name="room_delete_unsent_messages">Delete unsent messages</string>
<string name="room_message_file_not_found">File not found</string> <string name="room_message_file_not_found">File not found</string>