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