Add FileSelectionManager to storage lib
This new manager will be responsible for handling file selection by user prior to restore.
This commit is contained in:
parent
adbc412d20
commit
2b07b8417c
1 changed files with 218 additions and 0 deletions
|
@ -0,0 +1,218 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2024 The Calyx Institute
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.calyxos.backup.storage.ui.restore
|
||||||
|
|
||||||
|
import androidx.annotation.UiThread
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import org.calyxos.backup.storage.backup.BackupSnapshot
|
||||||
|
import org.calyxos.backup.storage.restore.RestorableFile
|
||||||
|
|
||||||
|
public sealed interface FilesItem {
|
||||||
|
public val name: String
|
||||||
|
public val dir: String
|
||||||
|
public val level: Int
|
||||||
|
public val selected: Boolean
|
||||||
|
public val size: Long
|
||||||
|
public val lastModified: Long?
|
||||||
|
}
|
||||||
|
|
||||||
|
public data class FileItem internal constructor(
|
||||||
|
internal val file: RestorableFile,
|
||||||
|
override val level: Int,
|
||||||
|
override val selected: Boolean,
|
||||||
|
) : FilesItem {
|
||||||
|
override val name: String get() = file.name
|
||||||
|
override val dir: String get() = file.dir
|
||||||
|
override val size: Long get() = file.size
|
||||||
|
override val lastModified: Long? get() = file.lastModified
|
||||||
|
}
|
||||||
|
|
||||||
|
public data class FolderItem(
|
||||||
|
override val dir: String,
|
||||||
|
override val name: String,
|
||||||
|
override val level: Int,
|
||||||
|
val numFiles: Int,
|
||||||
|
override val size: Long,
|
||||||
|
override val lastModified: Long?,
|
||||||
|
override val selected: Boolean,
|
||||||
|
val partiallySelected: Boolean,
|
||||||
|
val expanded: Boolean,
|
||||||
|
) : FilesItem {
|
||||||
|
init {
|
||||||
|
check(selected || !partiallySelected) {
|
||||||
|
"$dir was not selected, but partially selected"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class FileSelectionManager {
|
||||||
|
|
||||||
|
private val allFolders = HashMap<String, FolderItem>()
|
||||||
|
private val allFiles = HashMap<String, MutableList<FileItem>>()
|
||||||
|
private var expandedFolder: String? = null
|
||||||
|
|
||||||
|
private val mFiles = MutableStateFlow<List<FilesItem>>(emptyList())
|
||||||
|
public val files: StateFlow<List<FilesItem>> = mFiles.asStateFlow()
|
||||||
|
|
||||||
|
@UiThread
|
||||||
|
public fun onSnapshotChosen(snapshot: BackupSnapshot) {
|
||||||
|
snapshot.mediaFilesList.forEach { mediaFile ->
|
||||||
|
cacheFileItem(RestorableFile(mediaFile))
|
||||||
|
}
|
||||||
|
snapshot.documentFilesList.forEach { documentFile ->
|
||||||
|
cacheFileItem(RestorableFile(documentFile))
|
||||||
|
}
|
||||||
|
// figure out indentation level and display names for folders
|
||||||
|
val sortedFolders = allFiles.keys.sorted()
|
||||||
|
val levels = calculateFolderIndentationLevels(sortedFolders)
|
||||||
|
val list = mutableListOf<FilesItem>()
|
||||||
|
sortedFolders.forEach { folder ->
|
||||||
|
// get size and lastModified from files in that folder
|
||||||
|
val fileItems = allFiles[folder] ?: error("$folder not in allFiles")
|
||||||
|
val size = fileItems.sumOf { it.file.size }
|
||||||
|
val lastModified = fileItems.maxOf { it.file.lastModified ?: -1 }
|
||||||
|
|
||||||
|
val level = levels[folder] ?: error("No level for $folder")
|
||||||
|
val folderItem = FolderItem(
|
||||||
|
dir = folder,
|
||||||
|
name = level.second,
|
||||||
|
level = level.first,
|
||||||
|
numFiles = fileItems.size,
|
||||||
|
size = size,
|
||||||
|
lastModified = if (lastModified == -1L) null else lastModified,
|
||||||
|
selected = true,
|
||||||
|
partiallySelected = false,
|
||||||
|
expanded = false,
|
||||||
|
)
|
||||||
|
allFolders[folder] = folderItem
|
||||||
|
list.add(folderItem)
|
||||||
|
allFiles[folder] = fileItems.sortedBy { it.name }.map {
|
||||||
|
it.copy(level = level.first + 1)
|
||||||
|
}.toMutableList()
|
||||||
|
}
|
||||||
|
mFiles.value = list
|
||||||
|
}
|
||||||
|
|
||||||
|
@UiThread
|
||||||
|
internal fun onExpandClicked(clickedFolderItem: FolderItem) {
|
||||||
|
// un-expand previously expanded folder, if any
|
||||||
|
expandedFolder?.let { folder ->
|
||||||
|
allFolders[folder] = allFolders[folder]?.copy(expanded = false)
|
||||||
|
?: error("Expanded folder $folder not in allFolders")
|
||||||
|
}
|
||||||
|
|
||||||
|
// update clickedFolderItem's expanded state in cache
|
||||||
|
val newFolderItem = clickedFolderItem.copy(expanded = !clickedFolderItem.expanded)
|
||||||
|
allFolders[clickedFolderItem.dir] = newFolderItem
|
||||||
|
if (newFolderItem.expanded) expandedFolder = clickedFolderItem.dir
|
||||||
|
|
||||||
|
// re-build file tree for UI
|
||||||
|
mFiles.value = rebuildListFromCache()
|
||||||
|
}
|
||||||
|
|
||||||
|
@UiThread
|
||||||
|
internal fun onCheckedChanged(toggledFilesItem: FilesItem) {
|
||||||
|
if (toggledFilesItem is FileItem) {
|
||||||
|
onFileItemCheckedChanged(toggledFilesItem)
|
||||||
|
} else if (toggledFilesItem is FolderItem) {
|
||||||
|
onFolderItemCheckedChanged(toggledFilesItem)
|
||||||
|
}
|
||||||
|
// re-build list from cache, so selection state gets updated there
|
||||||
|
mFiles.value = rebuildListFromCache()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cacheFileItem(restorableFile: RestorableFile) {
|
||||||
|
val fileItem = FileItem(restorableFile, 0, true)
|
||||||
|
allFiles.getOrPut(restorableFile.dir) {
|
||||||
|
mutableListOf()
|
||||||
|
}.add(fileItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun calculateFolderIndentationLevels(
|
||||||
|
sortedFolders: List<String>,
|
||||||
|
): Map<String, Pair<Int, String>> {
|
||||||
|
val levels = mutableMapOf<String, Pair<Int, String>>()
|
||||||
|
sortedFolders.forEach { folder ->
|
||||||
|
val parts = folder.split('/')
|
||||||
|
for (i in parts.size - 1 downTo 0) {
|
||||||
|
val subPath = parts.subList(0, i).joinToString("/")
|
||||||
|
if (subPath.isBlank()) continue
|
||||||
|
val subPathLevel = levels[subPath]
|
||||||
|
if (subPathLevel != null) {
|
||||||
|
val name = if (i >= parts.size - 1) {
|
||||||
|
parts[i]
|
||||||
|
} else {
|
||||||
|
parts.subList(i, parts.size).joinToString { "/" }
|
||||||
|
}
|
||||||
|
levels[folder] = Pair(subPathLevel.first + 1, name)
|
||||||
|
return@forEach
|
||||||
|
}
|
||||||
|
}
|
||||||
|
levels[folder] = Pair(0, folder.ifEmpty { "/" })
|
||||||
|
}
|
||||||
|
return levels
|
||||||
|
}
|
||||||
|
|
||||||
|
@UiThread
|
||||||
|
private fun rebuildListFromCache(): MutableList<FilesItem> {
|
||||||
|
val list = mutableListOf<FilesItem>()
|
||||||
|
allFolders.keys.sorted().forEach { folder ->
|
||||||
|
val folderItem = allFolders[folder] ?: error("No item for $folder")
|
||||||
|
list.add(folderItem)
|
||||||
|
val fileItems = allFiles[folder] ?: error("$folder not in allFiles")
|
||||||
|
if (folderItem.expanded) {
|
||||||
|
list.addAll(fileItems)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
|
@UiThread
|
||||||
|
private fun onFileItemCheckedChanged(fileItem: FileItem) {
|
||||||
|
// get all file items from this dir and update only the changed one
|
||||||
|
val fileItems = allFiles[fileItem.dir]
|
||||||
|
?: error("no files for ${fileItem.dir}")
|
||||||
|
fileItems.replaceAll {
|
||||||
|
if (it.file == fileItem.file) it.copy(selected = !it.selected)
|
||||||
|
else it
|
||||||
|
}
|
||||||
|
// figure out how to update parent folder
|
||||||
|
var allSelected = true
|
||||||
|
var noneSelected = true
|
||||||
|
fileItems.forEach { item ->
|
||||||
|
if (item.selected) noneSelected = false
|
||||||
|
else allSelected = false
|
||||||
|
}
|
||||||
|
// update parent folder
|
||||||
|
val folderItem = allFolders[fileItem.dir]
|
||||||
|
?: error("no folder for ${fileItem.dir}")
|
||||||
|
allFolders[fileItem.dir] = folderItem.copy(
|
||||||
|
selected = allSelected || !noneSelected,
|
||||||
|
partiallySelected = !allSelected && !noneSelected,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@UiThread
|
||||||
|
private fun onFolderItemCheckedChanged(folderItem: FolderItem) {
|
||||||
|
val newSelected = if (!folderItem.selected) {
|
||||||
|
true // was not selected, so now it should be
|
||||||
|
} else if (folderItem.partiallySelected) {
|
||||||
|
true // was only partially selected, so now select all
|
||||||
|
} else {
|
||||||
|
false // was fully selected, so now deselect
|
||||||
|
}
|
||||||
|
allFiles[folderItem.dir]?.replaceAll {
|
||||||
|
it.copy(selected = newSelected)
|
||||||
|
}
|
||||||
|
allFolders[folderItem.dir] = folderItem.copy(
|
||||||
|
selected = newSelected,
|
||||||
|
partiallySelected = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
Loading…
Reference in a new issue