diff --git a/app/src/main/kotlin/com/wire/android/di/accountScoped/CellsModule.kt b/app/src/main/kotlin/com/wire/android/di/accountScoped/CellsModule.kt index e0bed7382b4..c5cc2b8f847 100644 --- a/app/src/main/kotlin/com/wire/android/di/accountScoped/CellsModule.kt +++ b/app/src/main/kotlin/com/wire/android/di/accountScoped/CellsModule.kt @@ -19,7 +19,6 @@ package com.wire.android.di.accountScoped import com.wire.android.di.CurrentAccount import com.wire.android.di.KaliumCoreLogic -import com.wire.android.feature.cells.util.FileNameResolver import com.wire.android.ui.home.conversations.model.messagetypes.multipart.CellAssetRefreshHelper import com.wire.kalium.cells.CellsScope import com.wire.kalium.cells.domain.CellUploadManager @@ -182,9 +181,6 @@ class CellsModule { @Provides fun provideGetOwnersUseCase(cellsScope: CellsScope): GetOwnersUseCase = cellsScope.getOwnersUseCase - @Provides - fun provideFileNameResolver(): FileNameResolver = FileNameResolver() - @Provides fun provideGetCellNodeUseCase(cellsScope: CellsScope): GetCellFileUseCase = cellsScope.getCellFileUseCase diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellViewModel.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellViewModel.kt index 27c9131d313..1ed904ce9c4 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellViewModel.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/CellViewModel.kt @@ -375,7 +375,7 @@ class CellViewModel @AssistedInject constructor( private fun startOpenDownload(cellNode: CellNodeUi.File) { openFileDownloadController.start( scope = viewModelScope, - cellNode = cellNode, + cellNode = cellNode.copy(conversationId = cellNode.conversationId ?: navArgs.conversationId), onOpenFile = ::openLocalFile, onError = { sendAction(ShowError(it)) }, ) @@ -441,14 +441,17 @@ class CellViewModel @AssistedInject constructor( return } } + AttachmentFileType.VIDEO -> { sendAction(OpenVideoViewer(file)) return } + AttachmentFileType.AUDIO -> { sendAction(OpenAudioPlayer(file)) return } + else -> Unit } file.contentUrl?.let { url -> @@ -471,14 +474,17 @@ class CellViewModel @AssistedInject constructor( return } } + AttachmentFileType.VIDEO -> { sendAction(OpenVideoViewer(file)) return } + AttachmentFileType.AUDIO -> { sendAction(OpenAudioPlayer(file)) return } + else -> Unit } file.localPath?.let { path -> diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/OfflineFileDownloadController.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/OfflineFileDownloadController.kt index fedde9eb1e3..56754d4fc61 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/OfflineFileDownloadController.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/OfflineFileDownloadController.kt @@ -19,7 +19,6 @@ package com.wire.android.feature.cells.ui import com.wire.android.feature.cells.ui.model.CellNodeUi import com.wire.android.feature.cells.util.FileHelper -import com.wire.android.feature.cells.util.FileNameResolver import com.wire.kalium.cells.domain.usecase.download.DownloadCellFileUseCase import com.wire.kalium.cells.domain.usecase.offline.OfflineFileInfo import com.wire.kalium.cells.domain.usecase.offline.SaveOfflineFileUseCase @@ -43,7 +42,6 @@ import dev.zacsweers.metro.Inject class OfflineFileDownloadController @Inject constructor( private val download: DownloadCellFileUseCase, private val fileHelper: FileHelper, - private val fileNameResolver: FileNameResolver, private val saveOfflineFile: SaveOfflineFileUseCase, private val sharedPathCache: CellFileLocalPathCache, ) { @@ -62,7 +60,7 @@ class OfflineFileDownloadController @Inject constructor( // If the file already exists locally (loaded this session or stored in DB), // skip the download and just persist the offline metadata. val existingPath = cellNode.localPath ?: sharedPathCache.getCompletedPath(cellNode.uuid) - if (existingPath != null) { + if (existingPath != null && File(existingPath).exists()) { saveExistingOfflineFile(scope, cellNode, existingPath, onSuccess, onError) return } @@ -74,9 +72,9 @@ class OfflineFileDownloadController @Inject constructor( onError(CellError.OTHER_ERROR) return } - val filePath = fileNameResolver - .getUniqueFile(fileHelper.getExternalFilesDir(), nodeName) - .toPath() + val filePath = File(fileHelper.getExternalFilesDir(), cellNode.conversationId ?: cellNode.uuid) + .also { it.mkdirs() } + .let { File(it, nodeName) } .toOkioPath() val job = scope.launch { diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/ui/OpenFileDownloadController.kt b/features/cells/src/main/java/com/wire/android/feature/cells/ui/OpenFileDownloadController.kt index d0956454e46..f3d0645df41 100644 --- a/features/cells/src/main/java/com/wire/android/feature/cells/ui/OpenFileDownloadController.kt +++ b/features/cells/src/main/java/com/wire/android/feature/cells/ui/OpenFileDownloadController.kt @@ -21,7 +21,6 @@ import com.wire.android.feature.cells.ui.OpenFileDownloadController.Companion.SP import com.wire.android.feature.cells.ui.model.CellNodeUi import com.wire.android.feature.cells.ui.model.OpenLoadState import com.wire.android.feature.cells.util.FileHelper -import com.wire.android.feature.cells.util.FileNameResolver import com.wire.kalium.cells.domain.usecase.download.DownloadCellFileUseCase import com.wire.kalium.common.functional.Either import com.wire.kalium.common.functional.onSuccess @@ -49,7 +48,6 @@ import java.io.File class OpenFileDownloadController @Inject constructor( private val download: DownloadCellFileUseCase, private val fileHelper: FileHelper, - private val fileNameResolver: FileNameResolver, private val sharedPathCache: CellFileLocalPathCache, ) { private data class ActiveDownload(val job: Job, val filePath: Path) @@ -68,7 +66,7 @@ class OpenFileDownloadController @Inject constructor( // (node.localPath) or recorded in this session's completed-paths guard (covers the // window between download completion and paging source refresh). val knownPath = cellNode.localPath ?: sharedPathCache.getCompletedPath(cellNode.uuid) - if (knownPath != null) { + if (knownPath != null && File(knownPath).exists()) { onOpenFile(cellNode.copy(localPath = knownPath)) return } @@ -81,9 +79,9 @@ class OpenFileDownloadController @Inject constructor( return } - val filePath = fileNameResolver - .getUniqueFile(fileHelper.getExternalFilesDir(), nodeName) - .toPath() + val filePath = File(fileHelper.getExternalFilesDir(), cellNode.conversationId ?: cellNode.uuid) + .also { it.mkdirs() } + .let { File(it, nodeName) } .toOkioPath() val job = scope.launch { diff --git a/features/cells/src/main/java/com/wire/android/feature/cells/util/FileNameResolver.kt b/features/cells/src/main/java/com/wire/android/feature/cells/util/FileNameResolver.kt deleted file mode 100644 index e4d62208ca4..00000000000 --- a/features/cells/src/main/java/com/wire/android/feature/cells/util/FileNameResolver.kt +++ /dev/null @@ -1,50 +0,0 @@ -/* - * Wire - * Copyright (C) 2025 Wire Swiss GmbH - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see http://www.gnu.org/licenses/. - */ -package com.wire.android.feature.cells.util - -import java.io.File -import dev.zacsweers.metro.Inject - -class FileNameResolver @Inject constructor() { - /** - * Generates a unique file name in the specified directory by appending a number in parentheses - * if a file with the same name already exists. - * - * For example, if "document.txt" already exists, it will return "document(1).txt", and if that - * also exists, it will return "document(2).txt", and so on. - * - * @param directory The directory to check for existing files. - * @param originalFileName The original file name to make unique. - * @return A File object with a unique name in the specified directory. - */ - fun getUniqueFile(directory: File, originalFileName: String): File { - val dotIndex = originalFileName.lastIndexOf('.') - val baseName = if (dotIndex != -1) originalFileName.substring(0, dotIndex) else originalFileName - val extension = if (dotIndex != -1) originalFileName.substring(dotIndex) else "" - - var file = File(directory, originalFileName) - var index = 1 - - while (file.exists()) { - val newName = "$baseName($index)$extension" - file = File(directory, newName) - index++ - } - return file - } -} diff --git a/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/CellViewModelTest.kt b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/CellViewModelTest.kt index 874d0cee107..fae1e15b0fd 100644 --- a/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/CellViewModelTest.kt +++ b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/CellViewModelTest.kt @@ -32,7 +32,6 @@ import com.wire.android.feature.cells.ui.search.SearchNavArgs import com.wire.android.feature.cells.ui.search.sort.SortCriteriaNavArg import com.wire.android.feature.cells.ui.search.sort.SortingCriteria import com.wire.android.feature.cells.util.FileHelper -import com.wire.android.feature.cells.util.FileNameResolver import com.wire.kalium.cells.domain.model.Node import com.wire.kalium.cells.domain.usecase.DeleteCellAssetUseCase import com.wire.kalium.cells.domain.usecase.GetConversationNameUseCase @@ -459,9 +458,6 @@ class CellViewModelTest { @MockK lateinit var fileHelper: FileHelper - @MockK - lateinit var fileNameResolver: FileNameResolver - val sharedPathCache = CellFileLocalPathCache() @MockK @@ -586,21 +582,18 @@ class CellViewModelTest { fun arrange(): Pair { every { fileHelper.getExternalFilesDir() } returns File("") - every { fileNameResolver.getUniqueFile(any(), any()) } returns File("") coEvery { getWireCellsConfig() } returns null val openFileDownloadController = OpenFileDownloadController( download = downloadCellFileUseCase, fileHelper = fileHelper, - fileNameResolver = fileNameResolver, sharedPathCache = sharedPathCache, ) val offlineFileDownloadController = OfflineFileDownloadController( download = downloadCellFileUseCase, fileHelper = fileHelper, - fileNameResolver = fileNameResolver, saveOfflineFile = mockk(relaxUnitFun = true), sharedPathCache = sharedPathCache, ) diff --git a/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/OfflineFileDownloadControllerTest.kt b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/OfflineFileDownloadControllerTest.kt new file mode 100644 index 00000000000..64f9e39f7a6 --- /dev/null +++ b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/OfflineFileDownloadControllerTest.kt @@ -0,0 +1,402 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ +package com.wire.android.feature.cells.ui + +import com.wire.android.feature.cells.domain.model.AttachmentFileType +import com.wire.android.feature.cells.ui.model.CellNodeUi +import com.wire.android.feature.cells.util.FileHelper +import com.wire.kalium.cells.domain.usecase.download.DownloadCellFileUseCase +import com.wire.kalium.cells.domain.usecase.offline.SaveOfflineFileUseCase +import com.wire.kalium.common.error.NetworkFailure +import com.wire.kalium.common.error.StorageFailure +import com.wire.kalium.common.functional.left +import com.wire.kalium.common.functional.right +import io.mockk.MockKAnnotations +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.impl.annotations.MockK +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.io.File +import java.io.IOException +import kotlin.io.path.createTempDirectory +import kotlin.time.Duration.Companion.milliseconds + +class OfflineFileDownloadControllerTest { + + private val dispatcher = UnconfinedTestDispatcher() + + @BeforeEach + fun beforeEach() { + Dispatchers.setMain(dispatcher) + } + + @AfterEach + fun afterEach() { + Dispatchers.resetMain() + } + + @Test + fun givenFileWithNoName_whenStartCalled_thenOnErrorCallbackInvoked() = runTest { + val (_, controller) = Arrangement().arrange() + var errorReceived: CellError? = null + + controller.start( + scope = this, + cellNode = testFile.copy(name = null), + onSuccess = {}, + onError = { errorReceived = it }, + ) + advanceUntilIdle() + + assertEquals(CellError.OTHER_ERROR, errorReceived) + } + + @Test + fun givenFileWithExistingLocalPath_whenFileOnDisk_thenSaveOfflineCalledWithoutDownload() = runTest { + val (arrangement, controller) = Arrangement().arrange() + val realFile = File(arrangement.externalFilesDir, "report.pdf").also { it.createNewFile() } + val fileWithLocalPath = testFile.copy(localPath = realFile.absolutePath) + var successPath: String? = null + + controller.start( + scope = this, + cellNode = fileWithLocalPath, + onSuccess = { successPath = it }, + onError = {}, + ) + advanceUntilIdle() + + assertEquals(realFile.absolutePath, successPath) + coVerify(exactly = 0) { arrangement.downloadUseCase(any(), any(), any(), any(), any(), any(), any(), any()) } + coVerify(exactly = 1) { arrangement.saveOfflineFile(any()) } + } + + @Test + fun givenFileWithExistingLocalPath_whenFileDeletedFromDisk_thenDownloadStarted() = runTest { + val (arrangement, controller) = Arrangement() + .withDownloadSuccess() + .arrange() + val fileWithStalePath = testFile.copy(localPath = "/non/existent/report.pdf") + + controller.start( + scope = this, + cellNode = fileWithStalePath, + onSuccess = {}, + onError = {}, + ) + advanceUntilIdle() + + coVerify(exactly = 1) { + arrangement.downloadUseCase(eq(testFile.uuid), any(), any(), any(), any(), any(), any(), any()) + } + } + + @Test + fun givenDownloadSuccess_whenStartCalled_thenOnSuccessCallbackInvoked() = runTest { + val (_, controller) = Arrangement() + .withDownloadSuccess() + .arrange() + var successPath: String? = null + + controller.start( + scope = this, + cellNode = testFile, + onSuccess = { successPath = it }, + onError = {}, + ) + advanceUntilIdle() + + assertNotNull(successPath) + } + + @Test + fun givenDownloadSuccess_whenStartCalled_thenProgressCleared() = runTest { + val (_, controller) = Arrangement() + .withDownloadSuccess() + .arrange() + + controller.start(scope = this, cellNode = testFile, onSuccess = {}, onError = {}) + advanceUntilIdle() + + assertNull(controller.downloadProgresses.value[testFile.uuid], "Progress should be cleared after success") + } + + @Test + fun givenDownloadSuccess_whenStartCalled_thenSaveOfflineFileCalled() = runTest { + val (arrangement, controller) = Arrangement() + .withDownloadSuccess() + .arrange() + + controller.start(scope = this, cellNode = testFile, onSuccess = {}, onError = {}) + advanceUntilIdle() + + coVerify(exactly = 1) { arrangement.saveOfflineFile(any()) } + } + + @Test + fun givenDownloadSuccess_whenStartCalled_thenPathRecordedInSharedCache() = runTest { + val (arrangement, controller) = Arrangement() + .withDownloadSuccess() + .arrange() + + controller.start(scope = this, cellNode = testFile, onSuccess = {}, onError = {}) + advanceUntilIdle() + + assertNotNull(arrangement.sharedPathCache.getCompletedPath(testFile.uuid)) + } + + @Test + fun givenProgressUpdate_whenDownloadProgresses_thenProgressReflectedInFlow() = runTest { + val (_, controller) = Arrangement() + .withProgressThenSuccess(bytesDownloaded = 512L) + .arrange() + + controller.start(scope = this, cellNode = testFile.copy(size = 1024L), onSuccess = {}, onError = {}) + // Download emits progress at 200ms, completes at 300ms. + // Advance to 250ms to capture the in-progress state. + advanceTimeBy(250.milliseconds) + + val progress = controller.downloadProgresses.value[testFile.uuid] + assertEquals(0.5f, progress) + } + + @Test + fun givenDownloadFailure_whenStartCalled_thenOnErrorCallbackInvoked() = runTest { + val (_, controller) = Arrangement() + .withDownloadFailure() + .arrange() + var errorReceived: CellError? = null + + controller.start(scope = this, cellNode = testFile, onSuccess = {}, onError = { errorReceived = it }) + advanceUntilIdle() + + assertEquals(CellError.DOWNLOAD_FAILED, errorReceived) + } + + @Test + fun givenNoSpaceLeftFailure_whenStartCalled_thenNoSpaceLeftErrorReturned() = runTest { + val (_, controller) = Arrangement() + .withNoSpaceLeftFailure() + .arrange() + var errorReceived: CellError? = null + + controller.start(scope = this, cellNode = testFile, onSuccess = {}, onError = { errorReceived = it }) + advanceUntilIdle() + + assertEquals(CellError.NO_SPACE_LEFT, errorReceived) + } + + @Test + fun givenDownloadFailure_whenStartCalled_thenProgressCleared() = runTest { + val (_, controller) = Arrangement() + .withDownloadFailure() + .arrange() + + controller.start(scope = this, cellNode = testFile, onSuccess = {}, onError = {}) + advanceUntilIdle() + + assertNull(controller.downloadProgresses.value[testFile.uuid]) + } + + @Test + fun givenActiveDownload_whenCancelCalled_thenProgressCleared() = runTest { + val (_, controller) = Arrangement() + .withSlowDownloadSuccess() + .arrange() + + controller.start(scope = this, cellNode = testFile, onSuccess = {}, onError = {}) + advanceTimeBy(100.milliseconds) + + controller.cancel(testFile.uuid, this) + + assertNull(controller.downloadProgresses.value[testFile.uuid], "Progress should be cleared on cancel") + } + + @Test + fun givenActiveDownload_whenCancelCalled_thenOnSuccessNotInvoked() = runTest { + val (_, controller) = Arrangement() + .withSlowDownloadSuccess() + .arrange() + var successCalled = false + + controller.start(scope = this, cellNode = testFile, onSuccess = { successCalled = true }, onError = {}) + advanceTimeBy(100.milliseconds) + + controller.cancel(testFile.uuid, this) + advanceUntilIdle() + + assertTrue(!successCalled, "onSuccess must not be called after cancel") + } + + @Test + fun givenNoActiveDownload_whenCancelCalled_thenNothingHappens() = runTest { + val (_, controller) = Arrangement().arrange() + + // Should not throw + controller.cancel("non-existent-uuid", this) + } + + @Test + fun givenRapidRetry_whenStartCalledTwice_thenOnlySecondDownloadCompletes() = runTest { + val (_, controller) = Arrangement() + .withSlowDownloadSuccess() + .arrange() + var successCount = 0 + + controller.start(scope = this, cellNode = testFile, onSuccess = { successCount++ }, onError = {}) + controller.start(scope = this, cellNode = testFile, onSuccess = { successCount++ }, onError = {}) + advanceUntilIdle() + + assertEquals(1, successCount, "Only the second download should complete") + } + + @Test + fun givenFileWithConversationId_whenDownloadSucceeds_thenFileStoredInConversationDirectory() = runTest { + val (_, controller) = Arrangement() + .withDownloadSuccess() + .arrange() + var successPath: String? = null + + controller.start( + scope = this, + cellNode = testFile.copy(conversationId = "conv-123"), + onSuccess = { successPath = it }, + onError = {}, + ) + advanceUntilIdle() + + assertNotNull(successPath) + assertTrue( + successPath!!.contains("conv-123"), + "File should be stored under the conversation directory, got: $successPath" + ) + } + + @Test + fun givenFileWithNoConversationId_whenDownloadSucceeds_thenFileStoredUnderUuidDirectory() = runTest { + val (_, controller) = Arrangement() + .withDownloadSuccess() + .arrange() + var successPath: String? = null + + controller.start( + scope = this, + cellNode = testFile.copy(conversationId = null), + onSuccess = { successPath = it }, + onError = {}, + ) + advanceUntilIdle() + + assertNotNull(successPath) + assertTrue( + successPath!!.contains(testFile.uuid), + "Standalone file should fall back to UUID directory, got: $successPath" + ) + } + + private companion object { + val testFile = CellNodeUi.File( + uuid = "test-uuid", + conversationId = "conversation-id", + name = "report.pdf", + mimeType = "application/pdf", + assetType = AttachmentFileType.OTHER, + localPath = null, + size = 1024L, + remotePath = "remote/report.pdf", + userName = null, + userHandle = null, + ownerUserId = null, + conversationName = null, + modifiedTime = null, + ) + } + + private inner class Arrangement { + + @MockK + lateinit var downloadUseCase: DownloadCellFileUseCase + + @MockK + lateinit var saveOfflineFile: SaveOfflineFileUseCase + + @MockK + lateinit var fileHelper: FileHelper + + val sharedPathCache = CellFileLocalPathCache() + val externalFilesDir: File = createTempDirectory("cells-offline-test").toFile() + + init { + MockKAnnotations.init(this, relaxUnitFun = true) + coEvery { saveOfflineFile(any()) } returns Unit + coEvery { fileHelper.getExternalFilesDir() } returns externalFilesDir + } + + fun withDownloadSuccess() = apply { + coEvery { downloadUseCase(any(), any(), any(), any(), any(), any(), any(), any()) } returns Unit.right() + } + + fun withSlowDownloadSuccess() = apply { + coEvery { downloadUseCase(any(), any(), any(), any(), any(), any(), any(), any()) } coAnswers { + delay(500.milliseconds) + Unit.right() + } + } + + fun withDownloadFailure() = apply { + coEvery { downloadUseCase(any(), any(), any(), any(), any(), any(), any(), any()) } returns + StorageFailure.DataNotFound.left() + } + + fun withNoSpaceLeftFailure() = apply { + coEvery { downloadUseCase(any(), any(), any(), any(), any(), any(), any(), any()) } returns + NetworkFailure.ServerMiscommunication(IOException("No space left on device")).left() + } + + fun withProgressThenSuccess(bytesDownloaded: Long) = apply { + coEvery { downloadUseCase(any(), any(), any(), any(), any(), any(), any(), any()) } coAnswers { + val onProgressUpdate = arg<(Long) -> Unit>(7) + delay(200.milliseconds) + onProgressUpdate(bytesDownloaded) + delay(100.milliseconds) + Unit.right() + } + } + + fun arrange() = this to OfflineFileDownloadController( + download = downloadUseCase, + fileHelper = fileHelper, + saveOfflineFile = saveOfflineFile, + sharedPathCache = sharedPathCache, + ) + } +} diff --git a/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/OpenFileDownloadControllerTest.kt b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/OpenFileDownloadControllerTest.kt index ac0af348d3d..1875b63c55e 100644 --- a/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/OpenFileDownloadControllerTest.kt +++ b/features/cells/src/test/kotlin/com/wire/android/feature/cells/ui/OpenFileDownloadControllerTest.kt @@ -22,7 +22,6 @@ import com.wire.android.feature.cells.domain.model.AttachmentFileType import com.wire.android.feature.cells.ui.model.CellNodeUi import com.wire.android.feature.cells.ui.model.OpenLoadState import com.wire.android.feature.cells.util.FileHelper -import com.wire.android.feature.cells.util.FileNameResolver import com.wire.kalium.cells.domain.usecase.download.DownloadCellFileUseCase import com.wire.kalium.common.error.StorageFailure import com.wire.kalium.common.functional.left @@ -48,6 +47,7 @@ import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import java.io.File +import kotlin.io.path.createTempDirectory class OpenFileDownloadControllerTest { @@ -80,9 +80,10 @@ class OpenFileDownloadControllerTest { } @Test - fun givenFileWithLocalPath_whenStartCalled_thenFileOpenedImmediatelyWithoutDownload() = runTest { + fun givenFileWithLocalPath_whenFileExistsOnDisk_thenFileOpenedImmediatelyWithoutDownload() = runTest { val (arrangement, controller) = Arrangement().arrange() - val fileWithLocalPath = testFile.copy(localPath = "/local/path/report.pdf") + val realFile = File(arrangement.externalFilesDir, "report.pdf").also { it.createNewFile() } + val fileWithLocalPath = testFile.copy(localPath = realFile.absolutePath) var openedFile: CellNodeUi.File? = null controller.start( @@ -98,6 +99,27 @@ class OpenFileDownloadControllerTest { coVerify(exactly = 0) { arrangement.downloadUseCase(any(), any(), any(), any(), any(), any(), any(), any()) } } + @Test + fun givenFileWithLocalPath_whenFileDeletedFromDisk_thenDownloadStarted() = runTest { + val (arrangement, controller) = Arrangement() + .withDownloadSuccess() + .arrange() + // localPath points to a file that no longer exists on disk + val fileWithStalePath = testFile.copy(localPath = "/non/existent/report.pdf") + + controller.start( + scope = this, + cellNode = fileWithStalePath, + onOpenFile = {}, + onError = {}, + ) + advanceUntilIdle() + + coVerify(exactly = 1) { + arrangement.downloadUseCase(eq(testFile.uuid), any(), any(), any(), any(), any(), any(), any()) + } + } + @Test fun givenFastDownloadSuccess_whenStartCalled_thenFileOpenedImmediatelyAndNoLoadStateSet() = runTest { val (_, controller) = Arrangement() @@ -405,15 +427,13 @@ class OpenFileDownloadControllerTest { @MockK lateinit var fileHelper: FileHelper - @MockK - lateinit var fileNameResolver: FileNameResolver - val sharedPathCache = CellFileLocalPathCache() + val externalFilesDir: File = createTempDirectory("cells-test").toFile() + init { MockKAnnotations.init(this, relaxUnitFun = true) - every { fileHelper.getExternalFilesDir() } returns File("") - every { fileNameResolver.getUniqueFile(any(), any()) } returns File("report.pdf") + every { fileHelper.getExternalFilesDir() } returns externalFilesDir } fun withDownloadSuccess(uuid: String = testFile.uuid) = apply { @@ -445,7 +465,6 @@ class OpenFileDownloadControllerTest { fun arrange() = this to OpenFileDownloadController( download = downloadUseCase, fileHelper = fileHelper, - fileNameResolver = fileNameResolver, sharedPathCache = sharedPathCache, ) } diff --git a/features/cells/src/test/kotlin/com/wire/android/feature/cells/util/FileNameResolverTest.kt b/features/cells/src/test/kotlin/com/wire/android/feature/cells/util/FileNameResolverTest.kt deleted file mode 100644 index b4f8db54bdf..00000000000 --- a/features/cells/src/test/kotlin/com/wire/android/feature/cells/util/FileNameResolverTest.kt +++ /dev/null @@ -1,96 +0,0 @@ -/* - * Wire - * Copyright (C) 2025 Wire Swiss GmbH - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see http://www.gnu.org/licenses/. - */ -package com.wire.android.feature.cells.util - -import org.junit.jupiter.api.Assertions -import org.junit.jupiter.api.Assertions.assertFalse -import org.junit.jupiter.api.Test -import java.io.File -import kotlin.io.path.createTempDirectory -import kotlin.io.path.deleteRecursively -import kotlin.io.path.ExperimentalPathApi - -@OptIn(ExperimentalPathApi::class) -class FileNameResolverTest { - - @Test - fun given_file_does_not_existWhen_getUniqueFile_calledThen_returns_original_file_name() { - // Given - val tempDir = createTempDirectory().toFile() - val fileName = "document.txt" - - // When - val result = FileNameResolver().getUniqueFile(tempDir, fileName) - - // Then - Assertions.assertEquals("document.txt", result.name) - assertFalse(result.exists()) - - tempDir.toPath().deleteRecursively() - } - - @Test - fun given_file_existsWhen_getUniqueFile_calledThen_returns_file_name_with_1_suffix() { - // Given - val tempDir = createTempDirectory().toFile() - File(tempDir, "image.png").createNewFile() - - // When - val result = FileNameResolver().getUniqueFile(tempDir, "image.png") - - // Then - Assertions.assertEquals("image(1).png", result.name) - assertFalse(result.exists()) - - tempDir.toPath().deleteRecursively() - } - - @Test - fun given_multiple_conflicting_files_existWhen_getUniqueFile_calledThen_returns_file_name_with_next_available_index() { - // Given - val tempDir = createTempDirectory().toFile() - File(tempDir, "file.txt").createNewFile() - File(tempDir, "file(1).txt").createNewFile() - File(tempDir, "file(2).txt").createNewFile() - - // When - val result = FileNameResolver().getUniqueFile(tempDir, "file.txt") - - // Then - Assertions.assertEquals("file(3).txt", result.name) - assertFalse(result.exists()) - - tempDir.toPath().deleteRecursively() - } - - @Test - fun given_file_without_extension_existsWhen_getUniqueFile_calledThen_returns_file_name_with_1_suffix() { - // Given - val tempDir = createTempDirectory().toFile() - File(tempDir, "LICENSE").createNewFile() - - // When - val result = FileNameResolver().getUniqueFile(tempDir, "LICENSE") - - // Then - Assertions.assertEquals("LICENSE(1)", result.name) - assertFalse(result.exists()) - - tempDir.toPath().deleteRecursively() - } -}