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 f19ab8657..d6855f284 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 @@ -17,45 +17,27 @@ */ package com.infomaniak.swisstransfer.ui.screen.newtransfer -import android.content.Context import android.net.Uri -import android.util.Log -import androidx.core.net.toUri import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.infomaniak.swisstransfer.ui.components.FileUi -import com.infomaniak.swisstransfer.ui.screen.newtransfer.TransferFilesManager.PickedFile import dagger.hilt.android.lifecycle.HiltViewModel -import dagger.hilt.android.qualifiers.ApplicationContext -import io.sentry.Sentry -import io.sentry.SentryLevel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.* +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import java.io.File import javax.inject.Inject @HiltViewModel class NewTransferViewModel @Inject constructor( - @ApplicationContext private val appContext: Context, private val transferFilesManager: TransferFilesManager, private val savedStateHandle: SavedStateHandle, ) : ViewModel() { - // Importing a file locally can take up time. We can't base the list of already used names on _files because a new import with - // the same name could occur while the file is not finished importing yet. - // This list needs to mark a name a "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() - private val _files = MutableStateFlow>(emptyList()) - private val files: StateFlow> = _files @OptIn(FlowPreview::class) - val filesDebounced = files + val filesDebounced = transferFilesManager.importedFiles .debounce(50) .stateIn( scope = viewModelScope, @@ -63,17 +45,9 @@ class NewTransferViewModel @Inject constructor( initialValue = emptyList(), ) - private val _failedFiles = MutableSharedFlow() - val failedFiles: SharedFlow = _failedFiles - - private val filesToImport: TransferCountChannel = TransferCountChannel() - val filesToImportCount: StateFlow = filesToImport.count - val currentSessionTotalUploadedFiles: StateFlow = filesToImport.currentSessionTotalUploadedFiles - - private val filesMutationMutex = Mutex() - - private val localCopyFolderFile by lazy { File(appContext.cacheDir, LOCAL_COPY_FOLDER) } - private val localCopyFolder get() = localCopyFolderFile.apply { if (!exists()) mkdirs() } + val failedFiles = transferFilesManager.failedFiles + val filesToImportCount = transferFilesManager.filesToImportCount + val currentSessionTotalUploadedFiles = transferFilesManager.currentSessionTotalUploadedFiles private var isFirstViewModelCreation: Boolean get() = savedStateHandle.get(IS_VIEW_MODEL_RESTORED_KEY) ?: true @@ -87,160 +61,27 @@ class NewTransferViewModel @Inject constructor( 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. - removeLocalCopyFolder() + transferFilesManager.removeLocalCopyFolder() } else { - restoreAlreadyImportedFiles() + transferFilesManager.restoreAlreadyImportedFiles() } - observeFilesToImport() + transferFilesManager.copyPickedFilesToLocalStorage() } } fun addFiles(uris: List) { viewModelScope.launch(Dispatchers.IO) { - val newFiles = transferFilesManager.getFiles(uris, isAlreadyUsed = { alreadyUsedFileNames.contains(it) }) - - alreadyUsedFileNames.addAll(newFiles.map { it.fileName }) - - newFiles.forEach { filesToImport.send(it) } + transferFilesManager.addFiles(uris) } } fun removeFileByUid(uid: String) { viewModelScope.launch(Dispatchers.IO) { - var fileName: String? = null - - filesMutationMutex.withLock { - val files = _files.value.toMutableList() - - val index = files.indexOfFirst { it.uid == uid }.takeIf { it != -1 } ?: return@withLock - val fileToRemove = files.removeAt(index) - fileName = fileToRemove.fileName - - runCatching { File(fileToRemove.uri).delete() } - - _files.value = files - } - - fileName?.let { - alreadyUsedFileNames.remove(it) - } + transferFilesManager.removeFileByUid(uid) } } - private suspend fun restoreAlreadyImportedFiles() { - if (!localCopyFolderFile.exists()) return - - val alreadyCopiedFiles = localCopyFolderFile.listFiles() ?: return - val restoredFileData = transferFilesManager.getRestoredFileData(alreadyCopiedFiles) - - if (alreadyCopiedFiles.size != restoredFileData.size) { - Sentry.withScope { scope -> - scope.level = SentryLevel.ERROR - Sentry.captureMessage("Failure of the restoration of the already imported files after a process kill") - } - } - - alreadyUsedFileNames.addAll(restoredFileData.map { it.fileName }) - - filesMutationMutex.withLock { _files.value += restoredFileData } - } - - private fun removeLocalCopyFolder() { - if (localCopyFolderFile.exists()) runCatching { localCopyFolderFile.deleteRecursively() } - } - - private suspend fun observeFilesToImport() { - filesToImport.consume { fileToImport -> - Log.i(TAG, "Importing ${fileToImport.uri}") - val copiedFile = copyFileLocally(fileToImport.uri, fileToImport.fileName) - - if (copiedFile == null) { - reportFailedImportation(fileToImport) - return@consume - } - - Log.i(TAG, "Successfully imported ${fileToImport.uri}") - - filesMutationMutex.withLock { - _files.value += FileUi( - uid = fileToImport.fileName, - fileName = fileToImport.fileName, - fileSizeInBytes = fileToImport.fileSizeInBytes, - mimeType = null, - uri = copiedFile.toUri().toString(), - ) - } - } - } - - private fun copyFileLocally(uri: Uri, fileName: String): File? { - val file = File(localCopyFolder, fileName).apply { - if (exists()) delete() - runCatching { createNewFile() }.onFailure { return null } - - runCatching { - val inputStream = appContext.contentResolver.openInputStream(uri) ?: return null - - inputStream.use { inputStream -> - outputStream().use { outputStream -> - inputStream.copyTo(outputStream) - } - } - }.onFailure { - return null - } - } - - return file - } - - private suspend fun reportFailedImportation(file: PickedFile) { - Log.w(TAG, "Failed importation of ${file.uri}"); - _failedFiles.emit(file) - } - - private class TransferCountChannel { - private val channel = Channel(capacity = Channel.UNLIMITED) - - private val _count = MutableStateFlow(0) - val count: StateFlow = _count - - // Session resets when reaching 0 files in the queue - private val _currentSessionTotalUploadedFiles = MutableStateFlow(0) - val currentSessionTotalUploadedFiles: StateFlow = _currentSessionTotalUploadedFiles - - private val countMutex = Mutex() - - suspend fun send(element: PickedFile) { - countMutex.withLock { - _count.value += 1 - _currentSessionTotalUploadedFiles.value += 1 - } - channel.send(element) - } - - suspend fun consume(process: suspend (PickedFile) -> Unit) { - for (element in channel) { - process(element) - countMutex.withLock { - val newValue = _count.value - 1 - _count.value = newValue - if (newValue == 0) _currentSessionTotalUploadedFiles.value = 0 - } - } - } - } - - class AlreadyUsedFileNamesSet { - private val alreadyUsedFileNames = mutableSetOf() - private val mutex = Mutex() - - suspend fun contains(fileName: String): Boolean = mutex.withLock { alreadyUsedFileNames.contains(fileName) } - suspend fun addAll(filesNames: List): Boolean = mutex.withLock { alreadyUsedFileNames.addAll(filesNames) } - suspend fun remove(filesName: String): Boolean = mutex.withLock { alreadyUsedFileNames.remove(filesName) } - } - companion object { private const val TAG = "File importation" const val LOCAL_COPY_FOLDER = "local_copy_folder" 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 index e2a1325ed..6eeee90f2 100644 --- 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 @@ -22,33 +22,104 @@ import android.content.Context import android.database.Cursor import android.net.Uri import android.provider.OpenableColumns +import android.util.Log import androidx.core.net.toUri import com.infomaniak.swisstransfer.ui.components.FileUi +import com.infomaniak.swisstransfer.ui.screen.newtransfer.NewTransferViewModel.Companion.LOCAL_COPY_FOLDER import com.infomaniak.swisstransfer.ui.utils.FileNameUtils import dagger.hilt.android.qualifiers.ApplicationContext import io.sentry.Sentry +import io.sentry.SentryLevel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import java.io.File import javax.inject.Inject import javax.inject.Singleton @Singleton class TransferFilesManager @Inject constructor(@ApplicationContext private val appContext: Context) { - suspend fun getFiles(uris: List, isAlreadyUsed: suspend (String) -> Boolean): Set { - val currentUsedFileNames = mutableSetOf() - val files = buildSet { - uris.forEach { uri -> - getFile(uri, isAlreadyUsed = { isAlreadyUsed(it) || currentUsedFileNames.contains(it) })?.let { file -> - currentUsedFileNames += file.fileName - add(file) - } + private val filesToImport: TransferCountChannel = TransferCountChannel() + val filesToImportCount: StateFlow = filesToImport.count + val currentSessionTotalUploadedFiles: StateFlow = filesToImport.currentSessionTotalUploadedFiles + + 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 _files because a new import with + // the same name could occur while the file is not finished importing yet. + // 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() + + private val localCopyFolder by lazy { File(appContext.cacheDir, LOCAL_COPY_FOLDER) } + private fun getGetLocalCopyFolderOrCopy() = localCopyFolder.apply { if (!exists()) mkdirs() } + + suspend fun addFiles(uris: List) { + val newFiles = getFiles(uris, isAlreadyUsed = { alreadyUsedFileNames.contains(it) }) + + alreadyUsedFileNames.addAll(newFiles.map { it.fileName }) + + newFiles.forEach { filesToImport.send(it) } + } + + suspend fun removeFileByUid(uid: String) { + _importedFiles.removeByUid(uid)?.also { removedFileName -> + alreadyUsedFileNames.remove(removedFileName) + } + } + + fun removeLocalCopyFolder() { + if (localCopyFolder.exists()) runCatching { localCopyFolder.deleteRecursively() } + } + + suspend fun restoreAlreadyImportedFiles() { + if (!localCopyFolder.exists()) return + + val alreadyCopiedFiles = localCopyFolder.listFiles() ?: return + val restoredFileData = getRestoredFileData(alreadyCopiedFiles) + + if (alreadyCopiedFiles.size != restoredFileData.size) { + Sentry.withScope { scope -> + scope.level = SentryLevel.ERROR + Sentry.captureMessage("Failure of the restoration of the already imported files after a process kill") } } - return files + alreadyUsedFileNames.addAll(restoredFileData.map { it.fileName }) + _importedFiles.addAll(restoredFileData) + } + + suspend fun copyPickedFilesToLocalStorage() { + filesToImport.consume { fileToImport -> + Log.i(TAG, "Importing ${fileToImport.uri}") + val copiedFile = copyFileLocally(fileToImport.uri, fileToImport.fileName) + + if (copiedFile == null) { + reportFailedImportation(fileToImport) + return@consume + } + + Log.i(TAG, "Successfully imported ${fileToImport.uri}") + + _importedFiles.add( + FileUi( + uid = fileToImport.fileName, + fileName = fileToImport.fileName, + fileSizeInBytes = fileToImport.fileSizeInBytes, + mimeType = null, + uri = copiedFile.toUri().toString(), + ) + ) + } } - fun getRestoredFileData(files: Array): List { + private fun getRestoredFileData(files: Array): List { return files.mapNotNull { file -> val fileSizeInBytes = runCatching { file.length() } .onFailure { Sentry.addBreadcrumb("Caught an exception while restoring imported files: $it") } @@ -64,6 +135,21 @@ class TransferFilesManager @Inject constructor(@ApplicationContext private val a } } + private suspend fun getFiles(uris: List, isAlreadyUsed: suspend (String) -> Boolean): Set { + val currentUsedFileNames = mutableSetOf() + + val files = buildSet { + uris.forEach { uri -> + getFile(uri, isAlreadyUsed = { isAlreadyUsed(it) || currentUsedFileNames.contains(it) })?.let { file -> + currentUsedFileNames += file.fileName + add(file) + } + } + } + + return files + } + private suspend fun getFile(uri: Uri, isAlreadyUsed: suspend (String) -> Boolean): PickedFile? { val contentResolver: ContentResolver = appContext.contentResolver val cursor: Cursor? = contentResolver.query(uri, null, null, null, null) @@ -92,5 +178,99 @@ class TransferFilesManager @Inject constructor(@ApplicationContext private val a return getColumnIndex(column).takeIf { it != -1 } } + private fun copyFileLocally(uri: Uri, fileName: String): File? { + val file = File(getGetLocalCopyFolderOrCopy(), fileName).apply { + if (exists()) delete() + runCatching { createNewFile() }.onFailure { return null } + + runCatching { + val inputStream = appContext.contentResolver.openInputStream(uri) ?: return null + + inputStream.use { inputStream -> + outputStream().use { outputStream -> + inputStream.copyTo(outputStream) + } + } + }.onFailure { + return null + } + } + + return file + } + + private suspend fun reportFailedImportation(file: PickedFile) { + Log.w(TAG, "Failed importation of ${file.uri}"); + _failedFiles.emit(file) + } + + private class TransferCountChannel { + private val channel = Channel(capacity = Channel.UNLIMITED) + + private val _count = MutableStateFlow(0) + val count: StateFlow = _count + + // Session resets when reaching 0 files in the queue + private val _currentSessionTotalUploadedFiles = MutableStateFlow(0) + val currentSessionTotalUploadedFiles: StateFlow = _currentSessionTotalUploadedFiles + + private val countMutex = Mutex() + + suspend fun send(element: PickedFile) { + countMutex.withLock { + _count.value += 1 + _currentSessionTotalUploadedFiles.value += 1 + } + channel.send(element) + } + + suspend fun consume(process: suspend (PickedFile) -> Unit) { + for (element in channel) { + process(element) + countMutex.withLock { + val newValue = _count.value - 1 + _count.value = newValue + if (newValue == 0) _currentSessionTotalUploadedFiles.value = 0 + } + } + } + } + + class AlreadyUsedFileNamesSet { + private val alreadyUsedFileNames = mutableSetOf() + private val mutex = Mutex() + + suspend fun contains(fileName: String): Boolean = mutex.withLock { alreadyUsedFileNames.contains(fileName) } + suspend fun addAll(filesNames: List): Boolean = mutex.withLock { alreadyUsedFileNames.addAll(filesNames) } + suspend fun remove(filesName: String): Boolean = mutex.withLock { alreadyUsedFileNames.remove(filesName) } + } + + 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 + } + } + data class PickedFile(val fileName: String, val fileSizeInBytes: Long, val uri: Uri) + + companion object { + private const val TAG = "File importation" + } }