Skip to content

Commit 7978ebe

Browse files
committed
Show file upload progress and placeholder media message in chat.
Signed-off-by: Jens Zalzala <jens@shakingearthdigital.com>
1 parent 4fc4f79 commit 7978ebe

13 files changed

Lines changed: 502 additions & 33 deletions

File tree

app/src/main/java/com/nextcloud/talk/api/NcApi.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -386,7 +386,8 @@ Observable<GenericOverall> createRemoteShare(@Nullable @Header("Authorization")
386386
@Field("path") String remotePath,
387387
@Field("shareWith") String roomToken,
388388
@Field("shareType") String shareType,
389-
@Field("talkMetaData") String talkMetaData);
389+
@Field("talkMetaData") String talkMetaData,
390+
@Field("referenceId") String referenceId);
390391

391392
@FormUrlEncoded
392393
@PUT

app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,7 @@ import com.nextcloud.talk.ui.StatusDrawable
157157
import com.nextcloud.talk.ui.chat.ChatView
158158
import com.nextcloud.talk.ui.chat.ChatViewCallbacks
159159
import com.nextcloud.talk.ui.chat.ChatViewState
160+
import com.nextcloud.talk.ui.chat.LocalUploadProgressProvider
160161
import com.nextcloud.talk.ui.dialog.DateTimeCompose
161162
import com.nextcloud.talk.ui.dialog.FileAttachmentPreviewFragment
162163
import com.nextcloud.talk.ui.dialog.GetPinnedOptionsDialog
@@ -605,10 +606,13 @@ class ChatActivity :
605606
val listState = rememberLazyListState()
606607
SideEffect { chatListState = listState }
607608

609+
val uploadProgressMap by chatViewModel.uploadProgressMap.collectAsStateWithLifecycle()
610+
608611
CompositionLocalProvider(
609612
LocalViewThemeUtils provides viewThemeUtils,
610613
LocalMessageUtils provides messageUtils,
611-
LocalOpenGraphFetcher provides { url -> chatViewModel.fetchOpenGraph(url) }
614+
LocalOpenGraphFetcher provides { url -> chatViewModel.fetchOpenGraph(url) },
615+
LocalUploadProgressProvider provides { refId -> uploadProgressMap[refId] }
612616
) {
613617
val isOneToOneConversation = uiState.isOneToOneConversation
614618
Log.d(TAG, "isOneToOneConversation=" + isOneToOneConversation)
@@ -635,7 +639,8 @@ class ChatActivity :
635639
onReactionClick = { messageId, emoji -> handleReactionClick(messageId, emoji) },
636640
onReactionLongClick = { messageId -> openReactionsDialog(messageId) },
637641
onOpenThreadClick = { messageId -> openThread(messageId.toLong()) },
638-
onLoadQuotedMessageClick = { messageId -> onLoadQuotedMessage(messageId) }
642+
onLoadQuotedMessageClick = { messageId -> onLoadQuotedMessage(messageId) },
643+
onCancelUpload = { referenceId -> chatViewModel.cancelUpload(referenceId) }
639644
),
640645
listState = listState
641646
)

app/src/main/java/com/nextcloud/talk/chat/data/ChatMessageRepository.kt

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,17 @@ interface ChatMessageRepository : LifecycleAwareManager {
117117
referenceId: String
118118
): Flow<Result<ChatMessage?>>
119119

120+
@Suppress("LongParameterList")
121+
suspend fun addUploadPlaceholderMessage(
122+
localFileUri: String,
123+
caption: String,
124+
mimeType: String?,
125+
fileSize: Long,
126+
referenceId: String
127+
): Flow<Result<ChatMessage?>>
128+
129+
suspend fun deleteTempMessageByReferenceId(referenceId: String)
130+
120131
suspend fun editChatMessage(credentials: String, url: String, text: String): Flow<Result<ChatOverallSingleMessage>>
121132

122133
suspend fun editTempChatMessage(message: ChatMessage, editedMessageText: String): Flow<Boolean>

app/src/main/java/com/nextcloud/talk/chat/data/network/OfflineFirstChatRepository.kt

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -855,6 +855,75 @@ class OfflineFirstChatRepository @Inject constructor(
855855
}
856856
}
857857

