Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"formatVersion": 1,
"database": {
"version": 1,
"identityHash": "53552e1c6a78205feeaf944f7da6005a",
"identityHash": "3802f1313df6c8c8e691e75e221bd42d",
"entities": [
{
"tableName": "TransferDB",
Expand Down Expand Up @@ -92,7 +92,7 @@
},
{
"tableName": "FileDB",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `path` TEXT NOT NULL, `size` INTEGER NOT NULL, `mimeType` TEXT, `isFolder` INTEGER NOT NULL, `thumbnailPath` TEXT, `transferId` TEXT NOT NULL, `folderId` TEXT, PRIMARY KEY(`id`), FOREIGN KEY(`transferId`) REFERENCES `TransferDB`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `path` TEXT NOT NULL, `size` INTEGER NOT NULL, `mimeType` TEXT, `isFolder` INTEGER NOT NULL, `thumbnailPath` TEXT, `transferId` TEXT NOT NULL, `parentId` TEXT, PRIMARY KEY(`id`), FOREIGN KEY(`transferId`) REFERENCES `TransferDB`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
Expand Down Expand Up @@ -135,8 +135,8 @@
"notNull": true
},
{
"fieldPath": "folderId",
"columnName": "folderId",
"fieldPath": "parentId",
"columnName": "parentId",
"affinity": "TEXT"
}
],
Expand Down Expand Up @@ -174,7 +174,7 @@
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '53552e1c6a78205feeaf944f7da6005a')"
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '3802f1313df6c8c8e691e75e221bd42d')"
]
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/*
* Infomaniak SwissTransfer - Multiplatform
* Copyright (C) 2026 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.multiplatform_swisstransfer.database

/**
* Example:
*
* For the input `"1/2/3/4/whatever"` and a [delimiter] set to `'/'`, [action] will be called with:
* - `"1"`
* - `"1/2"`
* - `"1/2/3"`
* - `"1/2/3/4"`
* - `"1/2/3/4/whatever"`
*/
internal inline fun String.forEachSubstring(
delimiter: Char,
action: (substring: String) -> Unit
) {
var lastOccurrenceIndex = 0
do {
lastOccurrenceIndex = indexOf(delimiter, startIndex = lastOccurrenceIndex)
val found = lastOccurrenceIndex != -1
action(if (found) substring(0, lastOccurrenceIndex) else this)
lastOccurrenceIndex += 1 // Delimiter length, to skip the previous occurrence.
} while (found)
}
Original file line number Diff line number Diff line change
Expand Up @@ -82,13 +82,13 @@ interface TransferDao {
@Query("SELECT * FROM FileDB WHERE id=:fileId LIMIT 1")
fun getFile(fileId: String): Flow<FileDB?>

@Query("SELECT * FROM FileDB WHERE transferId=:transferId AND folderId IS NULL")
@Query("SELECT * FROM FileDB WHERE transferId=:transferId AND parentId IS NULL")
suspend fun getTransferRootFiles(transferId: String): List<FileDB>

@Query("SELECT * FROM FileDB WHERE transferId=:transferId AND folderId=:folderId")
@Query("SELECT * FROM FileDB WHERE transferId=:transferId AND parentId=:folderId")
fun getTransferFolderFiles(transferId: String, folderId: String?): Flow<List<FileDB>>

@Query("SELECT * FROM FileDB WHERE folderId=:folderId")
@Query("SELECT * FROM FileDB WHERE parentId=:folderId")
fun getFilesByFolderId(folderId: String): Flow<List<FileDB>>

//TODO[API-V2]: suspend fun getNotReadyTransfers(userId: Long): List<TransferDB>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ data class FileDB(
override val thumbnailPath: String? = null,
@ColumnInfo(index = true)
val transferId: String,
val folderId: String? = null,
val parentId: String? = null,
) : File {
@Ignore
val children: List<FileDB> = emptyList()
Expand All @@ -57,6 +57,6 @@ data class FileDB(
mimeType = file.mimeType,
isFolder = file.isFolder,
transferId = transferId,
folderId = folderId
parentId = folderId
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/*
* Infomaniak SwissTransfer - Multiplatform
* 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.multiplatform_swisstransfer.database.utils

import androidx.collection.ArraySet
import com.infomaniak.multiplatform_swisstransfer.common.interfaces.transfers.v2.File
import com.infomaniak.multiplatform_swisstransfer.database.forEachSubstring
import com.infomaniak.multiplatform_swisstransfer.database.models.transfers.v2.FileDB
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid

object FileUtilsForApiV2 {

/**
* Takes a list of [files] that may be in a folder structure, according to their paths,
* and returns a set of [FileDB] objects that includes all these files, plus all the distinct
* folders, as [FileDB] instances with their [FileDB.isFolder] property set to `true`,
* and with their size reflecting the total size of their content.
*/
fun getFileDbTree(transferId: String, files: List<File>): Set<FileDB> {
val folderByPath = HashMap<String, FileDB>(files.size) // The value is mutable, but not the key, so we're safe.
val out = ArraySet<FileDB>(files.size * 2)

for (file in files) {
val parentId = updateFoldersAndGetParentId(file.path, file.size, transferId, folderByPath, out)
out += FileDB(file, transferId, parentId)
}
return out
}

/**
* Gets or puts the parent folders that this [filePath] references in both [folderByPath] & [out],
* updates their size while doing so, then, returns the id of the nearest (deepest/immediate) parent folder.
*/
private fun updateFoldersAndGetParentId(
filePath: String,
fileSize: Long,
transferId: String,
folderByPath: MutableMap<String, FileDB>,
out: MutableSet<FileDB>,
): String? {
val lastSlash = filePath.lastIndexOf('/')
if (lastSlash <= 0) return null // No parent folder (root file)

var parentId: String? = null

filePath.loopOverParentFolders { folderPath ->
val folder: FileDB = folderByPath.getOrPut(folderPath) {
val newId = generateFolderId(transferId)
FileDB(
id = newId,
path = folderPath,
size = 0L,
mimeType = null,
isFolder = true,
transferId = transferId,
parentId = parentId
).also {
folderByPath[folderPath] = it
out += it
}
}

folder.size += fileSize

parentId = folder.id
}

return parentId
}

/**
* Will loop over parent folders based on an input path in the form of a string
*
* `"abc/def/ghi/jkl.txt"` will lead [action] to be called in order with this:
* - `"abc"`
* - `"abc/def"`
* - `"abc/def/ghi"`
*/
private inline fun String.loopOverParentFolders(action: (folderPath: String) -> Unit) {
val directoryPath = substringBeforeLast('/', "").ifEmpty { return }
directoryPath.forEachSubstring(delimiter = '/', action)
}

@OptIn(ExperimentalUuidApi::class)
private fun generateFolderId(transferId: String): String {
return "$transferId:${Uuid.random()}"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
/*
* Infomaniak SwissTransfer - Multiplatform
* 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.multiplatform_swisstransfer.database

import com.infomaniak.multiplatform_swisstransfer.database.models.transfers.v2.FileDB
import com.infomaniak.multiplatform_swisstransfer.database.utils.FileUtilsForApiV2
import kotlin.test.AfterTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import com.infomaniak.multiplatform_swisstransfer.common.interfaces.transfers.v2.File as FileInterfaceV2

class FileUtilsForApiV2Test {

private val treeApiV2 = mutableListOf<FileDB>()

private data class FileV2Mock(
override val id: String,
override val path: String,
override val size: Long,
override val mimeType: String? = null
) : FileInterfaceV2

@AfterTest
fun clean() {
treeApiV2.clear()
}

@Test
fun filesInRoot() {
val filesList = listOf(
FileV2Mock(id = "1", path = "file1.txt", size = 100L),
FileV2Mock(id = "2", path = "file2.txt", size = 200L),
)
treeApiV2.addAll(FileUtilsForApiV2.getFileDbTree("transfer1", filesList))

val rootFiles = treeApiV2.filter { !it.isFolder }
assertEquals(2, rootFiles.size, "Should have 2 root files")
assertNotNull(rootFiles.find { it.path == "file1.txt" }, "file1.txt should exist")
assertNotNull(rootFiles.find { it.path == "file2.txt" }, "file2.txt should exist")
}

@Test
fun filesInFolder() {
val filesList = listOf(
FileV2Mock(id = "1", path = "folder1/file3.txt", size = 100L),
FileV2Mock(id = "2", path = "folder1/file4.txt", size = 200L),
FileV2Mock(id = "3", path = "folder1/randomFolder/empty_file.txt", size = 50L),
FileV2Mock(id = "4", path = "folder2/file5.txt", size = 300L),
FileV2Mock(id = "5", path = "folder2/file6.txt", size = 400L),
)
treeApiV2.addAll(FileUtilsForApiV2.getFileDbTree("transfer2", filesList))

val folder1 = treeApiV2.find { it.path == "folder1" && it.isFolder }
val folder2 = treeApiV2.find { it.path == "folder2" && it.isFolder }
val randomFolder = treeApiV2.find { it.path == "folder1/randomFolder" && it.isFolder }

assertNotNull(folder1, "folder1 should exist")
assertNotNull(folder2, "folder2 should exist")
assertNotNull(randomFolder, "folder1/randomFolder should exist")

assertEquals(350L, folder1.size, "folder1 size should be 350 (100 + 200 + 50)")
assertEquals(700L, folder2.size, "folder2 size should be 700 (300 + 400)")
assertEquals(50L, randomFolder.size, "randomFolder size should be 50")

val folder1Children = treeApiV2.filter { it.parentId == folder1.id && !it.isFolder }
assertEquals(2, folder1Children.size, "folder1 should have 2 direct file children")
}

@Test
fun fileContainedIn5NestedFolders() {
val path = "folder1/folder2/folder3/folder4/folder5"
val filesList = listOf(
FileV2Mock(id = "1", path = "$path/nested_file.txt", size = 1000L)
)
treeApiV2.addAll(FileUtilsForApiV2.getFileDbTree("transfer3", filesList))

checkFolders("folder1/folder2/folder3/folder4/folder5", size = 1000L)

val file = treeApiV2.find { it.path == "$path/nested_file.txt" }
assertNotNull(file, "nested_file.txt should exist")
}

@Test
fun folderSizeAccumulatedCorrectly() {
val filesList = listOf(
FileV2Mock(id = "1", path = "docs/report.pdf", size = 500L),
FileV2Mock(id = "2", path = "docs/notes.txt", size = 300L),
FileV2Mock(id = "3", path = "docs/subfolder/image.png", size = 200L),
)
treeApiV2.addAll(FileUtilsForApiV2.getFileDbTree("transfer4", filesList))

val docs = treeApiV2.find { it.path == "docs" && it.isFolder }
val subfolder = treeApiV2.find { it.path == "docs/subfolder" && it.isFolder }

assertEquals(1000L, docs?.size, "docs size should be 1000 (500 + 300 + 200)")
assertEquals(200L, subfolder?.size, "subfolder size should be 200")
}

@Test
fun folderHierarchyIsCorrect() {
val filesList = listOf(
FileV2Mock(id = "1", path = "a/b/c/file.txt", size = 100L),
)
treeApiV2.addAll(FileUtilsForApiV2.getFileDbTree("transfer5", filesList))

val folderA = treeApiV2.find { it.path == "a" && it.isFolder }
val folderB = treeApiV2.find { it.path == "a/b" && it.isFolder }
val folderC = treeApiV2.find { it.path == "a/b/c" && it.isFolder }

assertNotNull(folderA, "folder 'a' should exist")
assertNotNull(folderB, "folder 'a/b' should exist")
assertNotNull(folderC, "folder 'a/b/c' should exist")

assertEquals(null, folderA.parentId, "folder 'a' should have no parent")
assertEquals(folderA.id, folderB.parentId, "folder 'a/b' should have 'a' as parent")
assertEquals(folderB.id, folderC.parentId, "folder 'a/b/c' should have 'a/b' as parent")
}

private fun checkFolders(path: String, size: Long) {
val parts = path.split("/")
for (i in parts.indices) {
val partialPath = parts.take(i + 1).joinToString("/")
val folder = treeApiV2.find { it.path == partialPath && it.isFolder }
assertNotNull(folder, "$partialPath should exist as folder")
assertEquals(size, folder.size, "$partialPath should have correct accumulated size")
}
}
}
Loading