diff --git a/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/NewTransferViewModel.kt b/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/NewTransferViewModel.kt new file mode 100644 index 000000000..bc9fecf33 --- /dev/null +++ b/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/NewTransferViewModel.kt @@ -0,0 +1,50 @@ +/* + * Infomaniak SwissTransfer - Android + * Copyright (C) 2024 Infomaniak Network SA + * + * 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 . + */ +package com.infomaniak.swisstransfer.ui.screen.newtransfer + +import android.net.Uri +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class NewTransferViewModel @Inject constructor(private val transferFilesManager: TransferFilesManager) : ViewModel() { + + private val _transferFiles = MutableStateFlow>(emptyList()) + val transferFiles: StateFlow> = _transferFiles + + private val _failedTransferFileCount = MutableSharedFlow() + val failedTransferFileCount: SharedFlow = _failedTransferFileCount + + fun addFiles(uris: List) { + viewModelScope.launch { + val alreadyUsedFileNames = buildSet { transferFiles.value.forEach { add(it.fileName) } } + + val newTransferFiles = transferFilesManager.getTransferFiles(uris, alreadyUsedFileNames) + + _transferFiles.value += newTransferFiles + _failedTransferFileCount.emit(uris.count() - newTransferFiles.count()) + } + } +} diff --git a/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/TransferFile.kt b/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/TransferFile.kt new file mode 100644 index 000000000..e1e98d815 --- /dev/null +++ b/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/TransferFile.kt @@ -0,0 +1,22 @@ +/* + * Infomaniak SwissTransfer - Android + * Copyright (C) 2024 Infomaniak Network SA + * + * 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 . + */ +package com.infomaniak.swisstransfer.ui.screen.newtransfer + +import android.net.Uri + +data class TransferFile(val fileName: String, val size: Long, val uri: Uri) diff --git a/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/TransferFilesManager.kt b/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/TransferFilesManager.kt new file mode 100644 index 000000000..71ee71b60 --- /dev/null +++ b/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/TransferFilesManager.kt @@ -0,0 +1,73 @@ +/* + * Infomaniak SwissTransfer - Android + * Copyright (C) 2024 Infomaniak Network SA + * + * 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 . + */ +package com.infomaniak.swisstransfer.ui.screen.newtransfer + +import android.content.ContentResolver +import android.content.Context +import android.database.Cursor +import android.net.Uri +import android.provider.OpenableColumns +import com.infomaniak.swisstransfer.ui.utils.FileNameUtils +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class TransferFilesManager @Inject constructor(@ApplicationContext private val appContext: Context) { + fun getTransferFiles(uris: List, alreadyUsedFileNames: Set): MutableSet { + val currentUsedFileNames = alreadyUsedFileNames.toMutableSet() + val transferFiles = mutableSetOf() + + uris.forEach { uri -> + getTransferFile(uri, currentUsedFileNames)?.let { transferFile -> + currentUsedFileNames += transferFile.fileName + transferFiles += transferFile + } + } + + return transferFiles + } + + private fun getTransferFile(uri: Uri, alreadyUsedFileNames: Set): TransferFile? { + val contentResolver: ContentResolver = appContext.contentResolver + val cursor: Cursor? = contentResolver.query(uri, null, null, null, null) + + return cursor?.getFileNameAndSize()?.let { (name, size) -> + val uniqueName = FileNameUtils.postfixExistingFileNames(name, alreadyUsedFileNames) + TransferFile(uniqueName, size, uri) + } + } + + private fun Cursor.getFileNameAndSize(): Pair? = use { + if (it.moveToFirst()) { + val displayNameColumnIndex = it.getColumnIndexOrNull(OpenableColumns.DISPLAY_NAME) ?: return null + val fileName = it.getString(displayNameColumnIndex) + + val fileSizeColumnIndex = it.getColumnIndexOrNull(OpenableColumns.SIZE) ?: return null + val fileSize = it.getLong(fileSizeColumnIndex) + + fileName to fileSize + } else { + null + } + } + + private fun Cursor.getColumnIndexOrNull(column: String): Int? { + return getColumnIndex(column).takeIf { it != -1 } + } +} diff --git a/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/importfiles/ImportFilesScreen.kt b/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/importfiles/ImportFilesScreen.kt index 74c759a0d..ba400fa79 100644 --- a/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/importfiles/ImportFilesScreen.kt +++ b/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/importfiles/ImportFilesScreen.kt @@ -17,26 +17,53 @@ */ package com.infomaniak.swisstransfer.ui.screen.newtransfer.importfiles -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Text +import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.infomaniak.swisstransfer.R import com.infomaniak.swisstransfer.ui.components.* import com.infomaniak.swisstransfer.ui.images.AppImages.AppIcons import com.infomaniak.swisstransfer.ui.images.AppImages.AppIllus import com.infomaniak.swisstransfer.ui.images.icons.Add import com.infomaniak.swisstransfer.ui.images.illus.MascotWithMagnifyingGlass +import com.infomaniak.swisstransfer.ui.screen.newtransfer.NewTransferViewModel +import com.infomaniak.swisstransfer.ui.screen.newtransfer.TransferFile import com.infomaniak.swisstransfer.ui.theme.SwissTransferTheme import com.infomaniak.swisstransfer.ui.utils.PreviewLargeWindow import com.infomaniak.swisstransfer.ui.utils.PreviewSmallWindow @Composable -fun ImportFilesScreen(navigateToTransferTypeScreen: () -> Unit, closeActivity: () -> Unit) { +fun ImportFilesScreen( + newTransferViewModel: NewTransferViewModel = hiltViewModel(), + navigateToTransferTypeScreen: () -> Unit, + closeActivity: () -> Unit, +) { + val transferFiles by newTransferViewModel.transferFiles.collectAsStateWithLifecycle() + ImportFilesScreen({ transferFiles }, newTransferViewModel::addFiles, navigateToTransferTypeScreen, closeActivity) +} +@Composable +private fun ImportFilesScreen( + transferFiles: () -> List, + addFiles: (List) -> Unit, + navigateToTransferTypeScreen: () -> Unit, + closeActivity: () -> Unit, +) { var showUploadSourceChoiceBottomSheet by rememberSaveable { mutableStateOf(true) } - var isNextButtonEnabled by rememberSaveable { mutableStateOf(false) } + val isNextButtonEnabled by remember { derivedStateOf { transferFiles().isNotEmpty() } } + + val filePickerLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenMultipleDocuments() + ) { uris: List -> + addFiles(uris) + } BottomStickyButtonScaffold( topBar = { @@ -52,10 +79,7 @@ fun ImportFilesScreen(navigateToTransferTypeScreen: () -> Unit, closeActivity: ( titleRes = R.string.buttonAddFiles, imageVector = AppIcons.Add, style = ButtonType.TERTIARY, - onClick = { - showUploadSourceChoiceBottomSheet = true - isNextButtonEnabled = true // TODO: Move this where it should be - }, + onClick = { showUploadSourceChoiceBottomSheet = true }, ) }, bottomButton = { modifier -> @@ -67,14 +91,24 @@ fun ImportFilesScreen(navigateToTransferTypeScreen: () -> Unit, closeActivity: ( ) }, content = { - EmptyState( - icon = AppIllus.MascotWithMagnifyingGlass, - title = R.string.noFileTitle, - description = R.string.noFileDescription, - ) + if (transferFiles().isEmpty()) { + EmptyState( + icon = AppIllus.MascotWithMagnifyingGlass, + title = R.string.noFileTitle, + description = R.string.noFileDescription, + ) + } else { + LazyColumn { + items(items = transferFiles(), key = { it.fileName }) { file -> + Text(text = "${file.fileName} - ${file.size}") + } + } + } + UploadSourceChoiceBottomSheet( isBottomSheetVisible = { showUploadSourceChoiceBottomSheet }, - onDismissRequest = { showUploadSourceChoiceBottomSheet = false }, + onFilePickerClicked = { filePickerLauncher.launch(arrayOf("*/*")) }, + closeBottomSheet = { showUploadSourceChoiceBottomSheet = false }, ) }, ) @@ -85,6 +119,6 @@ fun ImportFilesScreen(navigateToTransferTypeScreen: () -> Unit, closeActivity: ( @Composable private fun ImportFilesScreenPreview() { SwissTransferTheme { - ImportFilesScreen({}, {}) + ImportFilesScreen({ emptyList() }, {}, navigateToTransferTypeScreen = {}, closeActivity = {}) } } diff --git a/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/importfiles/UploadSourceChoiceBottomSheet.kt b/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/importfiles/UploadSourceChoiceBottomSheet.kt index 1efa2919d..7c762f125 100644 --- a/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/importfiles/UploadSourceChoiceBottomSheet.kt +++ b/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/importfiles/UploadSourceChoiceBottomSheet.kt @@ -38,30 +38,40 @@ import com.infomaniak.swisstransfer.ui.utils.PreviewSmallWindow @Composable fun UploadSourceChoiceBottomSheet( isBottomSheetVisible: () -> Boolean, - onDismissRequest: () -> Unit, + onFilePickerClicked: () -> Unit, + closeBottomSheet: () -> Unit, ) { if (isBottomSheetVisible()) { SwissTransferBottomSheet( - onDismissRequest = onDismissRequest, + onDismissRequest = closeBottomSheet, titleRes = R.string.transferUploadSourceChoiceTitle, content = { Column { BottomSheetItem( imageVector = AppIcons.Camera, titleRes = R.string.transferUploadSourceChoiceCamera, - onClick = { /* TODO */ }, + onClick = { + closeBottomSheet() + /* TODO */ + }, ) HorizontalDivider(Modifier.padding(horizontal = Margin.Medium)) BottomSheetItem( imageVector = AppIcons.PolaroidLandscape, titleRes = R.string.transferUploadSourceChoiceGallery, - onClick = { /* TODO */ }, + onClick = { + closeBottomSheet() + /* TODO */ + }, ) HorizontalDivider(Modifier.padding(horizontal = Margin.Medium)) BottomSheetItem( imageVector = AppIcons.Folder, titleRes = R.string.transferUploadSourceChoiceFiles, - onClick = { /* TODO */ }, + onClick = { + closeBottomSheet() + onFilePickerClicked() + }, ) } }, @@ -75,7 +85,11 @@ fun UploadSourceChoiceBottomSheet( private fun UploadSourceChoiceBottomSheetPreview() { SwissTransferTheme { Surface { - UploadSourceChoiceBottomSheet(isBottomSheetVisible = { true }, onDismissRequest = {}) + UploadSourceChoiceBottomSheet( + isBottomSheetVisible = { true }, + onFilePickerClicked = {}, + closeBottomSheet = {} + ) } } } diff --git a/app/src/main/java/com/infomaniak/swisstransfer/ui/utils/FileNameUtils.kt b/app/src/main/java/com/infomaniak/swisstransfer/ui/utils/FileNameUtils.kt new file mode 100644 index 000000000..878b01097 --- /dev/null +++ b/app/src/main/java/com/infomaniak/swisstransfer/ui/utils/FileNameUtils.kt @@ -0,0 +1,72 @@ +/* + * Infomaniak SwissTransfer - Android + * Copyright (C) 2024 Infomaniak Network SA + * + * 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 . + */ +package com.infomaniak.swisstransfer.ui.utils + +object FileNameUtils { + + fun postfixExistingFileNames(fileName: String, existingFileNames: Set): String { + return if (fileName in existingFileNames) { + val postfixedFileName = PostfixedFileName.fromFileName(fileName) + + while (postfixedFileName.fullName() in existingFileNames) { + postfixedFileName.incrementPostfix() + } + + postfixedFileName.fullName() + } else { + fileName + } + } + + private data class PostfixedFileName( + private val start: String, + private var postfixNumber: Int, + private val end: String, + private val extension: String, + ) { + fun incrementPostfix() { + postfixNumber += 1 + } + + fun fullName(): String = "$start$postfixNumber$end$extension" + + companion object { + fun fromFileName(fileName: String): PostfixedFileName { + val (name, ext) = splitNameAndExtension(fileName) + + return PostfixedFileName( + start = "$name(", + postfixNumber = 1, + end = ")", + extension = ext, + ) + } + + private fun splitNameAndExtension(fileName: String): Pair { + val dotIndex = fileName.lastIndexOf('.') + + // If there's no dot or it's the first/last character, return the whole name and an empty extension + return if (dotIndex == -1) { + fileName to "" + } else { + fileName.substring(0, dotIndex) to fileName.substring(dotIndex) + } + } + } + } +} diff --git a/app/src/test/java/com/infomaniak/swisstransfer/PostifxedFileNameUnitTest.kt b/app/src/test/java/com/infomaniak/swisstransfer/PostifxedFileNameUnitTest.kt new file mode 100644 index 000000000..6ad50852d --- /dev/null +++ b/app/src/test/java/com/infomaniak/swisstransfer/PostifxedFileNameUnitTest.kt @@ -0,0 +1,99 @@ +/* + * Infomaniak SwissTransfer - Android + * Copyright (C) 2024 Infomaniak Network SA + * + * 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 . + */ +package com.infomaniak.swisstransfer + +import com.infomaniak.swisstransfer.ui.utils.FileNameUtils.postfixExistingFileNames +import org.junit.Assert.assertEquals +import org.junit.Test + +class PostifxedFileNameUnitTest { + @Test + fun unusedName_isUnchanged() { + val inputFileName = "world.txt" + val newName = postfixExistingFileNames(inputFileName, alreadyUsedFileNames) + assertEquals(newName, inputFileName) + } + + @Test + fun usedName_isPostfixed() { + val newName = postfixExistingFileNames("hello.txt", alreadyUsedFileNames) + assertEquals(newName, "hello(1).txt") + } + + @Test + fun alreadyPostfixedName_isPostfixedAgain() { + val newName = postfixExistingFileNames("test(1).txt", alreadyUsedFileNames) + assertEquals(newName, "test(1)(1).txt") + } + + @Test + fun postfixedNameThatCollidesWithAnotherName_isPostfixedUntilNoMoreCollision() { + val newName = postfixExistingFileNames("test.txt", alreadyUsedFileNames) + assertEquals(newName, "test(3).txt") + } + + @Test + fun unusedEmptyFileName_isUnchanged() { + assertNewFileNameIsUnchanged(inputFileName = "") + } + + @Test + fun usedEmptyFileName_isPostfixed() { + assertAlreadyExistingFileName(inputFileName = "", expectedFileName = "(1)") + } + + @Test + fun unusedNoExtensionFileName_isUnchanged() { + assertNewFileNameIsUnchanged(inputFileName = "file") + } + + @Test + fun usedNoExtensionFileName_isPostfixed() { + assertAlreadyExistingFileName(inputFileName = "file", expectedFileName = "file(1)") + } + + @Test + fun multipleExtensionFileName_isPostfixedBeforeLastOne() { + assertAlreadyExistingFileName(inputFileName = "file.abc.def.ghi", expectedFileName = "file.abc.def(1).ghi") + } + + @Test + fun unusedDotEndingFileName_isUnchanged() { + assertNewFileNameIsUnchanged(inputFileName = "file.") + } + + @Test + fun usedDotEndingFileName_isPostfixed() { + assertAlreadyExistingFileName(inputFileName = "file.", expectedFileName = "file(1).") + } + + private fun assertAlreadyExistingFileName(inputFileName: String, expectedFileName: String) { + val newName = postfixExistingFileNames(inputFileName, setOf(inputFileName)) + assertEquals(newName, expectedFileName) + } + + private fun assertNewFileNameIsUnchanged(inputFileName: String) { + val newName = postfixExistingFileNames(inputFileName, emptySet()) + assertEquals(newName, inputFileName) + } + + private companion object { + // Used for tests that require to check existing filenames among multiple edge case already existing filenames + val alreadyUsedFileNames = setOf("test.txt", "test(1).txt", "test(2).txt", "hello.txt") + } +}