diff --git a/app/src/main/java/com/nextcloud/talk/chat/data/ChatMessageRepository.kt b/app/src/main/java/com/nextcloud/talk/chat/data/ChatMessageRepository.kt index fb9ab78031..ebe226929e 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/data/ChatMessageRepository.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/data/ChatMessageRepository.kt @@ -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 diff --git a/app/src/main/java/com/nextcloud/talk/chat/data/network/OfflineFirstChatRepository.kt b/app/src/main/java/com/nextcloud/talk/chat/data/network/OfflineFirstChatRepository.kt index 5b871c2ce3..bb15517f7f 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/data/network/OfflineFirstChatRepository.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/data/network/OfflineFirstChatRepository.kt @@ -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)) @@ -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, diff --git a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt index 918cf62a68..ebd0fa8f9e 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt @@ -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 @@ -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, @@ -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) @@ -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) diff --git a/app/src/main/java/com/nextcloud/talk/data/database/dao/ChatMessagesDao.kt b/app/src/main/java/com/nextcloud/talk/data/database/dao/ChatMessagesDao.kt index 3bdae24ee9..eaeb3acac7 100644 --- a/app/src/main/java/com/nextcloud/talk/data/database/dao/ChatMessagesDao.kt +++ b/app/src/main/java/com/nextcloud/talk/data/database/dao/ChatMessagesDao.kt @@ -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( """ diff --git a/app/src/main/java/com/nextcloud/talk/utils/preview/ComposePreviewUtilsDaos.kt b/app/src/main/java/com/nextcloud/talk/utils/preview/ComposePreviewUtilsDaos.kt index f305eb5d7a..6d6bb33e5c 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/preview/ComposePreviewUtilsDaos.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/preview/ComposePreviewUtilsDaos.kt @@ -65,6 +65,9 @@ class DummyChatMessagesDaoImpl : ChatMessagesDao { /* */ } + override suspend fun hasServerCopyOfReferenceId(internalConversationId: String, referenceId: String): Boolean = + false + override fun getChatMessageForConversation( internalConversationId: String, messageId: Long diff --git a/app/src/test/java/com/nextcloud/talk/chat/data/network/OfflineFirstChatRepositoryUploadPlaceholderTest.kt b/app/src/test/java/com/nextcloud/talk/chat/data/network/OfflineFirstChatRepositoryUploadPlaceholderTest.kt new file mode 100644 index 0000000000..8df331f488 --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/chat/data/network/OfflineFirstChatRepositoryUploadPlaceholderTest.kt @@ -0,0 +1,232 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.chat.data.network + +import android.app.Application +import android.content.Context +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import com.nextcloud.talk.chat.data.model.ChatMessage +import com.nextcloud.talk.conversationlist.data.network.ConversationListUpdater +import com.nextcloud.talk.data.database.mappers.asEntity +import com.nextcloud.talk.data.database.model.ChatMessageEntity +import com.nextcloud.talk.data.database.model.SendStatus +import com.nextcloud.talk.data.network.NetworkMonitor +import com.nextcloud.talk.data.source.local.TalkDatabase +import com.nextcloud.talk.data.user.model.User +import com.nextcloud.talk.data.user.model.UserEntity +import com.nextcloud.talk.logger.Logger +import com.nextcloud.talk.models.domain.ConversationModel +import com.nextcloud.talk.models.json.chat.ChatMessageDto +import com.nextcloud.talk.models.json.conversations.ConversationDto +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Upload placeholders against a real in-memory database. The worker is enqueued before the placeholder + * exists, so a sync can store the server copy of the shared file first; the placeholder must then not + * be stored, because [com.nextcloud.talk.data.database.dao.ChatMessagesDao.upsertChatMessagesAndDeleteTemp] + * only drops temporary messages that exist at the time the server copy is written. + * + * The database does not allow main thread queries: the test thread is the main looper thread under + * Robolectric, so a blocking DAO call made by the repository on its caller's thread fails the test. + */ +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class, sdk = [33]) +class OfflineFirstChatRepositoryUploadPlaceholderTest { + + private lateinit var db: TalkDatabase + private lateinit var repository: OfflineFirstChatRepository + + private val logger: Logger = mock() + private val network: ChatNetworkDataSource = mock() + private val networkMonitor: NetworkMonitor = mock() + + @Before + fun setUp() { + val context = ApplicationProvider.getApplicationContext() + db = Room.inMemoryDatabaseBuilder(context, TalkDatabase::class.java).build() + runBlocking { + db.usersDao().saveUser(UserEntity(id = ACCOUNT_ID, userId = "me", username = "me", baseUrl = BASE_URL)) + db.conversationsDao().upsertConversations( + ACCOUNT_ID, + listOf(ConversationDto(token = ROOM_TOKEN).asEntity(ACCOUNT_ID)) + ) + } + whenever(networkMonitor.isOnline).thenReturn(MutableStateFlow(true)) + + val conversationListUpdater = + ConversationListUpdater(db.chatMessagesDao(), db.chatBlocksDao(), db.conversationsDao()) + repository = OfflineFirstChatRepository( + logger, + db.chatMessagesDao(), + db.chatBlocksDao(), + network, + networkMonitor, + ChatMessageSyncer( + db.chatMessagesDao(), + db.chatBlocksDao(), + network, + networkMonitor, + conversationListUpdater + ), + conversationListUpdater + ) + repository.initData(user(), "credentials", CHAT_URL, ROOM_TOKEN, null) + repository.updateConversation( + ConversationModel.mapToConversationModel(ConversationDto(token = ROOM_TOKEN), user()) + ) + } + + @After + fun tearDown() { + db.close() + } + + @Test + fun `placeholder is not stored when the server copy of the file message already exists`() = + runBlocking { + // the sync stored the shared file before the placeholder insert ran + val serverCopy = storeServerCopy(SERVER_MESSAGE_ID, REFERENCE_ID) + + val results = addPlaceholder(REFERENCE_ID) + + assertTrue(results.isEmpty()) + assertNull(tempMessage(REFERENCE_ID)) + assertEquals(serverCopy, storedMessage(SERVER_MESSAGE_ID)) + } + + @Test + fun `placeholder is stored pending when no server copy exists`() = + runBlocking { + val results = addPlaceholder(REFERENCE_ID) + + assertTrue(results.isEmpty()) + val placeholder = tempMessage(REFERENCE_ID) + assertNotNull(placeholder) + assertEquals(SendStatus.PENDING, placeholder!!.sendStatus) + } + + @Test + fun `server copy of another file does not block the placeholder`() = + runBlocking { + storeServerCopy(SERVER_MESSAGE_ID, OTHER_REFERENCE_ID) + + addPlaceholder(REFERENCE_ID) + + assertNotNull(tempMessage(REFERENCE_ID)) + } + + @Test + fun `server copy arriving after the placeholder still removes it`() = + runBlocking { + addPlaceholder(REFERENCE_ID) + assertNotNull(tempMessage(REFERENCE_ID)) + + storeServerCopy(SERVER_MESSAGE_ID, REFERENCE_ID) + + assertNull(tempMessage(REFERENCE_ID)) + assertNotNull(storedMessage(SERVER_MESSAGE_ID)) + } + + @Test + fun `marking a failed upload leaves the server copy unchanged`() = + runBlocking { + val serverCopy = storeServerCopy(SERVER_MESSAGE_ID, REFERENCE_ID) + + repository.markUploadPlaceholderFailed(REFERENCE_ID) + + assertNull(tempMessage(REFERENCE_ID)) + assertEquals(serverCopy, storedMessage(SERVER_MESSAGE_ID)) + } + + @Test + fun `marking a failed upload sets the pending placeholder to failed off the main thread`() = + runBlocking { + addPlaceholder(REFERENCE_ID) + + // called from viewModelScope, i.e. the main thread; the update must not run there + repository.markUploadPlaceholderFailed(REFERENCE_ID) + + assertEquals(SendStatus.FAILED, tempMessage(REFERENCE_ID)!!.sendStatus) + } + + private suspend fun addPlaceholder(referenceId: String) = + repository.addUploadPlaceholderMessage( + localFileUri = "content://provider/file", + fileName = "photo.jpg", + caption = "", + mimeType = "image/jpeg", + fileSize = FILE_SIZE, + referenceId = referenceId + ).toList() + + private suspend fun tempMessage(referenceId: String): ChatMessageEntity? = + db.chatMessagesDao().getTempMessageForConversation(INTERNAL_CONVERSATION_ID, referenceId, null).first() + + /** + * Stores the server copy the way the sync does: DTO -> [asEntity] -> upsertChatMessagesAndDeleteTemp. The + * mapper builds the conversation key itself, so the tests also prove it matches the repository's key. + * + * @return the stored row + */ + private suspend fun storeServerCopy(id: Long, referenceId: String): ChatMessageEntity { + val entity = ChatMessageDto( + id = id, + token = ROOM_TOKEN, + actorType = "users", + actorId = "me", + actorDisplayName = "Me", + message = "{file}", + messageType = "comment", + systemMessageType = ChatMessage.SystemMessageType.DUMMY, + referenceId = referenceId + ).asEntity(ACCOUNT_ID) + assertEquals(INTERNAL_CONVERSATION_ID, entity.internalConversationId) + db.chatMessagesDao().upsertChatMessagesAndDeleteTemp(entity.internalConversationId, listOf(entity)) + return storedMessage(id)!! + } + + private suspend fun storedMessage(id: Long): ChatMessageEntity? = + db.chatMessagesDao().getChatMessageEntity(INTERNAL_CONVERSATION_ID, id) + + private fun user(): User = + User( + id = ACCOUNT_ID, + userId = "me", + username = "me", + displayName = "Me", + baseUrl = BASE_URL + ) + + companion object { + private const val ACCOUNT_ID = 1L + private const val ROOM_TOKEN = "room1" + private const val INTERNAL_CONVERSATION_ID = "$ACCOUNT_ID@$ROOM_TOKEN" + private const val BASE_URL = "https://server.example.com" + private const val CHAT_URL = "$BASE_URL/ocs/v2.php/apps/spreed/api/v1/chat/$ROOM_TOKEN" + private const val REFERENCE_ID = "ref-file-1" + private const val OTHER_REFERENCE_ID = "ref-file-2" + private const val SERVER_MESSAGE_ID = 100L + private const val FILE_SIZE = 1024L + } +} diff --git a/app/src/test/java/com/nextcloud/talk/chat/viewmodels/ChatViewModelUploadFileTest.kt b/app/src/test/java/com/nextcloud/talk/chat/viewmodels/ChatViewModelUploadFileTest.kt new file mode 100644 index 0000000000..ff0aea2531 --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/chat/viewmodels/ChatViewModelUploadFileTest.kt @@ -0,0 +1,288 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.chat.viewmodels + +import android.content.ContentProvider +import android.content.ContentValues +import android.database.Cursor +import android.database.MatrixCursor +import android.net.Uri +import android.os.Looper +import android.provider.OpenableColumns +import androidx.lifecycle.viewModelScope +import androidx.work.WorkManager +import androidx.work.impl.WorkManagerImpl +import androidx.work.testing.WorkManagerTestInitHelper +import com.nextcloud.talk.application.NextcloudTalkApplication +import com.nextcloud.talk.chat.data.ChatMessageRepository +import com.nextcloud.talk.chat.data.model.ChatMessage +import com.nextcloud.talk.data.user.model.User +import com.nextcloud.talk.utils.ParticipantPermissions +import com.nextcloud.talk.utils.message.SendMessageUtils +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito +import org.mockito.Mockito.RETURNS_DEEP_STUBS +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import java.util.UUID +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger + +/** + * The name, type and size of a file to send come from the provider of the app that owns it (Nextcloud Files): + * a blocking binder call into another process. Sending a file must not make it on the main thread. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +class ChatViewModelUploadFileTest { + + private val chatRepository: ChatMessageRepository = mock() + private val placeholderAdded = CountDownLatch(1) + private val placeholderMarkedFailed = CountDownLatch(1) + private var previousApplication: NextcloudTalkApplication? = null + + // the setter of the singleton is protected: only the real application sets it + private val sharedApplicationField = NextcloudTalkApplication::class.java + .getDeclaredField("sharedApplication") + .apply { isAccessible = true } + + @Before + fun setUp() { + val context = RuntimeEnvironment.getApplication() + WorkManagerTestInitHelper.initializeTestWorkManager(context) + previousApplication = sharedApplicationField.get(null) as NextcloudTalkApplication? + val application = mock() + whenever(application.contentResolver).thenReturn(context.contentResolver) + whenever(application.applicationContext).thenReturn(context) + sharedApplicationField.set(null, application) + + SlowProvider.reset() + Robolectric.buildContentProvider(SlowProvider::class.java).create(AUTHORITY) + runBlocking { + whenever(chatRepository.addUploadPlaceholderMessage(any(), any(), any(), anyOrNull(), any(), any())) + .thenAnswer { + placeholderAdded.countDown() + flowOf>() + } + whenever(chatRepository.markUploadPlaceholderFailed(any())).thenAnswer { + placeholderMarkedFailed.countDown() + } + } + } + + @After + fun tearDown() { + sharedApplicationField.set(null, previousApplication) + } + + @Test + fun `sending a file does not ask its provider on the main thread`() { + sendAndAwaitPlaceholder("document.pdf") + + assertTrue("placeholder message is added", placeholderAdded.count == 0L) + assertEquals("provider calls on the main thread", 0, SlowProvider.mainThreadCalls.get()) + assertTrue("provider is asked for the type and the name", SlowProvider.otherCalls.get() >= 2) + } + + @Test + fun `a provider that rejects the uri still leaves a placeholder and does not crash`() { + sendAndAwaitPlaceholder("rejected.pdf") + + assertTrue("placeholder message is added", placeholderAdded.count == 0L) + assertEquals("provider calls on the main thread", 0, SlowProvider.mainThreadCalls.get()) + } + + @Test + fun `the upload worker is enqueued at once, before the provider has answered`() { + val viewModel = newViewModel() + + viewModel.send("slow-a.pdf", order = 1) + + assertEquals("uploads queued while the provider is still asked", 1, queuedUploads()) + assertEquals("placeholder is not added yet", 1L, placeholderAdded.count) + } + + @Test + fun `a view model cleared while the provider answers still has its upload enqueued`() { + val viewModel = newViewModel() + + viewModel.send("slow-a.pdf", order = 1) + viewModel.viewModelScope.cancel() + + assertEquals("uploads queued", 1, queuedUploads()) + } + + @Test + fun `files are enqueued in the order they were sent whatever speed their providers answer with`() { + val viewModel = newViewModel() + + viewModel.send("slow-a.pdf", order = 1) + viewModel.send("b.pdf", order = 2) + + val workIds = viewModel.uploadWorkIds() + val first = workIds.getValue(referenceId(1)) + val second = workIds.getValue(referenceId(2)) + val dependents = (WorkManager.getInstance(RuntimeEnvironment.getApplication()) as WorkManagerImpl) + .workDatabase + .dependencyDao() + .getDependentWorkIds(first.toString()) + assertEquals("the second file waits for the first one", listOf(second.toString()), dependents) + } + + @Test + fun `a worker that fails before the placeholder exists does not leave the placeholder pending`() { + val viewModel = newViewModel() + + viewModel.send("slow-a.pdf", order = 1) + val workId = viewModel.uploadWorkIds().getValue(referenceId(1)) + WorkManagerTestInitHelper.getTestDriver(RuntimeEnvironment.getApplication())!!.setAllConstraintsMet(workId) + + val deadline = System.currentTimeMillis() + TIMEOUT_MS + while (placeholderAdded.count > 0 && System.currentTimeMillis() < deadline) { + shadowOf(Looper.getMainLooper()).idle() + placeholderAdded.await(POLL_MS, TimeUnit.MILLISECONDS) + } + while (placeholderMarkedFailed.count > 0 && System.currentTimeMillis() < deadline) { + shadowOf(Looper.getMainLooper()).idle() + placeholderMarkedFailed.await(POLL_MS, TimeUnit.MILLISECONDS) + } + + assertEquals("the placeholder is marked as failed", 0L, placeholderMarkedFailed.count) + } + + private fun queuedUploads(): Int = + WorkManager.getInstance(RuntimeEnvironment.getApplication()) + .getWorkInfosForUniqueWork("upload_queue_1@room") + .get() + .size + + private fun referenceId(order: Int) = SendMessageUtils().generateGroupedReferenceId(UPLOAD_ID, order) + + @Suppress("UNCHECKED_CAST") + private fun ChatViewModel.uploadWorkIds(): Map = + ChatViewModel::class.java.getDeclaredField("uploadReferenceToWorkId") + .apply { isAccessible = true } + .get(this) as Map + + private fun ChatViewModel.send(name: String, order: Int) { + uploadFile( + fileUri = "content://" + AUTHORITY + "/" + name, + isVoiceMessage = false, + displayName = "Team", + uploadId = UPLOAD_ID, + order = order + ) + } + + private fun newViewModel(): ChatViewModel { + val user = User().apply { id = 1L } + val viewModel = ChatViewModel( + logger = mock(), + appPreferences = mock(defaultAnswer = RETURNS_DEEP_STUBS), + chatNetworkDataSource = mock(defaultAnswer = RETURNS_DEEP_STUBS), + chatRepository = chatRepository, + threadsRepository = mock(defaultAnswer = RETURNS_DEEP_STUBS), + conversationRepository = mock(defaultAnswer = RETURNS_DEEP_STUBS), + reactionsRepository = mock(defaultAnswer = RETURNS_DEEP_STUBS), + unifiedSearchRepository = mock(defaultAnswer = RETURNS_DEEP_STUBS), + mediaRecorderManager = mock(defaultAnswer = RETURNS_DEEP_STUBS), + audioFocusRequestManager = mock(defaultAnswer = RETURNS_DEEP_STUBS), + userManager = mock(defaultAnswer = RETURNS_DEEP_STUBS), + appScope = CoroutineScope(Dispatchers.Unconfined), + initialUser = user, + chatRoomToken = "room", + conversationThreadId = null + ) + viewModel.participantPermissions = Mockito.mock(ParticipantPermissions::class.java).also { + whenever(it.hasChatPermission()).thenReturn(true) + } + return viewModel + } + + private fun sendAndAwaitPlaceholder(name: String) { + newViewModel().send(name, order = 1) + + val deadline = System.currentTimeMillis() + TIMEOUT_MS + while (placeholderAdded.count > 0 && System.currentTimeMillis() < deadline) { + shadowOf(Looper.getMainLooper()).idle() + placeholderAdded.await(POLL_MS, TimeUnit.MILLISECONDS) + } + } + + class SlowProvider : ContentProvider() { + override fun onCreate() = true + + override fun getType(uri: Uri): String { + count(uri) + require(uri.lastPathSegment != "rejected.pdf") { "Unknown URL " + uri } + return "application/pdf" + } + + override fun query( + uri: Uri, + projection: Array?, + selection: String?, + selectionArgs: Array?, + sortOrder: String? + ): Cursor { + count(uri) + return MatrixCursor(arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE)).apply { + addRow(arrayOf(uri.lastPathSegment, FILE_BYTES)) + } + } + + override fun insert(uri: Uri, values: ContentValues?): Uri? = null + + override fun delete(uri: Uri, selection: String?, selectionArgs: Array?) = 0 + + override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array?) = 0 + + private fun count(uri: Uri) { + val onMain = Looper.myLooper() == Looper.getMainLooper() + (if (onMain) mainThreadCalls else otherCalls).incrementAndGet() + Thread.sleep(if (uri.lastPathSegment.orEmpty().startsWith("slow")) SLOW_DELAY_MS else PROVIDER_DELAY_MS) + } + + companion object { + val mainThreadCalls = AtomicInteger() + val otherCalls = AtomicInteger() + + fun reset() { + mainThreadCalls.set(0) + otherCalls.set(0) + } + } + } + + companion object { + private const val AUTHORITY = "com.nextcloud.client.documentsfileprovider" + private const val PROVIDER_DELAY_MS = 300L + private const val SLOW_DELAY_MS = 1500L + private const val FILE_BYTES = 2048L + private const val TIMEOUT_MS = 20_000L + private const val POLL_MS = 50L + private const val UPLOAD_ID = "upload" + } +}