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")
+ }
+}