From 087f23babbdc555e945b04dfcdf3307e79ed3de6 Mon Sep 17 00:00:00 2001 From: Krainov Gleb Date: Mon, 12 Oct 2026 10:46:16 +0000 Subject: [PATCH 1/5] fix(attachment-preview): do not ask the content provider for the type during composition The preview called ContentResolver.getType for every file on the main thread in the body of the composable, so each recomposition (for example switching the file permission) made a binder call into the provider. A file shared from the Nextcloud Files app answers slowly, which froze the screen into an ANR. The type now comes from the file descriptions, which are read on the IO dispatcher. describeFiles no longer clears the descriptions before publishing the new ones, so the quality option, which depends on them, does not disappear while they are re-read. Signed-off-by: Krainov Gleb Assisted-by: Claude-Code:claude-sonnet-5-5 --- .../FileAttachmentPreviewScreen.kt | 13 +++++++------ .../FileAttachmentPreviewViewModel.kt | 2 +- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewScreen.kt b/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewScreen.kt index 97d06c5631..e8adacaf47 100644 --- a/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewScreen.kt +++ b/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewScreen.kt @@ -64,9 +64,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.core.net.toUri import com.nextcloud.talk.R -import com.nextcloud.talk.utils.FileUtils import kotlinx.coroutines.launch private const val MAX_ADD_MORE_FILES = 10 @@ -89,17 +87,18 @@ internal fun FileAttachmentPreviewContent( onDismiss: () -> Unit, onSend: (files: List, caption: String, compressImages: Boolean, allowUpdate: Boolean) -> Unit ) { - val context = LocalContext.current val currentFiles = viewModel.files - val hasCompressibleMedia = currentFiles.any { isCompressible(FileUtils.resolveMimeType(context, it.toUri())) } var caption by rememberSaveable { mutableStateOf("") } - var compressImages by rememberSaveable { mutableStateOf(hasCompressibleMedia && initialCompressImages) } + var compressImages by rememberSaveable { mutableStateOf(initialCompressImages) } var allowUpdate by rememberSaveable { mutableStateOf(false) } LaunchedEffect(currentFiles.toSet(), compressImages) { viewModel.describeFiles(compressImages) } val fileDescriptions = currentFiles.mapNotNull { viewModel.descriptionsByUri[it] } + // The type comes from the descriptions, which are read off the main thread: asking a content provider + // (e.g. Nextcloud Files) for it during composition would block the main thread on every recomposition. + val hasCompressibleMedia = fileDescriptions.any { isCompressible(it.mimeType) } val pagerState = rememberPagerState(pageCount = { fileDescriptions.size }) val coroutineScope = rememberCoroutineScope() @@ -180,7 +179,9 @@ internal fun FileAttachmentPreviewContent( caption = caption, onCaptionChange = { caption = it }, sendEnabled = currentFiles.isNotEmpty(), - onSend = { onSend(currentFiles.toList(), caption, compressImages, allowUpdate) } + onSend = { + onSend(currentFiles.toList(), caption, compressImages && hasCompressibleMedia, allowUpdate) + } ) } } diff --git a/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewViewModel.kt b/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewViewModel.kt index 94790258ab..7e67b67163 100644 --- a/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewViewModel.kt @@ -58,8 +58,8 @@ internal class FileAttachmentPreviewViewModel @Inject constructor(private val co val snapshot = files.toList() viewModelScope.launch(Dispatchers.IO) { val described = snapshot.associateWith { describeFile(context, it, compress) } - _descriptionsByUri.clear() _descriptionsByUri.putAll(described) + _descriptionsByUri.keys.retainAll(described.keys) } } } From 0169d98456078cf253693989ee63d87bdc331291 Mon Sep 17 00:00:00 2001 From: Krainov Gleb Date: Mon, 12 Oct 2026 10:54:57 +0000 Subject: [PATCH 2/5] fix(attachment-preview): enable send only after the files are described The compression option sent with the files now depends on the file descriptions. Until they are read, the type of the files is unknown, so tapping send in that interval would turn compression off. Signed-off-by: Krainov Gleb Assisted-by: Claude-Code:claude-sonnet-5-5 --- .../talk/attachmentpreview/FileAttachmentPreviewScreen.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewScreen.kt b/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewScreen.kt index e8adacaf47..51dbcd71a8 100644 --- a/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewScreen.kt +++ b/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewScreen.kt @@ -178,7 +178,7 @@ internal fun FileAttachmentPreviewContent( CaptionInputBar( caption = caption, onCaptionChange = { caption = it }, - sendEnabled = currentFiles.isNotEmpty(), + sendEnabled = currentFiles.isNotEmpty() && fileDescriptions.size == currentFiles.size, onSend = { onSend(currentFiles.toList(), caption, compressImages && hasCompressibleMedia, allowUpdate) } From 3e6dcee3d82d06edef4cd60c76a0687cef601003 Mon Sep 17 00:00:00 2001 From: Krainov Gleb Date: Tue, 13 Oct 2026 18:01:03 +0000 Subject: [PATCH 3/5] fix(attachment-preview): do not hold Send back until the files are described Send waited for every FileDescription, and describeFile copies content:// files into the cache, so Send stayed disabled until a large file was copied. Resolve only the MIME type, off the main thread, into a separate mimeTypesByUri state and enable Send as soon as there are files. The worker already compresses only images and videos, so compressImages is passed on as is. Cancel the previous describeFiles job before launching a new one, so a stale job finishing last cannot retainAll away a newly added description. Assisted-by: Claude-Code:claude-sonnet-5-5 Signed-off-by: Krainov Gleb --- .../FileAttachmentPreviewScreen.kt | 14 ++-- .../FileAttachmentPreviewViewModel.kt | 38 ++++++++- .../FileAttachmentPreviewViewModelTest.kt | 81 +++++++++++++++++++ 3 files changed, 126 insertions(+), 7 deletions(-) create mode 100644 app/src/test/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewViewModelTest.kt diff --git a/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewScreen.kt b/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewScreen.kt index 51dbcd71a8..5fd88b9df1 100644 --- a/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewScreen.kt +++ b/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewScreen.kt @@ -95,10 +95,14 @@ internal fun FileAttachmentPreviewContent( LaunchedEffect(currentFiles.toSet(), compressImages) { viewModel.describeFiles(compressImages) } + LaunchedEffect(currentFiles.toSet()) { + viewModel.resolveMimeTypes() + } val fileDescriptions = currentFiles.mapNotNull { viewModel.descriptionsByUri[it] } - // The type comes from the descriptions, which are read off the main thread: asking a content provider - // (e.g. Nextcloud Files) for it during composition would block the main thread on every recomposition. - val hasCompressibleMedia = fileDescriptions.any { isCompressible(it.mimeType) } + // The type is resolved off the main thread, separately from the full descriptions: asking a content provider + // (e.g. Nextcloud Files) for it during composition would block the main thread on every recomposition, + // while waiting for the descriptions would hold Send back until large files are copied. + val hasCompressibleMedia = currentFiles.any { isCompressible(viewModel.mimeTypesByUri[it]) } val pagerState = rememberPagerState(pageCount = { fileDescriptions.size }) val coroutineScope = rememberCoroutineScope() @@ -178,9 +182,9 @@ internal fun FileAttachmentPreviewContent( CaptionInputBar( caption = caption, onCaptionChange = { caption = it }, - sendEnabled = currentFiles.isNotEmpty() && fileDescriptions.size == currentFiles.size, + sendEnabled = currentFiles.isNotEmpty(), onSend = { - onSend(currentFiles.toList(), caption, compressImages && hasCompressibleMedia, allowUpdate) + onSend(currentFiles.toList(), caption, compressImages, allowUpdate) } ) } diff --git a/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewViewModel.kt b/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewViewModel.kt index 7e67b67163..ff7ea9828e 100644 --- a/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewViewModel.kt @@ -11,7 +11,11 @@ import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateMapOf import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import androidx.core.net.toUri +import com.nextcloud.talk.utils.FileUtils +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.launch import javax.inject.Inject @@ -28,6 +32,20 @@ internal class FileAttachmentPreviewViewModel @Inject constructor(private val co private val _descriptionsByUri = mutableStateMapOf() val descriptionsByUri: Map get() = _descriptionsByUri + private val _mimeTypesByUri = mutableStateMapOf() + + /** Cheap, type-only counterpart of [descriptionsByUri]; filled before the (slow) full descriptions. */ + val mimeTypesByUri: Map get() = _mimeTypesByUri + + internal var ioDispatcher: CoroutineDispatcher = Dispatchers.IO + internal var describer: (Context, String, Boolean) -> FileDescription = ::describeFile + internal var mimeTypeResolver: (Context, String) -> String? = { ctx, uri -> + FileUtils.resolveMimeType(ctx, uri.toUri()) + } + + private var describeJob: Job? = null + private var mimeTypeJob: Job? = null + /** No-op after the first call, so re-entering (e.g. after rotation) doesn't wipe edits made since. */ fun setInitialFiles(initialFiles: List) { if (files.isEmpty()) { @@ -56,10 +74,26 @@ internal class FileAttachmentPreviewViewModel @Inject constructor(private val co */ fun describeFiles(compress: Boolean) { val snapshot = files.toList() - viewModelScope.launch(Dispatchers.IO) { - val described = snapshot.associateWith { describeFile(context, it, compress) } + // a stale job finishing last would retainAll away the description of a file added since + describeJob?.cancel() + describeJob = viewModelScope.launch(ioDispatcher) { + val described = snapshot.associateWith { describer(context, it, compress) } _descriptionsByUri.putAll(described) _descriptionsByUri.keys.retainAll(described.keys) } } + + /** + * Resolves only the MIME type of every current file. Unlike [describeFiles] this does not copy + * content:// files into the cache, so it is quick enough not to hold anything back. + */ + fun resolveMimeTypes() { + val snapshot = files.toList() + mimeTypeJob?.cancel() + mimeTypeJob = viewModelScope.launch(ioDispatcher) { + val resolved = snapshot.associateWith { mimeTypeResolver(context, it) } + _mimeTypesByUri.putAll(resolved) + _mimeTypesByUri.keys.retainAll(resolved.keys) + } + } } diff --git a/app/src/test/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewViewModelTest.kt b/app/src/test/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewViewModelTest.kt new file mode 100644 index 0000000000..4eb0de82b3 --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewViewModelTest.kt @@ -0,0 +1,81 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.attachmentpreview + +import android.app.Application +import android.content.Context +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +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.mock +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class, sdk = [33]) +class FileAttachmentPreviewViewModelTest { + + private val testScope = TestScope() + private val described = mutableListOf() + private lateinit var viewModel: FileAttachmentPreviewViewModel + + @Before + fun setUp() { + viewModel = FileAttachmentPreviewViewModel(mock(Context::class.java)).apply { + ioDispatcher = StandardTestDispatcher(testScope.testScheduler) + describer = { _, uri, _ -> + described.add(uri) + FileDescription(uri, uri, MediaKind.OTHER, "text/plain", null) + } + mimeTypeResolver = { _, uri -> if (uri.endsWith(".mp4")) "video/mp4" else "text/plain" } + } + } + + @Test + fun `mime types are resolved without describing the files`() { + viewModel.setInitialFiles(listOf("content://a/clip.mp4")) + + viewModel.resolveMimeTypes() + testScope.advanceUntilIdle() + + assertEquals("video/mp4", viewModel.mimeTypesByUri["content://a/clip.mp4"]) + assertTrue(viewModel.descriptionsByUri.isEmpty()) + assertTrue(described.isEmpty()) + } + + @Test + fun `mime types of removed files are dropped`() { + viewModel.setInitialFiles(listOf("content://a/1.txt", "content://a/2.txt")) + viewModel.resolveMimeTypes() + testScope.advanceUntilIdle() + + viewModel.removeFile("content://a/1.txt") + viewModel.resolveMimeTypes() + testScope.advanceUntilIdle() + + assertEquals(setOf("content://a/2.txt"), viewModel.mimeTypesByUri.keys) + } + + @Test + fun `a newer describeFiles call cancels the previous job`() { + viewModel.setInitialFiles(listOf("content://a/1.txt")) + viewModel.describeFiles(true) + viewModel.addFiles(listOf("content://a/2.txt")) + viewModel.describeFiles(true) + testScope.advanceUntilIdle() + + assertEquals(listOf("content://a/1.txt", "content://a/2.txt"), described) + assertEquals(setOf("content://a/1.txt", "content://a/2.txt"), viewModel.descriptionsByUri.keys) + } +} From a9d9fd6a1b5ce079501ba52895a4162c34ac4e14 Mon Sep 17 00:00:00 2001 From: Krainov Gleb Date: Tue, 13 Oct 2026 18:14:15 +0000 Subject: [PATCH 4/5] fix(attachment-preview): publish describe results on main so a cancelled job cannot write describer and mimeTypeResolver are blocking, so a cancelled job still finished its copy and then ran putAll + retainAll with its stale snapshot, dropping the entry of a file added since. Publish the results through withContext(main): it throws if the job was cancelled and serialises the writes with cancel(), which also moves the snapshot-state mutation off the IO thread. The new test blocks the first describe call until the second call has finished. Assisted-by: Claude-Code:claude-sonnet-5-5 Signed-off-by: Krainov Gleb --- .../FileAttachmentPreviewViewModel.kt | 16 ++++++-- .../FileAttachmentPreviewViewModelTest.kt | 38 +++++++++++++++++++ 2 files changed, 50 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewViewModel.kt b/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewViewModel.kt index ff7ea9828e..686585a258 100644 --- a/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewViewModel.kt @@ -17,6 +17,7 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import javax.inject.Inject /** @@ -38,6 +39,7 @@ internal class FileAttachmentPreviewViewModel @Inject constructor(private val co val mimeTypesByUri: Map get() = _mimeTypesByUri internal var ioDispatcher: CoroutineDispatcher = Dispatchers.IO + internal var mainDispatcher: CoroutineDispatcher = Dispatchers.Main.immediate internal var describer: (Context, String, Boolean) -> FileDescription = ::describeFile internal var mimeTypeResolver: (Context, String) -> String? = { ctx, uri -> FileUtils.resolveMimeType(ctx, uri.toUri()) @@ -78,8 +80,12 @@ internal class FileAttachmentPreviewViewModel @Inject constructor(private val co describeJob?.cancel() describeJob = viewModelScope.launch(ioDispatcher) { val described = snapshot.associateWith { describer(context, it, compress) } - _descriptionsByUri.putAll(described) - _descriptionsByUri.keys.retainAll(described.keys) + // the blocking copy above cannot be interrupted, so a cancelled job reaches this point too; + // withContext checks for cancellation and runs on main, where cancel() is also called + withContext(mainDispatcher) { + _descriptionsByUri.putAll(described) + _descriptionsByUri.keys.retainAll(described.keys) + } } } @@ -92,8 +98,10 @@ internal class FileAttachmentPreviewViewModel @Inject constructor(private val co mimeTypeJob?.cancel() mimeTypeJob = viewModelScope.launch(ioDispatcher) { val resolved = snapshot.associateWith { mimeTypeResolver(context, it) } - _mimeTypesByUri.putAll(resolved) - _mimeTypesByUri.keys.retainAll(resolved.keys) + withContext(mainDispatcher) { + _mimeTypesByUri.putAll(resolved) + _mimeTypesByUri.keys.retainAll(resolved.keys) + } } } } diff --git a/app/src/test/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewViewModelTest.kt b/app/src/test/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewViewModelTest.kt index 4eb0de82b3..bf0cf5b603 100644 --- a/app/src/test/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewViewModelTest.kt +++ b/app/src/test/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewViewModelTest.kt @@ -17,6 +17,8 @@ import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit import org.mockito.Mockito.mock import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config @@ -34,6 +36,7 @@ class FileAttachmentPreviewViewModelTest { fun setUp() { viewModel = FileAttachmentPreviewViewModel(mock(Context::class.java)).apply { ioDispatcher = StandardTestDispatcher(testScope.testScheduler) + mainDispatcher = StandardTestDispatcher(testScope.testScheduler) describer = { _, uri, _ -> described.add(uri) FileDescription(uri, uri, MediaKind.OTHER, "text/plain", null) @@ -78,4 +81,39 @@ class FileAttachmentPreviewViewModelTest { assertEquals(listOf("content://a/1.txt", "content://a/2.txt"), described) assertEquals(setOf("content://a/1.txt", "content://a/2.txt"), viewModel.descriptionsByUri.keys) } + + @Test + fun `a cancelled describeFiles job that is still copying does not drop a newer description`() { + val started = CountDownLatch(1) + val release = CountDownLatch(1) + viewModel.ioDispatcher = kotlinx.coroutines.Dispatchers.IO + viewModel.describer = { _, uri, _ -> + if (uri == "content://a/1.txt" && started.count == 1L) { + started.countDown() + release.await(5, TimeUnit.SECONDS) + } + FileDescription(uri, uri, MediaKind.OTHER, "text/plain", null) + } + viewModel.setInitialFiles(listOf("content://a/1.txt")) + viewModel.describeFiles(true) + assertTrue(started.await(5, TimeUnit.SECONDS)) + + viewModel.addFiles(listOf("content://a/2.txt")) + viewModel.describeFiles(true) + Thread.sleep(300) + testScope.advanceUntilIdle() + assertEquals( + setOf("content://a/1.txt", "content://a/2.txt"), + viewModel.descriptionsByUri.keys + ) + + release.countDown() + Thread.sleep(300) + testScope.advanceUntilIdle() + + assertEquals( + setOf("content://a/1.txt", "content://a/2.txt"), + viewModel.descriptionsByUri.keys + ) + } } From 73d1c9f4b1d7e3b6d7bcbde20959a13e3c37fc97 Mon Sep 17 00:00:00 2001 From: Krainov Gleb Date: Thu, 15 Oct 2026 17:45:47 +0000 Subject: [PATCH 5/5] refactor(attachment-preview): pass the view model test hooks as constructor parameters The dispatchers, the describer and the MIME type resolver were internal vars that the test reassigned after construction. They are now constructor parameters with the production defaults; Dagger keeps a constructor that takes only the context. Assisted-by: Claude-Code:claude-opus-5-5 Signed-off-by: Krainov Gleb --- .../FileAttachmentPreviewViewModel.kt | 21 +++++++++----- .../FileAttachmentPreviewViewModelTest.kt | 29 +++++++++++-------- 2 files changed, 30 insertions(+), 20 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewViewModel.kt b/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewViewModel.kt index 686585a258..f365753a92 100644 --- a/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewViewModel.kt @@ -26,7 +26,19 @@ import javax.inject.Inject * dialog's original arguments. Everything else on the screen — caption text, drag/scroll * position, HQ toggle animation state — is ephemeral UI state and stays in Compose's `remember`. */ -internal class FileAttachmentPreviewViewModel @Inject constructor(private val context: Context) : ViewModel() { +internal class FileAttachmentPreviewViewModel( + private val context: Context, + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, + private val mainDispatcher: CoroutineDispatcher = Dispatchers.Main.immediate, + private val describer: (Context, String, Boolean) -> FileDescription = ::describeFile, + private val mimeTypeResolver: (Context, String) -> String? = { ctx, uri -> + FileUtils.resolveMimeType(ctx, uri.toUri()) + } +) : ViewModel() { + + // Dagger cannot use the default values above, so it gets a constructor with the context only + @Inject + constructor(context: Context) : this(context, ioDispatcher = Dispatchers.IO) val files = mutableStateListOf() @@ -38,13 +50,6 @@ internal class FileAttachmentPreviewViewModel @Inject constructor(private val co /** Cheap, type-only counterpart of [descriptionsByUri]; filled before the (slow) full descriptions. */ val mimeTypesByUri: Map get() = _mimeTypesByUri - internal var ioDispatcher: CoroutineDispatcher = Dispatchers.IO - internal var mainDispatcher: CoroutineDispatcher = Dispatchers.Main.immediate - internal var describer: (Context, String, Boolean) -> FileDescription = ::describeFile - internal var mimeTypeResolver: (Context, String) -> String? = { ctx, uri -> - FileUtils.resolveMimeType(ctx, uri.toUri()) - } - private var describeJob: Job? = null private var mimeTypeJob: Job? = null diff --git a/app/src/test/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewViewModelTest.kt b/app/src/test/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewViewModelTest.kt index bf0cf5b603..7becb4d81d 100644 --- a/app/src/test/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewViewModelTest.kt +++ b/app/src/test/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewViewModelTest.kt @@ -34,15 +34,16 @@ class FileAttachmentPreviewViewModelTest { @Before fun setUp() { - viewModel = FileAttachmentPreviewViewModel(mock(Context::class.java)).apply { - ioDispatcher = StandardTestDispatcher(testScope.testScheduler) - mainDispatcher = StandardTestDispatcher(testScope.testScheduler) + viewModel = FileAttachmentPreviewViewModel( + context = mock(Context::class.java), + ioDispatcher = StandardTestDispatcher(testScope.testScheduler), + mainDispatcher = StandardTestDispatcher(testScope.testScheduler), describer = { _, uri, _ -> described.add(uri) FileDescription(uri, uri, MediaKind.OTHER, "text/plain", null) - } + }, mimeTypeResolver = { _, uri -> if (uri.endsWith(".mp4")) "video/mp4" else "text/plain" } - } + ) } @Test @@ -86,14 +87,18 @@ class FileAttachmentPreviewViewModelTest { fun `a cancelled describeFiles job that is still copying does not drop a newer description`() { val started = CountDownLatch(1) val release = CountDownLatch(1) - viewModel.ioDispatcher = kotlinx.coroutines.Dispatchers.IO - viewModel.describer = { _, uri, _ -> - if (uri == "content://a/1.txt" && started.count == 1L) { - started.countDown() - release.await(5, TimeUnit.SECONDS) + viewModel = FileAttachmentPreviewViewModel( + context = mock(Context::class.java), + ioDispatcher = kotlinx.coroutines.Dispatchers.IO, + mainDispatcher = StandardTestDispatcher(testScope.testScheduler), + describer = { _, uri, _ -> + if (uri == "content://a/1.txt" && started.count == 1L) { + started.countDown() + release.await(5, TimeUnit.SECONDS) + } + FileDescription(uri, uri, MediaKind.OTHER, "text/plain", null) } - FileDescription(uri, uri, MediaKind.OTHER, "text/plain", null) - } + ) viewModel.setInitialFiles(listOf("content://a/1.txt")) viewModel.describeFiles(true) assertTrue(started.await(5, TimeUnit.SECONDS))