Skip to content

Commit

Permalink
Merge pull request #93 from Infomaniak/copy-imported-files-locally
Browse files Browse the repository at this point in the history
Copy imported files to app's local file storage in cache
  • Loading branch information
LunarX authored Oct 28, 2024
2 parents a0f19ef + f3cc937 commit 80c5093
Show file tree
Hide file tree
Showing 10 changed files with 513 additions and 111 deletions.
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
}
}
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
}
}
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"
}
}
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"
}
}
Loading

0 comments on commit 80c5093

Please sign in to comment.