From 2b07b8417c509b1df0d1b4b9902af82418847977 Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Thu, 20 Jun 2024 16:26:45 -0300 Subject: [PATCH 1/9] Add FileSelectionManager to storage lib This new manager will be responsible for handling file selection by user prior to restore. --- .../ui/restore/FileSelectionManager.kt | 218 ++++++++++++++++++ 1 file changed, 218 insertions(+) create mode 100644 storage/lib/src/main/java/org/calyxos/backup/storage/ui/restore/FileSelectionManager.kt diff --git a/storage/lib/src/main/java/org/calyxos/backup/storage/ui/restore/FileSelectionManager.kt b/storage/lib/src/main/java/org/calyxos/backup/storage/ui/restore/FileSelectionManager.kt new file mode 100644 index 00000000..2428ba8c --- /dev/null +++ b/storage/lib/src/main/java/org/calyxos/backup/storage/ui/restore/FileSelectionManager.kt @@ -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() + private val allFiles = HashMap>() + private var expandedFolder: String? = null + + private val mFiles = MutableStateFlow>(emptyList()) + public val files: StateFlow> = 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() + 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, + ): Map> { + val levels = mutableMapOf>() + 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 { + val list = mutableListOf() + 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, + ) + } + +} From 5012099419eabf732e624aad5fb37ec2a4357694 Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Thu, 20 Jun 2024 16:30:06 -0300 Subject: [PATCH 2/9] Add UI prototype for selecting file to storage demo app --- .../seedvault/restore/RestoreViewModel.kt | 3 + .../storagebackuptester/MainViewModel.kt | 7 + .../restore/DemoFileSelectionFragment.kt | 36 +++++ .../restore/DemoSnapshotFragment.kt | 4 +- .../restore/RestoreFragment.kt | 8 +- .../ui/restore/FileSelectionFragment.kt | 58 ++++++++ .../backup/storage/ui/restore/FilesAdapter.kt | 126 ++++++++++++++++++ .../storage/ui/restore/SnapshotFragment.kt | 6 +- .../src/main/res/drawable/ic_audio_file.xml | 15 +++ .../main/res/drawable/ic_chevron_right.xml | 15 +++ .../lib/src/main/res/drawable/ic_image.xml | 14 ++ .../drawable/ic_indeterminate_check_box.xml | 27 ++++ .../res/drawable/ic_insert_drive_file.xml | 15 +++ .../res/drawable/ic_keyboard_arrow_down.xml | 15 +++ .../src/main/res/drawable/ic_video_file.xml | 14 ++ .../main/res/layout/fragment_select_files.xml | 58 ++++++++ storage/lib/src/main/res/layout/item_file.xml | 54 ++++++++ storage/lib/src/main/res/values/strings.xml | 4 + 18 files changed, 471 insertions(+), 8 deletions(-) create mode 100644 storage/demo/src/main/java/de/grobox/storagebackuptester/restore/DemoFileSelectionFragment.kt create mode 100644 storage/lib/src/main/java/org/calyxos/backup/storage/ui/restore/FileSelectionFragment.kt create mode 100644 storage/lib/src/main/java/org/calyxos/backup/storage/ui/restore/FilesAdapter.kt create mode 100644 storage/lib/src/main/res/drawable/ic_audio_file.xml create mode 100644 storage/lib/src/main/res/drawable/ic_chevron_right.xml create mode 100644 storage/lib/src/main/res/drawable/ic_image.xml create mode 100644 storage/lib/src/main/res/drawable/ic_indeterminate_check_box.xml create mode 100644 storage/lib/src/main/res/drawable/ic_insert_drive_file.xml create mode 100644 storage/lib/src/main/res/drawable/ic_keyboard_arrow_down.xml create mode 100644 storage/lib/src/main/res/drawable/ic_video_file.xml create mode 100644 storage/lib/src/main/res/layout/fragment_select_files.xml create mode 100644 storage/lib/src/main/res/layout/item_file.xml diff --git a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreViewModel.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreViewModel.kt index 0912fbe2..6a838828 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreViewModel.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreViewModel.kt @@ -46,6 +46,7 @@ import org.calyxos.backup.storage.api.SnapshotItem import org.calyxos.backup.storage.api.StorageBackup import org.calyxos.backup.storage.restore.RestoreService.Companion.EXTRA_TIMESTAMP_START import org.calyxos.backup.storage.restore.RestoreService.Companion.EXTRA_USER_ID +import org.calyxos.backup.storage.ui.restore.FileSelectionManager import org.calyxos.backup.storage.ui.restore.SnapshotViewModel import java.util.LinkedList @@ -98,6 +99,8 @@ internal class RestoreViewModel( get() = appDataRestoreManager.restoreBackupResult override val snapshots = storageBackup.getBackupSnapshots().asLiveData(ioDispatcher) + override val fileSelectionManager: FileSelectionManager + get() = TODO("Not yet implemented") internal fun loadRestoreSets() = viewModelScope.launch(ioDispatcher) { val backups = restoreCoordinator.getAvailableMetadata()?.mapNotNull { (token, metadata) -> diff --git a/storage/demo/src/main/java/de/grobox/storagebackuptester/MainViewModel.kt b/storage/demo/src/main/java/de/grobox/storagebackuptester/MainViewModel.kt index 18159b27..a0dd1728 100644 --- a/storage/demo/src/main/java/de/grobox/storagebackuptester/MainViewModel.kt +++ b/storage/demo/src/main/java/de/grobox/storagebackuptester/MainViewModel.kt @@ -28,6 +28,7 @@ import org.calyxos.backup.storage.backup.BackupJobService import org.calyxos.backup.storage.scanner.DocumentScanner import org.calyxos.backup.storage.scanner.MediaScanner import org.calyxos.backup.storage.ui.backup.BackupContentViewModel +import org.calyxos.backup.storage.ui.restore.FileSelectionManager import org.calyxos.backup.storage.ui.restore.SnapshotViewModel private val logEmptyState = """ @@ -47,6 +48,7 @@ class MainViewModel(application: Application) : BackupContentViewModel(applicati private val app: App = application as App private val settingsManager = app.settingsManager override val storageBackup: StorageBackup = app.storageBackup + override val fileSelectionManager = FileSelectionManager() private val _backupLog = MutableLiveData(BackupProgress(0, 0, logEmptyState)) val backupLog: LiveData = _backupLog @@ -124,6 +126,11 @@ class MainViewModel(application: Application) : BackupContentViewModel(applicati } fun onSnapshotClicked(item: SnapshotItem) { + val snapshot = item.snapshot ?: error("${item.storedSnapshot} had null snapshot") + fileSelectionManager.onSnapshotChosen(snapshot) + } + + fun onFilesSelected(item: SnapshotItem) { val snapshot = item.snapshot check(snapshot != null) diff --git a/storage/demo/src/main/java/de/grobox/storagebackuptester/restore/DemoFileSelectionFragment.kt b/storage/demo/src/main/java/de/grobox/storagebackuptester/restore/DemoFileSelectionFragment.kt new file mode 100644 index 00000000..35afa54c --- /dev/null +++ b/storage/demo/src/main/java/de/grobox/storagebackuptester/restore/DemoFileSelectionFragment.kt @@ -0,0 +1,36 @@ +/* + * SPDX-FileCopyrightText: 2021 The Calyx Institute + * SPDX-License-Identifier: Apache-2.0 + */ + +package de.grobox.storagebackuptester.restore + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.activityViewModels +import de.grobox.storagebackuptester.MainViewModel +import org.calyxos.backup.storage.ui.restore.FileSelectionFragment + +class DemoFileSelectionFragment : FileSelectionFragment() { + + override val viewModel: MainViewModel by activityViewModels() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + val v = super.onCreateView(inflater, container, savedInstanceState) + // val topStub: ViewStub = v.findViewById(R.id.topStub) + // topStub.layoutResource = R.layout.footer_snapshot + // val header = topStub.inflate() + // header.findViewById