-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #93 from Infomaniak/copy-imported-files-locally
Copy imported files to app's local file storage in cache
- Loading branch information
Showing
10 changed files
with
513 additions
and
111 deletions.
There are no files selected for viewing
46 changes: 46 additions & 0 deletions
46
...c/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/AlreadyUsedFileNamesSet.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 <http://www.gnu.org/licenses/>. | ||
*/ | ||
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<String>() | ||
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<String>): 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 | ||
} | ||
} |
48 changes: 48 additions & 0 deletions
48
...src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/FilesMutableStateFlow.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 <http://www.gnu.org/licenses/>. | ||
*/ | ||
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<List<FileUi>>(emptyList()) | ||
private val mutex = Mutex() | ||
|
||
val flow = files.asStateFlow() | ||
|
||
suspend fun addAll(newFiles: List<FileUi>): 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 | ||
} | ||
} |
72 changes: 72 additions & 0 deletions
72
app/src/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/ImportLocalStorage.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 <http://www.gnu.org/licenses/>. | ||
*/ | ||
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<File>? = 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" | ||
} | ||
} |
197 changes: 197 additions & 0 deletions
197
...c/main/java/com/infomaniak/swisstransfer/ui/screen/newtransfer/ImportationFilesManager.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 <http://www.gnu.org/licenses/>. | ||
*/ | ||
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<Int> = filesToImportChannel.count | ||
val currentSessionFilesCount: StateFlow<Int> = filesToImportChannel.currentSessionFilesCount | ||
|
||
private val _importedFiles = FilesMutableStateFlow() | ||
val importedFiles = _importedFiles.flow | ||
|
||
private val _failedFiles = MutableSharedFlow<PickedFile>() | ||
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<Uri>) { | ||
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<File>): List<FileUi> { | ||
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<Uri>.extractPickedFiles(): Set<PickedFile> { | ||
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<String, Long>? = 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" | ||
} | ||
} |
Oops, something went wrong.