Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,12 @@ interface ChatMessageRepository : LifecycleAwareManager {
*/
suspend fun deleteTempMessageByReferenceId(referenceId: String): Boolean

/**
* Marks the upload placeholder for [referenceId] as failed, but only while it's still PENDING.
* Does nothing if there is no such placeholder (yet).
*/
suspend fun markUploadPlaceholderFailed(referenceId: String)

/**
* Writes the new text into the local database before the server is asked, so the chat renders the
* edit right away, and restores the previous text if the request fails or the server rejects the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -793,7 +793,12 @@ class OfflineFirstChatRepository @Inject constructor(
sendStatus = SendStatus.PENDING,
silent = false
)
chatDao.upsertChatMessage(entity)
// The worker is enqueued before this runs, so a sync may already have stored the server copy.
// upsertChatMessagesAndDeleteTemp removed temp messages only at that time, so a placeholder
// stored now would stay pending next to the real message.
if (!chatDao.upsertTempChatMessageUnlessServerCopyExists(entity)) {
Log.d(TAG, "addUploadPlaceholderMessage: skipped, server copy of $referenceId already stored")
}
} catch (e: Exception) {
Log.e(TAG, "addUploadPlaceholderMessage failed for referenceId=$referenceId", e)
emit(Result.failure(e))
Expand All @@ -803,6 +808,14 @@ class OfflineFirstChatRepository @Inject constructor(
override suspend fun deleteTempMessageByReferenceId(referenceId: String): Boolean =
chatDao.deleteTempChatMessageIfPending(internalConversationId, referenceId) > 0

override suspend fun markUploadPlaceholderFailed(referenceId: String) {
val entity = chatDao.getTempMessageForConversation(internalConversationId, referenceId, null).firstOrNull()
if (entity?.sendStatus == SendStatus.PENDING) {
// called from viewModelScope: updateChatMessage is a blocking Room call
withContext(Dispatchers.IO) { chatDao.updateChatMessage(entity.copy(sendStatus = SendStatus.FAILED)) }
}
}

override suspend fun editChatMessage(
credentials: String,
url: String,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import com.nextcloud.talk.jobs.ReadMarkerSyncWorker
import com.nextcloud.talk.jobs.SendMessageWorker
import com.nextcloud.talk.jobs.ShareOperationWorker
import androidx.lifecycle.asFlow
import androidx.work.WorkInfo
import androidx.work.WorkManager
import com.nextcloud.talk.jobs.UploadAndShareFilesWorker
import com.nextcloud.talk.logger.Logger
Expand Down Expand Up @@ -2373,9 +2374,42 @@ class ChatViewModel @AssistedInject constructor(
try {
require(fileUri.isNotEmpty())

val internalConversationId = "${currentUser.id}@$chatRoomToken"
val enqueueUpload = {
UploadAndShareFilesWorker.upload(
userId = currentUser.id!!,
fileUri = fileUri,
roomToken = room,
conversationName = displayName,
metaData = metaData,
referenceId = referenceId,
internalConversationId = internalConversationId,
compressImages = compressImages,
allowUpdate = allowUpdate
)
}

// enqueued right here, in the order of the calls: the queue of a conversation is chained, and the
// slow lookup of the file info below must neither reorder the files nor die with the view model
val workerId = enqueueUpload()
if (!isVoiceMessage) {
val (fileName, mimeType, fileSize) = resolveFileInfo(fileUri)
uploadReferenceToWorkId[referenceId] = workerId
_uploadedLocalPreviewMap.update { it + (referenceId to fileUri) }

viewModelScope.launch {
// getType/query are blocking binder calls into the provider's process (e.g. Nextcloud Files)
val (fileName, mimeType, fileSize) = withContext(Dispatchers.IO) {
try {
resolveFileInfo(fileUri)
} catch (e: IllegalArgumentException) {
// unknown or revoked uri: the worker reports the failed upload, the placeholder stays
Log.w(TAG, "Could not resolve file info of the file to send", e)
Triple(fileUri.toUri().lastPathSegment.orEmpty(), null, 0L)
} catch (e: SecurityException) {
Log.w(TAG, "No access to the file info of the file to send", e)
Triple(fileUri.toUri().lastPathSegment.orEmpty(), null, 0L)
}
}
chatRepository.addUploadPlaceholderMessage(
localFileUri = fileUri,
fileName = fileName,
Expand All @@ -2384,26 +2418,11 @@ class ChatViewModel @AssistedInject constructor(
fileSize = fileSize,
referenceId = referenceId
).collect {}
}
}

val internalConversationId = "${currentUser.id}@$chatRoomToken"
val workerId = UploadAndShareFilesWorker.upload(
userId = currentUser.id!!,
fileUri = fileUri,
roomToken = room,
conversationName = displayName,
metaData = metaData,
referenceId = referenceId,
internalConversationId = internalConversationId,
compressImages = compressImages,
allowUpdate = allowUpdate
)

if (!isVoiceMessage) {
uploadReferenceToWorkId[referenceId] = workerId
_uploadedLocalPreviewMap.update { it + (referenceId to fileUri) }
observeUploadProgress(workerId, referenceId)
// observed only now: a failure the worker reported before the placeholder existed is
// delivered with the current state and marks the placeholder that was just added
observeUploadProgress(workerId, referenceId)
}
}
} catch (e: IllegalArgumentException) {
Log.e(javaClass.simpleName, "Something went wrong when trying to upload file", e)
Expand Down Expand Up @@ -2436,6 +2455,9 @@ class ChatViewModel @AssistedInject constructor(
if (progress >= 0) {
_uploadProgressMap.update { it + (referenceId to progress) }
}
if (workInfo.state == WorkInfo.State.FAILED) {
viewModelScope.launch { chatRepository.markUploadPlaceholderFailed(referenceId) }
}
if (workInfo.state.isFinished) {
_uploadProgressMap.update { it - referenceId }
uploadReferenceToWorkId.remove(referenceId)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,37 @@ interface ChatMessagesDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertChatMessage(chatMessage: ChatMessageEntity)

@Query(
"""
SELECT EXISTS(
SELECT 1
FROM ChatMessages
WHERE internalConversationId = :internalConversationId
AND referenceId = :referenceId
AND isTemporary = 0
)
"""
)
suspend fun hasServerCopyOfReferenceId(internalConversationId: String, referenceId: String): Boolean

/**
* Stores a temporary message unless the server copy of it is already stored. The counterpart of
* [upsertChatMessagesAndDeleteTemp], which drops the temporary message once the server copy arrives:
* a temporary message stored after that would never be dropped again. Check and insert share one
* transaction, so a sync cannot store the server copy in between.
*
* @return true if the message was stored, false if the server copy already exists
*/
@Transaction
suspend fun upsertTempChatMessageUnlessServerCopyExists(chatMessage: ChatMessageEntity): Boolean {
val referenceId = chatMessage.referenceId
if (referenceId != null && hasServerCopyOfReferenceId(chatMessage.internalConversationId, referenceId)) {
return false
}
upsertChatMessage(chatMessage)
return true
}

@Deprecated("use getChatMessageEntity")
@Query(
"""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@ class DummyChatMessagesDaoImpl : ChatMessagesDao {
/* */
}

override suspend fun hasServerCopyOfReferenceId(internalConversationId: String, referenceId: String): Boolean =
false

override fun getChatMessageForConversation(
internalConversationId: String,
messageId: Long
Expand Down
Loading
Loading