Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Copy imported files to app's local file storage in cache #93

Merged
merged 35 commits into from
Oct 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
b0424d7
Local file importation
LunarX Oct 15, 2024
d007d5b
Delete local file copy of removed files
LunarX Oct 15, 2024
833fedc
Restore already imported files after view model restoration
LunarX Oct 15, 2024
5a6fc48
Use a `send` method to send picked files to the channel
LunarX Oct 15, 2024
9900c11
Disable button "next" as the files are getting copied locally
LunarX Oct 16, 2024
7e0c56c
Debounce addition of picked files in the UI to avoid spamming the use…
LunarX Oct 16, 2024
3fb3254
Compute importation progress
LunarX Oct 17, 2024
2fe3eb6
Remove unnecessary derivedStateOf
LunarX Oct 18, 2024
216d813
Replace TODO with Sentry.captureMessage()
LunarX Oct 18, 2024
775a0c8
Fix already used name not being updated quickly enough
LunarX Oct 18, 2024
308cc4e
Extract file importation logic from view model to manger
LunarX Oct 18, 2024
d15ad90
Rename variables and classes
LunarX Oct 21, 2024
acef63c
Simplify and remove bug potentiel from alreadyUsedFiles record of names
LunarX Oct 21, 2024
9e89924
Rename getFiles into extractPickedFiles
LunarX Oct 23, 2024
4b08d81
Rename local copy folder into import folder
LunarX Oct 23, 2024
757a484
Extract local storage logic to its own class
LunarX Oct 23, 2024
2226044
Add stream errors breadbrumbs
LunarX Oct 23, 2024
1d6c4ee
Refactor input and output stream when copying files locally
LunarX Oct 23, 2024
6628e57
Clean code smells
LunarX Oct 23, 2024
4022fe4
Extract classes to their own files
LunarX Oct 23, 2024
758295c
Add a `addUniqueFileName` method to AlreadyUsedFileNamesSet to hide m…
LunarX Oct 24, 2024
3b7b7c4
Rename `addFiles` into `importFiles`
LunarX Oct 24, 2024
bfc478c
Refactor TransferCountChannel
LunarX Oct 24, 2024
261c5ca
Apply suggestions from code review
LunarX Oct 24, 2024
932c697
Refactor ImportLocalStorage
LunarX Oct 24, 2024
5cf6815
Extract component to optimize recomposition
LunarX Oct 24, 2024
25f7e59
Replace logs with sentry logs
LunarX Oct 24, 2024
df2553c
Use injected ioDispatcher
LunarX Oct 24, 2024
a6da559
Apply suggestions from code review
LunarX Oct 24, 2024
c15ce39
Fix broken tests
LunarX Oct 25, 2024
66c73fd
Extract openInputStream from ImportLocalStorage to ImportationFilesMa…
LunarX Oct 25, 2024
a0a99ee
Optimize jetpack compose by using lambdas
LunarX Oct 25, 2024
fba40cc
Rename type of the name "postifxed"
LunarX Oct 28, 2024
275efa8
Remove useless new line
LunarX Oct 28, 2024
f3cc937
Better wording for restoration failure sentry
LunarX Oct 28, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 {
[email protected] { 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
Loading