858+
@Suppress("Detekt.TooGenericExceptionCaught", "LongMethod")
859+
override suspend fun addUploadPlaceholderMessage(
860+
localFileUri: String,
861+
caption: String,
862+
mimeType: String?,
863+
fileSize: Long,
864+
referenceId: String
865+
): Flow<Result<ChatMessage?>> =
866+
flow {
867+
try {
868+
val currentTimeMillis = System.currentTimeMillis()
869+
870+
// Use the first 15 hex chars so the value always fits in a signed Long.
871+
// Use referenceId.hashCode() as the placeholder id so that:
872+
// 1. It is unique per file even when multiple files are selected simultaneously
873+
// 2. It fits in an Int, so it survives the Long→Int cast in ChatMessageUi.id without
874+
// truncation, keeping DB lookups consistent when the message is tapped.
875+
// 3. It is always positive, because getMessagesEqualOrNewerThan expects it to be larger
876+
// than oldestMessageId
877+
val placeholderId = (referenceId.hashCode().toLong() and 0x7FFF_FFFFL)
878+
879+
Log.d(TAG, "addUploadPlaceholderMessage: referenceId=$referenceId placeholderId=$placeholderId caption=$caption")
880+
881+
val fileParams = hashMapOf<String?, String?>(
882+
"type" to "file",
883+
"name" to caption,
884+
"mimetype" to (mimeType ?: ""),
885+
"size" to fileSize.toString(),
886+
"path" to localFileUri
887+
)
888+
val messageParameters = hashMapOf<String?, HashMap<String?, String?>>(
889+
"file" to fileParams
890+
)
891+
892+
val entity = ChatMessageEntity(
893+
internalId = "$internalConversationId@_temp_$referenceId",
894+
internalConversationId = internalConversationId,
895+
id = placeholderId,
896+
threadId = threadId,
897+
message = "{file}",
898+
deleted = false,
899+
token = conversationModel.token,
900+
actorId = currentUser.userId!!,
901+
actorType = EnumActorTypeConverter().convertToString(Participant.ActorType.USERS),
902+
accountId = currentUser.id!!,
903+
messageParameters = messageParameters,
904+
messageType = "comment",
905+
parentMessageId = null,
906+
systemMessageType = ChatMessage.SystemMessageType.DUMMY,
907+
replyable = false,
908+
timestamp = currentTimeMillis / MILLIES,
909+
expirationTimestamp = 0,
910+
actorDisplayName = currentUser.displayName!!,
911+
referenceId = referenceId,
912+
isTemporary = true,
913+
sendStatus = SendStatus.PENDING,
914+
silent = false
915+
)
916+
chatDao.upsertChatMessage(entity)
917+
} catch (e: Exception) {
918+
Log.e(TAG, "addUploadPlaceholderMessage failed for referenceId=$referenceId", e)
919+
emit(Result.failure(e))
920+
}
921+
}
922+
923+
override suspend fun deleteTempMessageByReferenceId(referenceId: String) {
924+
chatDao.deleteTempChatMessages(internalConversationId, listOf(referenceId))
925+
}
926+
858927
@Suppress("Detekt.TooGenericExceptionCaught")
859928
override suspend fun editChatMessage(
860929
credentials: String,

app/src/main/java/com/nextcloud/talk/chat/ui/model/ChatMessageUi.kt

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,8 @@ data class ChatMessageUi(
4343
val reactions: List<MessageReactionUi> = emptyList(),
4444
val isEdited: Boolean = false,
4545
val parentMessage: ChatMessageUi? = null,
46-
val replyable: Boolean = false
46+
val replyable: Boolean = false,
47+
val referenceId: String? = null
4748
)
4849

4950
data class MessageReactionUi(val emoji: String, val amount: Int, val isSelfReaction: Boolean)
@@ -56,6 +57,13 @@ sealed interface MessageTypeContent {
5657

5758
data class Media(val previewUrl: String?, val drawableResourceId: Int) : MessageTypeContent
5859

60+
data class UploadingMedia(
61+
val localFileUri: String,
62+
val caption: String,
63+
val mimeType: String?,
64+
val drawableResourceId: Int
65+
) : MessageTypeContent
66+
5967
data class Geolocation(val id: String, val name: String, val lat: Double, val lon: Double) : MessageTypeContent
6068

6169
data class Poll(val pollId: String, val pollName: String) : MessageTypeContent
@@ -125,7 +133,8 @@ fun ChatMessage.toUiModel(
125133
lastCommonReadMessageId = 0,
126134
parentMessage = null
127135
),
128-
replyable = replyable
136+
replyable = replyable,
137+
referenceId = referenceId
129138
)
130139

131140
private fun ChatMessage.normalizeMessageParameters(): Map<String, Map<String, String>> =
@@ -173,6 +182,8 @@ fun resolveStatusIcon(
173182
): MessageStatusIcon {
174183
val status = if (sendStatus == SendStatus.FAILED) {
175184
MessageStatusIcon.FAILED
185+
} else if (isTemporary && sendStatus == SendStatus.SENT_PENDING_ACK) {
186+
MessageStatusIcon.SENT
176187
} else if (isTemporary) {
177188
MessageStatusIcon.SENDING
178189
} else if (jsonMessageId <= lastCommonReadMessageId) {
@@ -188,6 +199,8 @@ fun getMessageTypeContent(user: User, message: ChatMessage): MessageTypeContent?
188199
MessageTypeContent.SystemMessage
189200
} else if (message.isVoiceMessage) {
190201
getVoiceContent(message)
202+
} else if (message.hasFileAttachment && message.isTemporary) {
203+
getUploadingMediaContent(message)
191204
} else if (message.hasFileAttachment) {
192205
getMediaContent(user, message)
193206
} else if (message.hasGeoLocation) {
@@ -202,6 +215,17 @@ fun getMessageTypeContent(user: User, message: ChatMessage): MessageTypeContent?
202215
?: MessageTypeContent.RegularText
203216
}
204217

218+
fun getUploadingMediaContent(message: ChatMessage): MessageTypeContent.UploadingMedia {
219+
val mimetype = message.fileParameters.mimetype
220+
val drawableResourceId = DrawableUtils.getDrawableResourceIdForMimeType(mimetype)
221+
return MessageTypeContent.UploadingMedia(
222+
localFileUri = message.fileParameters.path.orEmpty(),
223+
caption = message.fileParameters.name.orEmpty(),
224+
mimeType = mimetype.takeIf { !it.isNullOrEmpty() },
225+
drawableResourceId = drawableResourceId
226+
)
227+
}
228+
205229
fun getMediaContent(user: User, message: ChatMessage): MessageTypeContent.Media {
206230
val previewUrl = getPreviewImageUrl(user, message)
207231
val mimetype = message.fileParameters.mimetype

app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt

Lines changed: 82 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ package com.nextcloud.talk.chat.viewmodels
1010
import android.content.Context
1111
import android.net.Uri
1212
import android.os.Bundle
13+
import android.provider.OpenableColumns
1314
import android.util.Log
1415
import androidx.lifecycle.DefaultLifecycleObserver
1516
import androidx.lifecycle.LifecycleOwner
@@ -35,6 +36,9 @@ import com.nextcloud.talk.data.database.mappers.toDomainModel
3536
import com.nextcloud.talk.data.database.model.ChatMessageEntity
3637
import com.nextcloud.talk.data.user.model.User
3738
import com.nextcloud.talk.extensions.toIntOrZero
39+
import androidx.lifecycle.asFlow
40+
import androidx.work.WorkManager
41+
import com.nextcloud.talk.application.NextcloudTalkApplication
3842
import com.nextcloud.talk.jobs.UploadAndShareFilesWorker
3943
import com.nextcloud.talk.models.MessageDraft
4044
import com.nextcloud.talk.models.domain.ConversationModel
@@ -100,7 +104,9 @@ import retrofit2.HttpException
100104
import java.io.File
101105
import java.io.IOException
102106
import java.time.LocalDate
107+
import java.util.UUID
103108
import javax.inject.Inject
109+
import androidx.core.net.toUri
104110

105111
@Suppress("TooManyFunctions", "LongParameterList")
106112
class ChatViewModel @AssistedInject constructor(
@@ -145,6 +151,21 @@ class ChatViewModel @AssistedInject constructor(
145151
var hiddenUpcomingEvent: String? = null
146152
lateinit var participantPermissions: ParticipantPermissions
147153

154+
private val _uploadProgressMap = MutableStateFlow<Map<String, Int>>(emptyMap())
155+
val uploadProgressMap: StateFlow<Map<String, Int>> = _uploadProgressMap
156+
157+
// Maps referenceId -> fileUri for cancellation support
158+
private val uploadReferenceToUri = mutableMapOf<String, String>()
159+
160+
fun cancelUpload(referenceId: String) {
161+
val fileUri = uploadReferenceToUri.remove(referenceId) ?: return
162+
WorkManager.getInstance(NextcloudTalkApplication.sharedApplication!!).cancelUniqueWork(fileUri)
163+
viewModelScope.launch {
164+
chatRepository.deleteTempMessageByReferenceId(referenceId)
165+
}
166+
_uploadProgressMap.update { it - referenceId }
167+
}
168+
148169
fun getChatRepository(): ChatMessageRepository = chatRepository
149170

150171
override fun onResume(owner: LifecycleOwner) {
@@ -1392,23 +1413,82 @@ class ChatViewModel @AssistedInject constructor(
13921413
metaDataMap["caption"] = caption
13931414
}
13941415

1416+
val referenceId = UUID.randomUUID().toString().replace("-", "")
1417+
metaDataMap["referenceId"] = referenceId
1418+
13951419
val metaData = Gson().toJson(metaDataMap)
13961420

13971421
room = if (roomToken == "") chatRoomToken else roomToken
13981422

13991423
try {
14001424
require(fileUri.isNotEmpty())
1401-
UploadAndShareFilesWorker.upload(
1425+
1426+
if (!isVoiceMessage) {
1427+
val (fileName, mimeType, fileSize) = resolveFileInfo(fileUri)
1428+
viewModelScope.launch {
1429+
chatRepository.addUploadPlaceholderMessage(
1430+
localFileUri = fileUri,
1431+
caption = caption.ifEmpty { fileName },
1432+
mimeType = mimeType,
1433+
fileSize = fileSize,
1434+
referenceId = referenceId
1435+
).collect {}
1436+
}
1437+
}
1438+
1439+
val internalConversationId = "${currentUser.id}@$chatRoomToken"
1440+
val workerId = UploadAndShareFilesWorker.upload(
14021441
fileUri,
14031442
room,
14041443
displayName,
1405-
metaData
1444+
metaData,
1445+
referenceId,
1446+
internalConversationId
14061447
)
1448+
1449+
if (!isVoiceMessage) {
1450+
uploadReferenceToUri[referenceId] = fileUri
1451+
observeUploadProgress(workerId, referenceId)
1452+
}
14071453
} catch (e: IllegalArgumentException) {
14081454
Log.e(javaClass.simpleName, "Something went wrong when trying to upload file", e)
14091455
}
14101456
}
14111457

1458+
private fun resolveFileInfo(fileUri: String): Triple<String, String?, Long> {
1459+
val uri = fileUri.toUri()
1460+
val mimeType = NextcloudTalkApplication.sharedApplication!!.contentResolver.getType(uri)
1461+
val cursor = NextcloudTalkApplication.sharedApplication!!.contentResolver.query(uri, null, null, null, null)
1462+
cursor?.use {
1463+
val nameIndex = it.getColumnIndex(OpenableColumns.DISPLAY_NAME)
1464+
val sizeIndex = it.getColumnIndex(OpenableColumns.SIZE)
1465+
if (it.moveToFirst()) {
1466+
val name = if (nameIndex >= 0) it.getString(nameIndex).orEmpty() else uri.lastPathSegment.orEmpty()
1467+
val size = if (sizeIndex >= 0) it.getLong(sizeIndex) else 0L
1468+
return Triple(name, mimeType, size)
1469+
}
1470+
}
1471+
return Triple(uri.lastPathSegment.orEmpty(), mimeType, 0L)
1472+
}
1473+
1474+
private fun observeUploadProgress(workerId: UUID, referenceId: String) {
1475+
WorkManager.getInstance(NextcloudTalkApplication.sharedApplication!!)
1476+
.getWorkInfoByIdLiveData(workerId)
1477+
.asFlow()
1478+
.onEach { workInfo ->
1479+
if (workInfo == null) return@onEach
1480+
val progress = workInfo.progress.getInt(UploadAndShareFilesWorker.PROGRESS_KEY, -1)
1481+
if (progress >= 0) {
1482+
_uploadProgressMap.update { it + (referenceId to progress) }
1483+
}
1484+
if (workInfo.state.isFinished) {
1485+
_uploadProgressMap.update { it - referenceId }
1486+
uploadReferenceToUri.remove(referenceId)
1487+
}
1488+
}
1489+
.launchIn(viewModelScope)
1490+
}
1491+
14121492
fun postToRecordTouchObserver(float: Float) {
14131493
_recordTouchObserver.postValue(float)
14141494
}

app/src/main/java/com/nextcloud/talk/jobs/ShareOperationWorker.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,8 @@ class ShareOperationWorker(context: Context, workerParams: WorkerParameters) : W
5252
filePath,
5353
roomToken,
5454
"10",
55-
metaData
55+
metaData,
56+
"" // no reference id
5657
)
5758
.subscribeOn(Schedulers.io())
5859
.blockingSubscribe(

0 commit comments

Comments
 (0)