diff --git a/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/AlreadyUsedFileNamesSet.kt b/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/AlreadyUsedFileNamesSet.kt
new file mode 100644
index 000000000..6bcc2af99
--- /dev/null
+++ b/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/AlreadyUsedFileNamesSet.kt
@@ -0,0 +1,46 @@
+/*
+ * 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 com.infomaniak.swisstransfer.ui.screen.newtransfer.AlreadyUsedFileNamesSet.AlreadyUsedStrategy
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+
+class AlreadyUsedFileNamesSet {
+ private val alreadyUsedFileNames = mutableSetOf()
+ private val mutex = Mutex()
+
+ suspend fun addUniqueFileName(computeUniqueFileName: (AlreadyUsedStrategy) -> String): String {
+ val uniqueFileName: String
+
+ mutex.withLock {
+ val alreadyUsedStrategy = AlreadyUsedStrategy { alreadyUsedFileNames.contains(it) }
+ uniqueFileName = computeUniqueFileName(alreadyUsedStrategy)
+ alreadyUsedFileNames.add(uniqueFileName)
+ }
+
+ return uniqueFileName
+ }
+
+ suspend fun addAll(filesNames: List): Boolean = mutex.withLock { alreadyUsedFileNames.addAll(filesNames) }
+ suspend fun remove(filesName: String): Boolean = mutex.withLock { alreadyUsedFileNames.remove(filesName) }
+
+ fun interface AlreadyUsedStrategy {
+ fun isAlreadyUsed(fileName: String): Boolean
+ }
+}
diff --git a/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/FilesMutableStateFlow.kt b/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/FilesMutableStateFlow.kt
new file mode 100644
index 000000000..40ff6fea0
--- /dev/null
+++ b/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/FilesMutableStateFlow.kt
@@ -0,0 +1,48 @@
+/*
+ * 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 com.infomaniak.swisstransfer.ui.components.FileUi
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import java.io.File
+
+class FilesMutableStateFlow {
+ private val files = MutableStateFlow>(emptyList())
+ private val mutex = Mutex()
+
+ val flow = files.asStateFlow()
+
+ suspend fun addAll(newFiles: List): Unit = mutex.withLock { files.value += newFiles }
+ suspend fun add(newFile: FileUi): Unit = mutex.withLock { files.value += newFile }
+
+ suspend fun removeByUid(uid: String): String? = mutex.withLock {
+ val files = files.value.toMutableList()
+
+ val index = files.indexOfFirst { it.uid == uid }.takeIf { it != -1 } ?: return null
+ val fileToRemove = files.removeAt(index)
+
+ runCatching { File(fileToRemove.uri).delete() }
+
+ this.files.value = files
+
+ fileToRemove.fileName
+ }
+}
diff --git a/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/ImportLocalStorage.kt b/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/ImportLocalStorage.kt
new file mode 100644
index 000000000..f1e350390
--- /dev/null
+++ b/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/ImportLocalStorage.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.screen.newtransfer
+
+import android.content.Context
+import com.infomaniak.sentry.SentryLog
+import dagger.hilt.android.qualifiers.ApplicationContext
+import java.io.File
+import java.io.InputStream
+import java.io.OutputStream
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class ImportLocalStorage @Inject constructor(@ApplicationContext private val appContext: Context) {
+
+ private val importFolder by lazy { File(appContext.cacheDir, LOCAL_COPY_FOLDER) }
+
+ fun removeImportFolder() {
+ if (importFolder.exists()) runCatching { importFolder.deleteRecursively() }
+ }
+
+ fun importFolderExists() = importFolder.exists()
+
+ fun getLocalFiles(): Array? = importFolder.listFiles()
+
+ fun copyUriDataLocally(inputStream: InputStream, fileName: String): File? {
+ val file = File(getImportFolderOrCreate(), fileName)
+
+ if (file.exists()) file.delete()
+ runCatching { file.createNewFile() }.onFailure { return null }
+
+ runCatching {
+ copyStreams(inputStream, file.outputStream())
+ }.onFailure {
+ SentryLog.w(TAG, "Caught an exception while copying file to local storage: $it")
+ return null
+ }
+
+ return file
+ }
+
+ private fun copyStreams(inputStream: InputStream, outputStream: OutputStream): Long {
+ return inputStream.use { inputStream ->
+ outputStream.use { outputStream ->
+ inputStream.copyTo(outputStream)
+ }
+ }
+ }
+
+ private fun getImportFolderOrCreate() = importFolder.apply { if (!exists()) mkdirs() }
+
+ companion object {
+ const val TAG = "Importation stream copy"
+ private const val LOCAL_COPY_FOLDER = "local_copy_folder"
+ }
+}
diff --git a/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/ImportationFilesManager.kt b/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/ImportationFilesManager.kt
new file mode 100644
index 000000000..338e98bec
--- /dev/null
+++ b/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/ImportationFilesManager.kt
@@ -0,0 +1,197 @@
+/*
+ * 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 androidx.core.net.toUri
+import com.infomaniak.sentry.SentryLog
+import com.infomaniak.swisstransfer.ui.components.FileUi
+import com.infomaniak.swisstransfer.ui.utils.FileNameUtils
+import dagger.hilt.android.qualifiers.ApplicationContext
+import io.sentry.Sentry
+import io.sentry.SentryLevel
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import java.io.File
+import java.io.InputStream
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class ImportationFilesManager @Inject constructor(
+ @ApplicationContext private val appContext: Context,
+ private val importLocalStorage: ImportLocalStorage,
+) {
+
+ private val filesToImportChannel: TransferCountChannel = TransferCountChannel()
+ val filesToImportCount: StateFlow = filesToImportChannel.count
+ val currentSessionFilesCount: StateFlow = filesToImportChannel.currentSessionFilesCount
+
+ private val _importedFiles = FilesMutableStateFlow()
+ val importedFiles = _importedFiles.flow
+
+ private val _failedFiles = MutableSharedFlow()
+ val failedFiles = _failedFiles.asSharedFlow()
+
+ // Importing a file locally can take up time. We can't base the list of already used names on _importedFiles's value because a
+ // new import with the same name could occur while the file is still importing. This would lead to a name collision.
+ // This list needs to mark a name as "taken" as soon as the file is queued to be imported and until the file is removed from
+ // the list of already imported files we listen to in the LazyRow.
+ private val alreadyUsedFileNames = AlreadyUsedFileNamesSet()
+
+ suspend fun importFiles(uris: List) {
+ uris.extractPickedFiles().forEach { filesToImportChannel.send(it) }
+ }
+
+ suspend fun removeFileByUid(uid: String) {
+ _importedFiles.removeByUid(uid)?.also { removedFileName ->
+ alreadyUsedFileNames.remove(removedFileName)
+ }
+ }
+
+ fun removeLocalCopyFolder() = importLocalStorage.removeImportFolder()
+
+ suspend fun restoreAlreadyImportedFiles() {
+ if (!importLocalStorage.importFolderExists()) return
+
+ val localFiles = importLocalStorage.getLocalFiles() ?: return
+ val restoredFileUi = getRestoredFileUi(localFiles)
+
+ if (localFiles.size != restoredFileUi.size) {
+ Sentry.withScope { scope ->
+ scope.level = SentryLevel.ERROR
+ Sentry.captureMessage("Restoration failure of already imported files after a process kill")
+ }
+ }
+
+ alreadyUsedFileNames.addAll(restoredFileUi.map { it.fileName })
+ _importedFiles.addAll(restoredFileUi)
+ }
+
+ suspend fun continuouslyCopyPickedFilesToLocalStorage() {
+ filesToImportChannel.consume { fileToImport ->
+ SentryLog.i(TAG, "Importing ${fileToImport.uri}")
+ val copiedFile = copyUriDataLocally(fileToImport.uri, fileToImport.fileName)
+
+ if (copiedFile == null) {
+ reportFailedImportation(fileToImport)
+ return@consume
+ }
+
+ SentryLog.i(TAG, "Successfully imported ${fileToImport.uri}")
+
+ _importedFiles.add(
+ FileUi(
+ uid = fileToImport.fileName,
+ fileName = fileToImport.fileName,
+ fileSizeInBytes = fileToImport.fileSizeInBytes,
+ mimeType = null,
+ uri = copiedFile.toUri().toString(),
+ )
+ )
+ }
+ }
+
+ private fun copyUriDataLocally(uri: Uri, fileName: String): File? {
+ val inputStream = openInputStream(uri) ?: return null
+ return importLocalStorage.copyUriDataLocally(inputStream, fileName)
+ }
+
+ private fun openInputStream(uri: Uri): InputStream? {
+ return appContext.contentResolver.openInputStream(uri) ?: run {
+ SentryLog.w(ImportLocalStorage.TAG, "During local copy of the file openInputStream returned null")
+ null
+ }
+ }
+
+ private fun getRestoredFileUi(localFiles: Array): List {
+ return localFiles.mapNotNull { localFile ->
+ val fileSizeInBytes = runCatching { localFile.length() }
+ .onFailure { Sentry.addBreadcrumb("Caught an exception while restoring imported files: $it") }
+ .getOrNull() ?: return@mapNotNull null
+
+ FileUi(
+ uid = localFile.name,
+ fileName = localFile.name,
+ fileSizeInBytes = fileSizeInBytes,
+ mimeType = null,
+ uri = localFile.toUri().toString(),
+ )
+ }
+ }
+
+ private suspend fun List.extractPickedFiles(): Set {
+ val files = buildSet {
+ this@extractPickedFiles.forEach { uri ->
+ extractPickedFile(uri)?.let(::add)
+ }
+ }
+
+ return files
+ }
+
+ private suspend fun extractPickedFile(uri: Uri): PickedFile? {
+ val contentResolver: ContentResolver = appContext.contentResolver
+ val cursor: Cursor? = contentResolver.query(uri, null, null, null, null)
+
+ return cursor?.getFileNameAndSize()?.let { (name, size) ->
+ val uniqueName = alreadyUsedFileNames.addUniqueFileName(computeUniqueFileName = { alreadyUsedStrategy ->
+ FileNameUtils.postfixExistingFileNames(
+ fileName = name,
+ isAlreadyUsed = { alreadyUsedStrategy.isAlreadyUsed(it) }
+ )
+ })
+
+ PickedFile(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 }
+ }
+
+ private suspend fun reportFailedImportation(file: PickedFile) {
+ SentryLog.e(TAG, "Failed importation of ${file.uri}")
+ _failedFiles.emit(file)
+ }
+
+ data class PickedFile(val fileName: String, val fileSizeInBytes: Long, val uri: Uri)
+
+ companion object {
+ private const val TAG = "File importation"
+ }
+}
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
index 4696748f6..2612e19e4 100644
--- 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
@@ -18,38 +18,73 @@
package com.infomaniak.swisstransfer.ui.screen.newtransfer
import android.net.Uri
+import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
-import com.infomaniak.swisstransfer.ui.components.FileUi
+import com.infomaniak.swisstransfer.di.IoDispatcher
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.CoroutineDispatcher
+import kotlinx.coroutines.FlowPreview
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.debounce
+import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
-class NewTransferViewModel @Inject constructor(private val transferFilesManager: TransferFilesManager) : ViewModel() {
+class NewTransferViewModel @Inject constructor(
+ private val importationFilesManager: ImportationFilesManager,
+ @IoDispatcher private val ioDispatcher: CoroutineDispatcher,
+ private val savedStateHandle: SavedStateHandle,
+) : ViewModel() {
- private val _files = MutableStateFlow>(emptyList())
- val files: StateFlow> = _files
+ @OptIn(FlowPreview::class)
+ val importedFilesDebounced = importationFilesManager.importedFiles
+ .debounce(50)
+ .stateIn(
+ scope = viewModelScope,
+ started = SharingStarted.Lazily,
+ initialValue = emptyList(),
+ )
- private val _failedFileCount = MutableSharedFlow()
- val failedFileCount: SharedFlow = _failedFileCount
+ val failedFiles = importationFilesManager.failedFiles
+ val filesToImportCount = importationFilesManager.filesToImportCount
+ val currentSessionFilesCount = importationFilesManager.currentSessionFilesCount
- fun addFiles(uris: List) {
- viewModelScope.launch {
- val alreadyUsedFileNames = buildSet { files.value.forEach { add(it.fileName) } }
+ private var isFirstViewModelCreation: Boolean
+ get() = savedStateHandle.get(IS_VIEW_MODEL_RESTORED_KEY) ?: true
+ set(value) {
+ savedStateHandle[IS_VIEW_MODEL_RESTORED_KEY] = value
+ }
+
+ init {
+ viewModelScope.launch(ioDispatcher) {
+ if (isFirstViewModelCreation) {
+ isFirstViewModelCreation = false
+ // Remove old imported files in case it would've crashed or similar to start with a clean slate. This is required
+ // for already imported files restoration to not pick up old files in some extreme cases.
+ importationFilesManager.removeLocalCopyFolder()
+ } else {
+ importationFilesManager.restoreAlreadyImportedFiles()
+ }
- val newFiles = transferFilesManager.getFiles(uris, alreadyUsedFileNames)
+ importationFilesManager.continuouslyCopyPickedFilesToLocalStorage()
+ }
+ }
- _files.value += newFiles
- _failedFileCount.emit(uris.count() - newFiles.count())
+ fun importFiles(uris: List) {
+ viewModelScope.launch(ioDispatcher) {
+ importationFilesManager.importFiles(uris)
}
}
fun removeFileByUid(uid: String) {
- _files.value = _files.value.filterNot { it.uid == uid }
+ viewModelScope.launch(ioDispatcher) {
+ importationFilesManager.removeFileByUid(uid)
+ }
+ }
+
+ companion object {
+ private const val IS_VIEW_MODEL_RESTORED_KEY = "IS_VIEW_MODEL_RESTORED_KEY"
}
}
diff --git a/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/TransferCountChannel.kt b/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/TransferCountChannel.kt
new file mode 100644
index 000000000..6337adaf3
--- /dev/null
+++ b/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/TransferCountChannel.kt
@@ -0,0 +1,59 @@
+/*
+ * 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 kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+
+class TransferCountChannel {
+ private val channel = Channel(capacity = Channel.UNLIMITED)
+
+ private val _count = MutableStateFlow(0)
+ val count: StateFlow = _count.asStateFlow()
+
+ /**
+ * A "session" lasts from when the queue count is greater than 0 until it resets to 0 again.
+ * This count is used to track and compute the progress of files being imported during the current session.
+ */
+ private val _currentSessionFilesCount = MutableStateFlow(0)
+ val currentSessionFilesCount: StateFlow = _currentSessionFilesCount
+
+ private val countMutex = Mutex()
+
+ suspend fun send(element: ImportationFilesManager.PickedFile) {
+ countMutex.withLock {
+ _count.value += 1
+ _currentSessionFilesCount.value += 1
+ }
+ channel.send(element)
+ }
+
+ suspend fun consume(process: suspend (ImportationFilesManager.PickedFile) -> Unit) {
+ for (element in channel) {
+ process(element)
+ countMutex.withLock {
+ _count.value -= 1
+ if (_count.value == 0) _currentSessionFilesCount.value = 0
+ }
+ }
+ }
+}
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
deleted file mode 100644
index 729af2e5b..000000000
--- a/app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/TransferFilesManager.kt
+++ /dev/null
@@ -1,74 +0,0 @@
-/*
- * 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.components.FileUi
-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 getFiles(uris: List, alreadyUsedFileNames: Set): MutableSet {
- val currentUsedFileNames = alreadyUsedFileNames.toMutableSet()
- val files = mutableSetOf()
-
- uris.forEach { uri ->
- getFile(uri, currentUsedFileNames)?.let { file ->
- currentUsedFileNames += file.fileName
- files += file
- }
- }
-
- return files
- }
-
- private fun getFile(uri: Uri, alreadyUsedFileNames: Set): FileUi? {
- 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)
- FileUi(fileName = uniqueName, uid = uniqueName, fileSizeInBytes = size, mimeType = null, uri = uri.toString())
- }
- }
-
- 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 4306bcce2..892dfb410 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
@@ -46,11 +46,15 @@ fun ImportFilesScreen(
newTransferViewModel: NewTransferViewModel = hiltViewModel(),
closeActivity: () -> Unit,
) {
- val files by newTransferViewModel.files.collectAsStateWithLifecycle()
+ val files by newTransferViewModel.importedFilesDebounced.collectAsStateWithLifecycle()
+ val filesToImportCount by newTransferViewModel.filesToImportCount.collectAsStateWithLifecycle()
+ val currentSessionFilesCount by newTransferViewModel.currentSessionFilesCount.collectAsStateWithLifecycle()
ImportFilesScreen(
files = { files },
+ filesToImportCount = { filesToImportCount },
+ currentSessionFilesCount = { currentSessionFilesCount },
removeFileByUid = newTransferViewModel::removeFileByUid,
- addFiles = newTransferViewModel::addFiles,
+ addFiles = newTransferViewModel::importFiles,
closeActivity = closeActivity,
initialShowUploadSourceChoiceBottomSheet = true,
)
@@ -59,6 +63,8 @@ fun ImportFilesScreen(
@Composable
private fun ImportFilesScreen(
files: () -> List,
+ filesToImportCount: () -> Int,
+ currentSessionFilesCount: () -> Int,
removeFileByUid: (uid: String) -> Unit,
addFiles: (List) -> Unit,
closeActivity: () -> Unit,
@@ -74,7 +80,11 @@ private fun ImportFilesScreen(
getHumanReadableSize(context, spaceLeft)
}
- val isSendButtonEnabled by remember { derivedStateOf { importedFiles.isNotEmpty() } }
+ val count = filesToImportCount()
+ val isImporting by remember(count) { derivedStateOf { count > 0 } }
+
+ val total = currentSessionFilesCount()
+ val importProgress = remember(count, total) { 1 - (count.toFloat() / total) }
val filePickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenMultipleDocuments()
@@ -91,13 +101,7 @@ private fun ImportFilesScreen(
)
},
topButton = { modifier ->
- LargeButton(
- modifier = modifier,
- titleRes = R.string.transferSendButton,
- style = ButtonType.PRIMARY,
- enabled = { isSendButtonEnabled },
- onClick = { /*TODO*/ },
- )
+ SendButton({ isImporting }, { importProgress }, modifier)
},
) {
ImportedFilesCard(
@@ -116,6 +120,19 @@ private fun ImportFilesScreen(
}
}
+@Composable
+private fun SendButton(isImporting: () -> Boolean, importProgress: () -> Float, modifier: Modifier) {
+ val progress: (() -> Float)? = if (isImporting()) importProgress else null
+
+ LargeButton(
+ modifier = modifier,
+ titleRes = R.string.transferSendButton,
+ style = ButtonType.PRIMARY,
+ progress = progress,
+ onClick = { /*TODO*/ },
+ )
+}
+
enum class PasswordTransferOption(override val title: @Composable () -> String) : TransferAdvancedOptionsEnum {
NONE({ stringResource(R.string.settingsOptionNone) }),
ACTIVATED({ stringResource(R.string.settingsOptionActivated) }),
@@ -131,6 +148,8 @@ private fun ImportFilesScreenPreview(@PreviewParameter(FileUiListPreviewParamete
SwissTransferTheme {
ImportFilesScreen(
files = { files },
+ filesToImportCount = { 0 },
+ currentSessionFilesCount = { 0 },
removeFileByUid = {},
addFiles = {},
closeActivity = {},
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
index 878b01097..1276e2d07 100644
--- a/app/src/main/java/com/infomaniak/swisstransfer/ui/utils/FileNameUtils.kt
+++ b/app/src/main/java/com/infomaniak/swisstransfer/ui/utils/FileNameUtils.kt
@@ -19,11 +19,11 @@ package com.infomaniak.swisstransfer.ui.utils
object FileNameUtils {
- fun postfixExistingFileNames(fileName: String, existingFileNames: Set): String {
- return if (fileName in existingFileNames) {
+ fun postfixExistingFileNames(fileName: String, isAlreadyUsed: (String) -> Boolean): String {
+ return if (isAlreadyUsed(fileName)) {
val postfixedFileName = PostfixedFileName.fromFileName(fileName)
- while (postfixedFileName.fullName() in existingFileNames) {
+ while (isAlreadyUsed(postfixedFileName.fullName())) {
postfixedFileName.incrementPostfix()
}
diff --git a/app/src/test/java/com/infomaniak/swisstransfer/PostifxedFileNameUnitTest.kt b/app/src/test/java/com/infomaniak/swisstransfer/PostfixedFileNameUnitTest.kt
similarity index 94%
rename from app/src/test/java/com/infomaniak/swisstransfer/PostifxedFileNameUnitTest.kt
rename to app/src/test/java/com/infomaniak/swisstransfer/PostfixedFileNameUnitTest.kt
index 6ad50852d..fdb4c1db0 100644
--- a/app/src/test/java/com/infomaniak/swisstransfer/PostifxedFileNameUnitTest.kt
+++ b/app/src/test/java/com/infomaniak/swisstransfer/PostfixedFileNameUnitTest.kt
@@ -21,29 +21,29 @@ import com.infomaniak.swisstransfer.ui.utils.FileNameUtils.postfixExistingFileNa
import org.junit.Assert.assertEquals
import org.junit.Test
-class PostifxedFileNameUnitTest {
+class PostfixedFileNameUnitTest {
@Test
fun unusedName_isUnchanged() {
val inputFileName = "world.txt"
- val newName = postfixExistingFileNames(inputFileName, alreadyUsedFileNames)
+ val newName = postfixExistingFileNames(inputFileName, alreadyUsedFileNames::contains)
assertEquals(newName, inputFileName)
}
@Test
fun usedName_isPostfixed() {
- val newName = postfixExistingFileNames("hello.txt", alreadyUsedFileNames)
+ val newName = postfixExistingFileNames("hello.txt", alreadyUsedFileNames::contains)
assertEquals(newName, "hello(1).txt")
}
@Test
fun alreadyPostfixedName_isPostfixedAgain() {
- val newName = postfixExistingFileNames("test(1).txt", alreadyUsedFileNames)
+ val newName = postfixExistingFileNames("test(1).txt", alreadyUsedFileNames::contains)
assertEquals(newName, "test(1)(1).txt")
}
@Test
fun postfixedNameThatCollidesWithAnotherName_isPostfixedUntilNoMoreCollision() {
- val newName = postfixExistingFileNames("test.txt", alreadyUsedFileNames)
+ val newName = postfixExistingFileNames("test.txt", alreadyUsedFileNames::contains)
assertEquals(newName, "test(3).txt")
}
@@ -83,12 +83,12 @@ class PostifxedFileNameUnitTest {
}
private fun assertAlreadyExistingFileName(inputFileName: String, expectedFileName: String) {
- val newName = postfixExistingFileNames(inputFileName, setOf(inputFileName))
+ val newName = postfixExistingFileNames(inputFileName, setOf(inputFileName)::contains)
assertEquals(newName, expectedFileName)
}
private fun assertNewFileNameIsUnchanged(inputFileName: String) {
- val newName = postfixExistingFileNames(inputFileName, emptySet())
+ val newName = postfixExistingFileNames(inputFileName, emptySet()::contains)
assertEquals(newName, inputFileName)
}