Merge pull request #705 from grote/565-choose-files-restore

Allow choosing what files/folders will get restored
This commit is contained in:
Torsten Grote 2024-08-15 09:42:54 -03:00 committed by GitHub
commit b571da787a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
41 changed files with 991 additions and 53 deletions

View file

@ -57,6 +57,7 @@ class KoinInstrumentationTestApp : App() {
iconManager = get(), iconManager = get(),
storageBackup = get(), storageBackup = get(),
pluginManager = get(), pluginManager = get(),
fileSelectionManager = get(),
) )
) )
currentRestoreViewModel!! currentRestoreViewModel!!

View file

@ -28,8 +28,8 @@ import com.stevesoltys.seedvault.metadata.metadataModule
import com.stevesoltys.seedvault.plugins.StoragePluginManager import com.stevesoltys.seedvault.plugins.StoragePluginManager
import com.stevesoltys.seedvault.plugins.saf.storagePluginModuleSaf import com.stevesoltys.seedvault.plugins.saf.storagePluginModuleSaf
import com.stevesoltys.seedvault.plugins.webdav.storagePluginModuleWebDav import com.stevesoltys.seedvault.plugins.webdav.storagePluginModuleWebDav
import com.stevesoltys.seedvault.restore.RestoreViewModel
import com.stevesoltys.seedvault.restore.install.installModule import com.stevesoltys.seedvault.restore.install.installModule
import com.stevesoltys.seedvault.restore.restoreUiModule
import com.stevesoltys.seedvault.settings.AppListRetriever import com.stevesoltys.seedvault.settings.AppListRetriever
import com.stevesoltys.seedvault.settings.SettingsManager import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.settings.SettingsViewModel import com.stevesoltys.seedvault.settings.SettingsViewModel
@ -37,7 +37,6 @@ import com.stevesoltys.seedvault.storage.storageModule
import com.stevesoltys.seedvault.transport.TRANSPORT_ID import com.stevesoltys.seedvault.transport.TRANSPORT_ID
import com.stevesoltys.seedvault.transport.backup.backupModule import com.stevesoltys.seedvault.transport.backup.backupModule
import com.stevesoltys.seedvault.transport.restore.restoreModule import com.stevesoltys.seedvault.transport.restore.restoreModule
import com.stevesoltys.seedvault.ui.files.FileSelectionViewModel
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
import com.stevesoltys.seedvault.ui.recoverycode.RecoveryCodeViewModel import com.stevesoltys.seedvault.ui.recoverycode.RecoveryCodeViewModel
import com.stevesoltys.seedvault.ui.storage.BackupStorageViewModel import com.stevesoltys.seedvault.ui.storage.BackupStorageViewModel
@ -97,20 +96,6 @@ open class App : Application() {
) )
} }
viewModel { RestoreStorageViewModel(this@App, get(), get(), get(), get()) } viewModel { RestoreStorageViewModel(this@App, get(), get(), get(), get()) }
viewModel {
RestoreViewModel(
app = this@App,
settingsManager = get(),
keyManager = get(),
backupManager = get(),
restoreCoordinator = get(),
apkRestore = get(),
iconManager = get(),
storageBackup = get(),
pluginManager = get(),
)
}
viewModel { FileSelectionViewModel(this@App, get()) }
} }
override fun onCreate() { override fun onCreate() {
@ -155,6 +140,7 @@ open class App : Application() {
installModule, installModule,
storageModule, storageModule,
workerModule, workerModule,
restoreUiModule,
appModule appModule
) )

View file

@ -0,0 +1,45 @@
/*
* SPDX-FileCopyrightText: 2024 The Calyx Institute
* SPDX-License-Identifier: Apache-2.0
*/
package com.stevesoltys.seedvault.restore
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.ViewStub
import android.widget.Button
import com.stevesoltys.seedvault.R
import org.calyxos.backup.storage.ui.restore.FileSelectionFragment
import org.calyxos.backup.storage.ui.restore.FilesItem
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
internal class FilesSelectionFragment : FileSelectionFragment() {
override val viewModel: RestoreViewModel by sharedViewModel()
private lateinit var button: Button
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
): View {
val v = super.onCreateView(inflater, container, savedInstanceState)
val topStub: ViewStub = v.requireViewById(R.id.topStub)
topStub.layoutResource = R.layout.header_files_selection
topStub.inflate()
val bottomStub: ViewStub = v.requireViewById(R.id.bottomStub)
bottomStub.layoutResource = R.layout.footer_files_selection
button = bottomStub.inflate() as Button
button.setOnClickListener {
viewModel.startFilesRestore()
}
return v
}
override fun onFileItemsChanged(filesItems: List<FilesItem>) {
slideUpInRootView(button)
}
}

View file

@ -12,6 +12,7 @@ import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_APPS
import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_BACKUP import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_BACKUP
import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_FILES import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_FILES
import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_FILES_STARTED import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_FILES_STARTED
import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_SELECT_FILES
import com.stevesoltys.seedvault.restore.DisplayFragment.SELECT_APPS import com.stevesoltys.seedvault.restore.DisplayFragment.SELECT_APPS
import com.stevesoltys.seedvault.restore.install.InstallProgressFragment import com.stevesoltys.seedvault.restore.install.InstallProgressFragment
import com.stevesoltys.seedvault.ui.RequireProvisioningActivity import com.stevesoltys.seedvault.ui.RequireProvisioningActivity
@ -35,7 +36,12 @@ class RestoreActivity : RequireProvisioningActivity() {
RESTORE_APPS -> showFragment(InstallProgressFragment()) RESTORE_APPS -> showFragment(InstallProgressFragment())
RESTORE_BACKUP -> showFragment(RestoreProgressFragment()) RESTORE_BACKUP -> showFragment(RestoreProgressFragment())
RESTORE_FILES -> showFragment(RestoreFilesFragment()) RESTORE_FILES -> showFragment(RestoreFilesFragment())
RESTORE_FILES_STARTED -> showFragment(RestoreFilesStartedFragment()) RESTORE_SELECT_FILES -> showFragment(FilesSelectionFragment(), true)
RESTORE_FILES_STARTED -> {
// pop back stack, so back navigation doesn't bring us to RESTORE_SELECT_FILES
supportFragmentManager.popBackStackImmediate()
showFragment(RestoreFilesStartedFragment())
}
else -> throw AssertionError() else -> throw AssertionError()
} }
} }

View file

@ -47,7 +47,7 @@ internal class RestoreFilesFragment : SnapshotFragment() {
} }
override fun onSnapshotClicked(item: SnapshotItem) { override fun onSnapshotClicked(item: SnapshotItem) {
viewModel.startFilesRestore(item) viewModel.selectFilesForRestore(item)
} }
} }

View file

@ -0,0 +1,31 @@
/*
* SPDX-FileCopyrightText: 2020 The Calyx Institute
* SPDX-License-Identifier: Apache-2.0
*/
package com.stevesoltys.seedvault.restore
import com.stevesoltys.seedvault.ui.files.FileSelectionViewModel
import org.calyxos.backup.storage.ui.restore.FileSelectionManager
import org.koin.android.ext.koin.androidApplication
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.dsl.module
val restoreUiModule = module {
single { FileSelectionManager() }
viewModel {
RestoreViewModel(
app = androidApplication(),
settingsManager = get(),
keyManager = get(),
backupManager = get(),
restoreCoordinator = get(),
apkRestore = get(),
iconManager = get(),
storageBackup = get(),
pluginManager = get(),
fileSelectionManager = get(),
)
}
viewModel { FileSelectionViewModel(androidApplication(), get()) }
}

View file

@ -23,6 +23,7 @@ import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_APPS
import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_BACKUP import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_BACKUP
import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_FILES import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_FILES
import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_FILES_STARTED import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_FILES_STARTED
import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_SELECT_FILES
import com.stevesoltys.seedvault.restore.DisplayFragment.SELECT_APPS import com.stevesoltys.seedvault.restore.DisplayFragment.SELECT_APPS
import com.stevesoltys.seedvault.restore.install.ApkRestore import com.stevesoltys.seedvault.restore.install.ApkRestore
import com.stevesoltys.seedvault.restore.install.InstallIntentCreator import com.stevesoltys.seedvault.restore.install.InstallIntentCreator
@ -44,8 +45,10 @@ import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.calyxos.backup.storage.api.SnapshotItem import org.calyxos.backup.storage.api.SnapshotItem
import org.calyxos.backup.storage.api.StorageBackup import org.calyxos.backup.storage.api.StorageBackup
import org.calyxos.backup.storage.api.StoredSnapshot
import org.calyxos.backup.storage.restore.RestoreService.Companion.EXTRA_TIMESTAMP_START 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.restore.RestoreService.Companion.EXTRA_USER_ID
import org.calyxos.backup.storage.ui.restore.FileSelectionManager
import org.calyxos.backup.storage.ui.restore.SnapshotViewModel import org.calyxos.backup.storage.ui.restore.SnapshotViewModel
import java.util.LinkedList import java.util.LinkedList
@ -63,6 +66,7 @@ internal class RestoreViewModel(
private val iconManager: IconManager, private val iconManager: IconManager,
storageBackup: StorageBackup, storageBackup: StorageBackup,
pluginManager: StoragePluginManager, pluginManager: StoragePluginManager,
override val fileSelectionManager: FileSelectionManager,
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
) : RequireProvisioningViewModel(app, settingsManager, keyManager, pluginManager), ) : RequireProvisioningViewModel(app, settingsManager, keyManager, pluginManager),
RestorableBackupClickListener, SnapshotViewModel { RestorableBackupClickListener, SnapshotViewModel {
@ -98,6 +102,7 @@ internal class RestoreViewModel(
get() = appDataRestoreManager.restoreBackupResult get() = appDataRestoreManager.restoreBackupResult
override val snapshots = storageBackup.getBackupSnapshots().asLiveData(ioDispatcher) override val snapshots = storageBackup.getBackupSnapshots().asLiveData(ioDispatcher)
private var storedSnapshot: StoredSnapshot? = null
internal fun loadRestoreSets() = viewModelScope.launch(ioDispatcher) { internal fun loadRestoreSets() = viewModelScope.launch(ioDispatcher) {
val backups = restoreCoordinator.getAvailableMetadata()?.mapNotNull { (token, metadata) -> val backups = restoreCoordinator.getAvailableMetadata()?.mapNotNull { (token, metadata) ->
@ -179,12 +184,22 @@ internal class RestoreViewModel(
} }
@UiThread @UiThread
internal fun startFilesRestore(item: SnapshotItem) { internal fun selectFilesForRestore(item: SnapshotItem) {
val snapshot = item.snapshot ?: error("${item.storedSnapshot} had null snapshot")
fileSelectionManager.onSnapshotChosen(snapshot)
storedSnapshot = item.storedSnapshot
mDisplayFragment.setEvent(RESTORE_SELECT_FILES)
}
@UiThread
internal fun startFilesRestore() {
val storedSnapshot = this.storedSnapshot ?: error("No snapshot stored")
val i = Intent(app, StorageRestoreService::class.java) val i = Intent(app, StorageRestoreService::class.java)
i.putExtra(EXTRA_USER_ID, item.storedSnapshot.userId) i.putExtra(EXTRA_USER_ID, storedSnapshot.userId)
i.putExtra(EXTRA_TIMESTAMP_START, item.time) i.putExtra(EXTRA_TIMESTAMP_START, storedSnapshot.timestamp)
app.startForegroundService(i) app.startForegroundService(i)
mDisplayFragment.setEvent(RESTORE_FILES_STARTED) mDisplayFragment.setEvent(RESTORE_FILES_STARTED)
this.storedSnapshot = null
} }
} }
@ -206,5 +221,10 @@ internal class RestoreBackupResult(val errorMsg: String? = null) {
} }
internal enum class DisplayFragment { internal enum class DisplayFragment {
SELECT_APPS, RESTORE_APPS, RESTORE_BACKUP, RESTORE_FILES, RESTORE_FILES_STARTED SELECT_APPS,
RESTORE_APPS,
RESTORE_BACKUP,
RESTORE_FILES,
RESTORE_SELECT_FILES,
RESTORE_FILES_STARTED,
} }

View file

@ -224,12 +224,13 @@ internal class SettingsViewModel(
internal fun backupNow() { internal fun backupNow() {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
val isAppBackupEnabled = backupManager.isBackupEnabled
if (settingsManager.isStorageBackupEnabled()) { if (settingsManager.isStorageBackupEnabled()) {
val i = Intent(app, StorageBackupService::class.java) val i = Intent(app, StorageBackupService::class.java)
// this starts an app backup afterwards // this starts an app backup afterwards (if enabled)
i.putExtra(EXTRA_START_APP_BACKUP, true) i.putExtra(EXTRA_START_APP_BACKUP, isAppBackupEnabled)
startForegroundService(app, i) startForegroundService(app, i)
} else { } else if (isAppBackupEnabled) {
AppBackupWorker.scheduleNow(app, reschedule = !pluginManager.isOnRemovableDrive) AppBackupWorker.scheduleNow(app, reschedule = !pluginManager.isOnRemovableDrive)
} }
} }

View file

@ -18,6 +18,7 @@ import org.calyxos.backup.storage.backup.BackupService
import org.calyxos.backup.storage.backup.NotificationBackupObserver import org.calyxos.backup.storage.backup.NotificationBackupObserver
import org.calyxos.backup.storage.restore.NotificationRestoreObserver import org.calyxos.backup.storage.restore.NotificationRestoreObserver
import org.calyxos.backup.storage.restore.RestoreService import org.calyxos.backup.storage.restore.RestoreService
import org.calyxos.backup.storage.ui.restore.FileSelectionManager
import org.koin.android.ext.android.inject import org.koin.android.ext.android.inject
/* /*
@ -70,6 +71,7 @@ internal class StorageBackupService : BackupService() {
internal class StorageRestoreService : RestoreService() { internal class StorageRestoreService : RestoreService() {
override val storageBackup: StorageBackup by inject() override val storageBackup: StorageBackup by inject()
override val fileSelectionManager: FileSelectionManager by inject()
// use lazy delegate because context isn't available during construction time // use lazy delegate because context isn't available during construction time
override val restoreObserver: RestoreObserver by lazy { override val restoreObserver: RestoreObserver by lazy {

View file

@ -24,8 +24,7 @@ abstract class BackupActivity : AppCompatActivity() {
protected fun showFragment(f: Fragment, addToBackStack: Boolean = false, tag: String? = null) { protected fun showFragment(f: Fragment, addToBackStack: Boolean = false, tag: String? = null) {
supportFragmentManager.beginTransaction().apply { supportFragmentManager.beginTransaction().apply {
if (tag == null) replace(R.id.fragment, f) replace(R.id.fragment, f, tag)
else replace(R.id.fragment, f, tag)
if (addToBackStack) addToBackStack(null) if (addToBackStack) addToBackStack(null)
commit() commit()
} }

View file

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?><!--
SPDX-FileCopyrightText: 2024 The Calyx Institute
SPDX-License-Identifier: Apache-2.0
-->
<com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/button"
style="@style/SudPrimaryButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_marginEnd="16dp"
android:drawableTint="?android:textColorPrimaryInverse"
app:icon="@drawable/ic_cloud_restore"
android:text="@string/select_files_button_restore"
app:layout_behavior="com.google.android.material.behavior.HideBottomViewOnScrollBehavior" />

View file

@ -108,10 +108,6 @@
android:layout_marginTop="0dp" android:layout_marginTop="0dp"
android:layout_marginEnd="0dp" android:layout_marginEnd="0dp"
app:layout_behavior="@string/appbar_scrolling_view_behavior" app:layout_behavior="@string/appbar_scrolling_view_behavior"
app:layout_constraintBottom_toTopOf="@+id/button"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/backupNameView"
tools:listitem="@layout/list_item_app_status" /> tools:listitem="@layout/list_item_app_status" />
<Button <Button
@ -122,9 +118,6 @@
android:layout_gravity="bottom|end" android:layout_gravity="bottom|end"
android:layout_marginEnd="16dp" android:layout_marginEnd="16dp"
android:text="@string/restore_backup_button" android:text="@string/restore_backup_button"
app:layout_behavior="com.google.android.material.behavior.HideBottomViewOnScrollBehavior" app:layout_behavior="com.google.android.material.behavior.HideBottomViewOnScrollBehavior" />
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@+id/appList" />
</androidx.coordinatorlayout.widget.CoordinatorLayout> </androidx.coordinatorlayout.widget.CoordinatorLayout>

View file

@ -0,0 +1,45 @@
<?xml version="1.0" encoding="utf-8"?><!--
SPDX-FileCopyrightText: 2020 The Calyx Institute
SPDX-License-Identifier: Apache-2.0
-->
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/background"
android:paddingBottom="16dp">
<ImageView
android:id="@+id/imageView"
style="@style/SudHeaderIcon"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/ic_cloud_download"
app:tint="?android:colorAccent"
tools:ignore="ContentDescription" />
<TextView
android:id="@+id/titleView"
style="@style/SudHeaderTitle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/select_files_title"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/imageView" />
<TextView
android:id="@+id/backupNameView"
style="@style/SudDescription"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/restore_storage_selection_description"
android:textColor="?android:textColorSecondary"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/titleView" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -234,6 +234,7 @@
<string name="restore_storage_skip">Skip restoring files</string> <string name="restore_storage_skip">Skip restoring files</string>
<string name="restore_storage_choose_snapshot">Choose a storage backup to restore (beta)</string> <string name="restore_storage_choose_snapshot">Choose a storage backup to restore (beta)</string>
<string name="restore_storage_selection_description">Selected files will be restored. Tap folders to see files in them.</string>
<string name="restore_storage_in_progress_title">Files are being restored…</string> <string name="restore_storage_in_progress_title">Files are being restored…</string>
<string name="restore_storage_in_progress_info">Your files are being restored in the background. You can start using your phone while this is running.\n\nSome apps (e.g. Signal or WhatsApp) might require files to be fully restored to import a backup. Try to avoid starting those apps before file restore is complete.</string> <string name="restore_storage_in_progress_info">Your files are being restored in the background. You can start using your phone while this is running.\n\nSome apps (e.g. Signal or WhatsApp) might require files to be fully restored to import a backup. Try to avoid starting those apps before file restore is complete.</string>
<string name="restore_storage_got_it">Got it</string> <string name="restore_storage_got_it">Got it</string>

View file

@ -12,6 +12,7 @@ import android.util.Log
import de.grobox.storagebackuptester.plugin.TestSafStoragePlugin import de.grobox.storagebackuptester.plugin.TestSafStoragePlugin
import de.grobox.storagebackuptester.settings.SettingsManager import de.grobox.storagebackuptester.settings.SettingsManager
import org.calyxos.backup.storage.api.StorageBackup import org.calyxos.backup.storage.api.StorageBackup
import org.calyxos.backup.storage.ui.restore.FileSelectionManager
class App : Application() { class App : Application() {
@ -20,6 +21,7 @@ class App : Application() {
val plugin = TestSafStoragePlugin(this) { settingsManager.getBackupLocation() } val plugin = TestSafStoragePlugin(this) { settingsManager.getBackupLocation() }
StorageBackup(this, { plugin }) StorageBackup(this, { plugin })
} }
val fileSelectionManager: FileSelectionManager get() = FileSelectionManager()
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()

View file

@ -14,6 +14,7 @@ import org.calyxos.backup.storage.backup.BackupService
import org.calyxos.backup.storage.backup.NotificationBackupObserver import org.calyxos.backup.storage.backup.NotificationBackupObserver
import org.calyxos.backup.storage.restore.NotificationRestoreObserver import org.calyxos.backup.storage.restore.NotificationRestoreObserver
import org.calyxos.backup.storage.restore.RestoreService import org.calyxos.backup.storage.restore.RestoreService
import org.calyxos.backup.storage.ui.restore.FileSelectionManager
import java.util.concurrent.TimeUnit.HOURS import java.util.concurrent.TimeUnit.HOURS
// debug with: // debug with:
@ -45,6 +46,8 @@ class DemoBackupService : BackupService() {
class DemoRestoreService : RestoreService() { class DemoRestoreService : RestoreService() {
// use lazy delegate because context isn't available during construction time // use lazy delegate because context isn't available during construction time
override val storageBackup: StorageBackup by lazy { (application as App).storageBackup } override val storageBackup: StorageBackup by lazy { (application as App).storageBackup }
override val fileSelectionManager: FileSelectionManager
get() = (application as App).fileSelectionManager
override val restoreObserver: RestoreObserver by lazy { override val restoreObserver: RestoreObserver by lazy {
NotificationRestoreObserver(applicationContext) NotificationRestoreObserver(applicationContext)
} }

View file

@ -24,6 +24,7 @@ import kotlinx.coroutines.withContext
import org.calyxos.backup.storage.api.SnapshotItem import org.calyxos.backup.storage.api.SnapshotItem
import org.calyxos.backup.storage.api.SnapshotResult import org.calyxos.backup.storage.api.SnapshotResult
import org.calyxos.backup.storage.api.StorageBackup import org.calyxos.backup.storage.api.StorageBackup
import org.calyxos.backup.storage.api.StoredSnapshot
import org.calyxos.backup.storage.backup.BackupJobService import org.calyxos.backup.storage.backup.BackupJobService
import org.calyxos.backup.storage.scanner.DocumentScanner import org.calyxos.backup.storage.scanner.DocumentScanner
import org.calyxos.backup.storage.scanner.MediaScanner import org.calyxos.backup.storage.scanner.MediaScanner
@ -47,6 +48,7 @@ class MainViewModel(application: Application) : BackupContentViewModel(applicati
private val app: App = application as App private val app: App = application as App
private val settingsManager = app.settingsManager private val settingsManager = app.settingsManager
override val storageBackup: StorageBackup = app.storageBackup override val storageBackup: StorageBackup = app.storageBackup
override val fileSelectionManager = app.fileSelectionManager
private val _backupLog = MutableLiveData(BackupProgress(0, 0, logEmptyState)) private val _backupLog = MutableLiveData(BackupProgress(0, 0, logEmptyState))
val backupLog: LiveData<BackupProgress> = _backupLog val backupLog: LiveData<BackupProgress> = _backupLog
@ -62,6 +64,7 @@ class MainViewModel(application: Application) : BackupContentViewModel(applicati
override val snapshots: LiveData<SnapshotResult> override val snapshots: LiveData<SnapshotResult>
get() = storageBackup.getBackupSnapshots().asLiveData(Dispatchers.IO) get() = storageBackup.getBackupSnapshots().asLiveData(Dispatchers.IO)
private var storedSnapshot: StoredSnapshot? = null
init { init {
viewModelScope.launch { loadContent() } viewModelScope.launch { loadContent() }
@ -124,8 +127,14 @@ class MainViewModel(application: Application) : BackupContentViewModel(applicati
} }
fun onSnapshotClicked(item: SnapshotItem) { fun onSnapshotClicked(item: SnapshotItem) {
val snapshot = item.snapshot val snapshot = item.snapshot ?: error("${item.storedSnapshot} had null snapshot")
check(snapshot != null) fileSelectionManager.onSnapshotChosen(snapshot)
storedSnapshot = item.storedSnapshot
}
fun onFilesSelected() {
val storedSnapshot = this.storedSnapshot ?: error("No snapshot stored")
val snapshot = fileSelectionManager.getBackupSnapshotAndReset()
// example for how to do restore via foreground service // example for how to do restore via foreground service
// app.startForegroundService(Intent(app, DemoRestoreService::class.java).apply { // app.startForegroundService(Intent(app, DemoRestoreService::class.java).apply {
@ -137,8 +146,9 @@ class MainViewModel(application: Application) : BackupContentViewModel(applicati
_restoreProgressVisible.value = true _restoreProgressVisible.value = true
val restoreObserver = RestoreStats(app, _restoreLog) val restoreObserver = RestoreStats(app, _restoreLog)
viewModelScope.launch { viewModelScope.launch {
storageBackup.restoreBackupSnapshot(item.storedSnapshot, snapshot, restoreObserver) storageBackup.restoreBackupSnapshot(storedSnapshot, snapshot, restoreObserver)
_restoreProgressVisible.value = false _restoreProgressVisible.value = false
this@MainViewModel.storedSnapshot = null
} }
} }

View file

@ -0,0 +1,52 @@
/*
* 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 android.view.ViewStub
import androidx.fragment.app.activityViewModels
import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
import de.grobox.storagebackuptester.MainViewModel
import de.grobox.storagebackuptester.R
import org.calyxos.backup.storage.ui.restore.FileSelectionFragment
import org.calyxos.backup.storage.ui.restore.FilesItem
class DemoFileSelectionFragment : FileSelectionFragment() {
override val viewModel: MainViewModel by activityViewModels()
private var fab: ExtendedFloatingActionButton? = null
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.header_file_select
topStub.inflate()
val bottomStub: ViewStub = v.findViewById(R.id.bottomStub)
bottomStub.layoutResource = R.layout.footer_files
val footer = bottomStub.inflate() as ExtendedFloatingActionButton
fab = footer
footer.setOnClickListener {
viewModel.onFilesSelected()
parentFragmentManager.beginTransaction()
.replace(R.id.container, RestoreFragment.newInstance())
.commit()
}
return v
}
override fun onFileItemsChanged(filesItems: List<FilesItem>) {
super.onFileItemsChanged(filesItems)
slideUpInRootView(fab!!)
}
}

View file

@ -39,8 +39,8 @@ class DemoSnapshotFragment : SnapshotFragment() {
override fun onSnapshotClicked(item: SnapshotItem) { override fun onSnapshotClicked(item: SnapshotItem) {
viewModel.onSnapshotClicked(item) viewModel.onSnapshotClicked(item)
parentFragmentManager.beginTransaction() parentFragmentManager.beginTransaction()
.replace(R.id.container, RestoreFragment.newInstance()) .replace(R.id.container, DemoFileSelectionFragment())
.addToBackStack("RESTORE") .addToBackStack("SELECT")
.commit() .commit()
} }

View file

@ -52,17 +52,17 @@ class RestoreFragment : Fragment() {
} }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
viewModel.restoreLog.observe(viewLifecycleOwner, { progress -> viewModel.restoreLog.observe(viewLifecycleOwner) { progress ->
progress.text?.let { adapter.addItem(it) } progress.text?.let { adapter.addItem(it) }
horizontalProgressBar.max = progress.total horizontalProgressBar.max = progress.total
horizontalProgressBar.setProgress(progress.current, true) horizontalProgressBar.setProgress(progress.current, true)
list.postDelayed({ list.postDelayed({
list.scrollToPosition(adapter.itemCount - 1) list.scrollToPosition(adapter.itemCount - 1)
}, 50) }, 50)
}) }
viewModel.restoreProgressVisible.observe(viewLifecycleOwner, { visible -> viewModel.restoreProgressVisible.observe(viewLifecycleOwner) { visible ->
progressBar.visibility = if (visible) VISIBLE else INVISIBLE progressBar.visibility = if (visible) VISIBLE else INVISIBLE
}) }
} }
override fun onStart() { override fun onStart() {

View file

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?><!--
SPDX-FileCopyrightText: 2024 The Calyx Institute
SPDX-License-Identifier: Apache-2.0
-->
<com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/fab"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_margin="16dp"
android:text="@string/select_files_button_restore"
android:textColor="#ffffff"
app:backgroundTint="?colorAccent"
app:icon="@drawable/ic_cloud_restore"
app:iconTint="#ffffff"
app:layout_behavior="com.google.android.material.behavior.HideBottomViewOnScrollBehavior" />

View file

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
SPDX-FileCopyrightText: 2021 The Calyx Institute
SPDX-License-Identifier: Apache-2.0
-->
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp">
<TextView
android:id="@+id/button"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="De-select folders you don't want to restore. Tap folders to see their contents."
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -200,7 +200,7 @@ public class StorageBackup(
public suspend fun restoreBackupSnapshot( public suspend fun restoreBackupSnapshot(
storedSnapshot: StoredSnapshot, storedSnapshot: StoredSnapshot,
snapshot: BackupSnapshot? = null, snapshot: BackupSnapshot,
restoreObserver: RestoreObserver? = null, restoreObserver: RestoreObserver? = null,
): Boolean = withContext(dispatcher) { ): Boolean = withContext(dispatcher) {
if (restoreRunning.getAndSet(true)) { if (restoreRunning.getAndSet(true)) {

View file

@ -33,6 +33,8 @@ internal class BackupResult(
backupMediaFiles = backupMediaFiles + other.backupMediaFiles, backupMediaFiles = backupMediaFiles + other.backupMediaFiles,
backupDocumentFiles = backupDocumentFiles + other.backupDocumentFiles, backupDocumentFiles = backupDocumentFiles + other.backupDocumentFiles,
) )
val isEmpty: Boolean = backupMediaFiles.isEmpty() && backupDocumentFiles.isEmpty()
} }
internal class Backup( internal class Backup(
@ -134,6 +136,7 @@ internal class Backup(
fileBackup.backupFiles(filesResult.files, availableChunkIds, backupObserver) fileBackup.backupFiles(filesResult.files, availableChunkIds, backupObserver)
} }
val result = largeResult + smallResult val result = largeResult + smallResult
if (result.isEmpty) return // TODO maybe warn user that nothing could get backed up?
val backupSize = result.backupMediaFiles.sumOf { it.size } + val backupSize = result.backupMediaFiles.sumOf { it.size } +
result.backupDocumentFiles.sumOf { it.size } result.backupDocumentFiles.sumOf { it.size }
val endTime = System.currentTimeMillis() val endTime = System.currentTimeMillis()

View file

@ -22,7 +22,6 @@ import org.calyxos.backup.storage.plugin.SnapshotRetriever
import java.io.IOException import java.io.IOException
import java.io.InputStream import java.io.InputStream
import java.security.GeneralSecurityException import java.security.GeneralSecurityException
import kotlin.time.ExperimentalTime
private const val TAG = "Restore" private const val TAG = "Restore"
@ -99,15 +98,12 @@ internal class Restore(
Log.e(TAG, "Decrypting and parsing $numSnapshots snapshots took $time") Log.e(TAG, "Decrypting and parsing $numSnapshots snapshots took $time")
} }
@OptIn(ExperimentalTime::class)
@Throws(IOException::class, GeneralSecurityException::class) @Throws(IOException::class, GeneralSecurityException::class)
suspend fun restoreBackupSnapshot( suspend fun restoreBackupSnapshot(
storedSnapshot: StoredSnapshot, storedSnapshot: StoredSnapshot,
optionalSnapshot: BackupSnapshot? = null, snapshot: BackupSnapshot,
observer: RestoreObserver? = null, observer: RestoreObserver? = null,
) { ) {
val snapshot = optionalSnapshot ?: snapshotRetriever.getSnapshot(streamKey, storedSnapshot)
val filesTotal = snapshot.mediaFilesList.size + snapshot.documentFilesList.size val filesTotal = snapshot.mediaFilesList.size + snapshot.documentFilesList.size
val totalSize = val totalSize =
snapshot.mediaFilesList.sumOf { it.size } + snapshot.documentFilesList.sumOf { it.size } snapshot.mediaFilesList.sumOf { it.size } + snapshot.documentFilesList.sumOf { it.size }

View file

@ -9,8 +9,10 @@ import android.app.Service
import android.content.Intent import android.content.Intent
import android.os.IBinder import android.os.IBinder
import android.util.Log import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.calyxos.backup.storage.api.RestoreObserver import org.calyxos.backup.storage.api.RestoreObserver
import org.calyxos.backup.storage.api.StorageBackup import org.calyxos.backup.storage.api.StorageBackup
import org.calyxos.backup.storage.api.StoredSnapshot import org.calyxos.backup.storage.api.StoredSnapshot
@ -18,6 +20,7 @@ import org.calyxos.backup.storage.restore.RestoreService.Companion.EXTRA_TIMESTA
import org.calyxos.backup.storage.restore.RestoreService.Companion.EXTRA_USER_ID import org.calyxos.backup.storage.restore.RestoreService.Companion.EXTRA_USER_ID
import org.calyxos.backup.storage.ui.NOTIFICATION_ID_RESTORE import org.calyxos.backup.storage.ui.NOTIFICATION_ID_RESTORE
import org.calyxos.backup.storage.ui.Notifications import org.calyxos.backup.storage.ui.Notifications
import org.calyxos.backup.storage.ui.restore.FileSelectionManager
/** /**
* Start to trigger restore as a foreground service. Ensure that you provide the snapshot * Start to trigger restore as a foreground service. Ensure that you provide the snapshot
@ -36,6 +39,7 @@ public abstract class RestoreService : Service() {
private val n by lazy { Notifications(applicationContext) } private val n by lazy { Notifications(applicationContext) }
protected abstract val storageBackup: StorageBackup protected abstract val storageBackup: StorageBackup
protected abstract val fileSelectionManager: FileSelectionManager
protected abstract val restoreObserver: RestoreObserver? protected abstract val restoreObserver: RestoreObserver?
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
@ -47,8 +51,11 @@ public abstract class RestoreService : Service() {
startForeground(NOTIFICATION_ID_RESTORE, n.getRestoreNotification()) startForeground(NOTIFICATION_ID_RESTORE, n.getRestoreNotification())
GlobalScope.launch { GlobalScope.launch {
val snapshot = withContext(Dispatchers.Main) {
fileSelectionManager.getBackupSnapshotAndReset()
}
// TODO offer a way to try again if failed, or do an automatic retry here // TODO offer a way to try again if failed, or do an automatic retry here
storageBackup.restoreBackupSnapshot(storedSnapshot, null, restoreObserver) storageBackup.restoreBackupSnapshot(storedSnapshot, snapshot, restoreObserver)
stopSelf(startId) stopSelf(startId)
} }
return START_STICKY_COMPATIBILITY return START_STICKY_COMPATIBILITY

View file

@ -0,0 +1,66 @@
/*
* SPDX-FileCopyrightText: 2024 The Calyx Institute
* SPDX-License-Identifier: Apache-2.0
*/
package org.calyxos.backup.storage.ui.restore
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.annotation.CallSuper
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.fragment.app.Fragment
import androidx.lifecycle.Lifecycle.State.STARTED
import androidx.lifecycle.flowWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.behavior.HideBottomViewOnScrollBehavior
import kotlinx.coroutines.launch
import org.calyxos.backup.storage.R
public abstract class FileSelectionFragment : Fragment() {
protected abstract val viewModel: SnapshotViewModel
protected lateinit var list: RecyclerView
private lateinit var adapter: FilesAdapter
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
): View {
requireActivity().setTitle(R.string.select_files_title)
val v = inflater.inflate(R.layout.fragment_select_files, container, false)
list = v.findViewById(R.id.list)
return v
}
@CallSuper
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
adapter = FilesAdapter(
viewModel.fileSelectionManager::onExpandClicked,
viewModel.fileSelectionManager::onCheckedChanged,
)
list.adapter = adapter
lifecycleScope.launch {
viewModel.fileSelectionManager.files.flowWithLifecycle(lifecycle, STARTED).collect {
adapter.submitList(it) {
onFileItemsChanged(it)
}
}
}
}
protected open fun onFileItemsChanged(filesItems: List<FilesItem>) {
}
protected fun slideUpInRootView(view: View) {
val layoutParams = view.layoutParams as CoordinatorLayout.LayoutParams
val behavior = layoutParams.behavior as HideBottomViewOnScrollBehavior
behavior.slideUp(view)
}
}

View file

@ -0,0 +1,217 @@
/*
* 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 class FileSelectionManager {
private val allFolders = HashMap<String, FolderItem>()
private val allFiles = HashMap<String, MutableList<FileItem>>()
private var snapshot: BackupSnapshot? = null
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) {
// clear previous state if existing
clearState()
// store snapshot for later
this.snapshot = snapshot
// cache files from snapshot within [RestorableFile] (for easier processing)
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()
}
@UiThread
public fun getBackupSnapshotAndReset(): BackupSnapshot {
val snapshot = this.snapshot ?: error("No snapshot stored")
// clear previous media files from snapshot
val snapshotBuilder = snapshot.toBuilder()
.clearMediaFiles()
.clearDocumentFiles()
// add only selected files back to snapshot
allFiles.values.forEach { fileList ->
fileList.forEach { file ->
if (file.selected && file.file.mediaFile != null) {
snapshotBuilder.addMediaFiles(file.file.mediaFile)
} else if (file.selected && file.file.docFile != null) {
snapshotBuilder.addDocumentFiles(file.file.docFile)
}
}
}
// clear state to free up memory
clearState()
return snapshotBuilder.build()
}
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,
)
}
private fun clearState() {
snapshot = null
expandedFolder = null
allFolders.clear()
allFiles.clear()
mFiles.value = emptyList()
}
}

View file

@ -0,0 +1,126 @@
/*
* SPDX-FileCopyrightText: 2024 The Calyx Institute
* SPDX-License-Identifier: Apache-2.0
*/
package org.calyxos.backup.storage.ui.restore
import android.content.res.Resources
import android.text.format.DateUtils.FORMAT_ABBREV_ALL
import android.text.format.DateUtils.getRelativeTimeSpanString
import android.text.format.Formatter
import android.view.LayoutInflater
import android.view.View
import android.view.View.VISIBLE
import android.view.ViewGroup
import android.widget.CheckBox
import android.widget.ImageView
import android.widget.TextView
import androidx.core.content.ContextCompat.getDrawable
import androidx.core.view.updatePadding
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView.ViewHolder
import org.calyxos.backup.storage.R
import org.calyxos.backup.storage.backup.BackupMediaFile.MediaType.AUDIO
import org.calyxos.backup.storage.backup.BackupMediaFile.MediaType.IMAGES
import org.calyxos.backup.storage.backup.BackupMediaFile.MediaType.VIDEO
import org.calyxos.backup.storage.ui.restore.FilesAdapter.FilesViewHolder
private class FilesItemCallback : DiffUtil.ItemCallback<FilesItem>() {
override fun areItemsTheSame(oldItem: FilesItem, newItem: FilesItem): Boolean {
if (oldItem is FileItem && newItem is FileItem) return newItem.file == oldItem.file
if (oldItem is FolderItem && newItem is FolderItem) return newItem.name == oldItem.name
return false
}
override fun areContentsTheSame(oldItem: FilesItem, newItem: FilesItem): Boolean {
if (oldItem is FileItem && newItem is FileItem) return newItem.selected == oldItem.selected
if (oldItem is FolderItem && newItem is FolderItem) {
return newItem.selected == oldItem.selected && newItem.expanded == oldItem.expanded &&
newItem.partiallySelected == oldItem.partiallySelected
}
return false
}
}
internal class FilesAdapter(
private val onExpandClicked: (FolderItem) -> Unit,
private val onCheckedChanged: (FilesItem) -> Unit,
) : ListAdapter<FilesItem, FilesViewHolder>(FilesItemCallback()) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): FilesViewHolder {
val v = LayoutInflater.from(parent.context).inflate(R.layout.item_file, parent, false)
return FilesViewHolder(v)
}
override fun onBindViewHolder(holder: FilesViewHolder, position: Int) {
holder.bind(getItem(position))
}
inner class FilesViewHolder(itemView: View) : ViewHolder(itemView) {
private val context = itemView.context
private val expandView: ImageView = itemView.findViewById(R.id.expandView)
private val nameView: TextView = itemView.findViewById(R.id.nameView)
private val infoView: TextView = itemView.findViewById(R.id.infoView)
private val checkBox: CheckBox = itemView.findViewById(R.id.checkBox)
private val indentPadding = (8 * Resources.getSystem().displayMetrics.density).toInt()
private val checkBoxDrawable = checkBox.buttonDrawable
private val indeterminateDrawable =
getDrawable(context, R.drawable.ic_indeterminate_check_box)
fun bind(item: FilesItem) {
if (item is FolderItem) {
expandView.visibility = VISIBLE
val res = if (item.expanded) {
R.drawable.ic_keyboard_arrow_down
} else {
R.drawable.ic_chevron_right
}
expandView.setImageResource(res)
} else if (item is FileItem) {
expandView.setImageResource(getDrawableResource(item))
}
itemView.setOnClickListener {
if (item is FolderItem) onExpandClicked(item)
else checkBox.toggle()
}
itemView.updatePadding(left = indentPadding * item.level)
nameView.text = item.name
val now = System.currentTimeMillis()
var text = Formatter.formatShortFileSize(context, item.size)
item.lastModified?.let {
text += " - " + getRelativeTimeSpanString(it, now, 0L, FORMAT_ABBREV_ALL)
}
if (item is FolderItem) {
val numStr = context.getString(R.string.select_files_number_of_files, item.numFiles)
text += " - $numStr"
}
infoView.text = text
// unset and re-reset onCheckedChangeListener while updating checked state
checkBox.setOnCheckedChangeListener(null)
checkBox.isChecked = item.selected
checkBox.setOnCheckedChangeListener { _, _ ->
onCheckedChanged(item)
}
if (item is FolderItem && item.partiallySelected) {
checkBox.buttonDrawable = indeterminateDrawable
} else {
checkBox.buttonDrawable = checkBoxDrawable
}
}
}
private fun getDrawableResource(item: FileItem): Int = item.file.mediaFile?.type?.let { type ->
when (type) {
IMAGES -> R.drawable.ic_image
VIDEO -> R.drawable.ic_video_file
AUDIO -> R.drawable.ic_audio_file
else -> R.drawable.ic_insert_drive_file
}
} ?: R.drawable.ic_insert_drive_file
}

View file

@ -0,0 +1,46 @@
/*
* SPDX-FileCopyrightText: 2024 The Calyx Institute
* SPDX-License-Identifier: Apache-2.0
*/
package org.calyxos.backup.storage.ui.restore
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"
}
}
}

View file

@ -22,6 +22,7 @@ import org.calyxos.backup.storage.api.SnapshotResult
public interface SnapshotViewModel { public interface SnapshotViewModel {
public val snapshots: LiveData<SnapshotResult> public val snapshots: LiveData<SnapshotResult>
public val fileSelectionManager: FileSelectionManager
} }
internal interface SnapshotClickListener { internal interface SnapshotClickListener {
@ -46,7 +47,7 @@ public abstract class SnapshotFragment : Fragment(), SnapshotClickListener {
val adapter = SnapshotAdapter(this) val adapter = SnapshotAdapter(this)
list.adapter = adapter list.adapter = adapter
viewModel.snapshots.observe(viewLifecycleOwner, { viewModel.snapshots.observe(viewLifecycleOwner) {
progressBar.visibility = INVISIBLE progressBar.visibility = INVISIBLE
when (it) { when (it) {
is SnapshotResult.Success -> { is SnapshotResult.Success -> {
@ -54,6 +55,7 @@ public abstract class SnapshotFragment : Fragment(), SnapshotClickListener {
emptyStateView.visibility = VISIBLE emptyStateView.visibility = VISIBLE
} else adapter.submitList(it.snapshots) } else adapter.submitList(it.snapshots)
} }
is SnapshotResult.Error -> { is SnapshotResult.Error -> {
val color = resources.getColor(R.color.design_default_color_error, null) val color = resources.getColor(R.color.design_default_color_error, null)
emptyStateView.setTextColor(color) emptyStateView.setTextColor(color)
@ -61,7 +63,7 @@ public abstract class SnapshotFragment : Fragment(), SnapshotClickListener {
emptyStateView.visibility = VISIBLE emptyStateView.visibility = VISIBLE
} }
} }
}) }
return v return v
} }

View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?><!--
SPDX-FileCopyrightText: Material Design Authors / Google LLC
SPDX-License-Identifier: Apache-2.0
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?android:attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M14,2H6C4.9,2 4.01,2.9 4.01,4L4,20c0,1.1 0.89,2 1.99,2H18c1.1,0 2,-0.9 2,-2V8L14,2zM16,13h-3v3.75c0,1.24 -1.01,2.25 -2.25,2.25S8.5,17.99 8.5,16.75c0,-1.24 1.01,-2.25 2.25,-2.25c0.46,0 0.89,0.14 1.25,0.38V11h4V13zM13,9V3.5L18.5,9H13z" />
</vector>

View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?><!--
SPDX-FileCopyrightText: Material Design Authors / Google LLC
SPDX-License-Identifier: Apache-2.0
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?android:attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M10,6L8.59,7.41 13.17,12l-4.58,4.59L10,18l6,-6z" />
</vector>

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?><!--
SPDX-FileCopyrightText: Material Design Authors / Google LLC
SPDX-License-Identifier: Apache-2.0
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?android:attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M21,19V5c0,-1.1 -0.9,-2 -2,-2H5c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2zM8.5,13.5l2.5,3.01L14.5,12l4.5,6H5l3.5,-4.5z" />
</vector>

View file

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?><!--
SPDX-FileCopyrightText: Material Design Authors / Google LLC
SPDX-License-Identifier: Apache-2.0
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="32dp"
android:height="32dp"
android:tint="?colorAccent"
android:viewportWidth="48"
android:viewportHeight="48">
<group
android:name="icon_null"
android:scaleX="0.2"
android:scaleY="0.2"
android:translateX="6"
android:translateY="6">
<group
android:name="check"
android:scaleX="7.5"
android:scaleY="7.5">
<path
android:name="check_path_merged"
android:fillColor="#FF000000"
android:pathData="M19,3H5C3.9,3 3,3.9 3,5v14c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2V5C21,3.9 20.1,3 19,3zM17,13H7v-2h10V13z" />
</group>
</group>
</vector>

View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?><!--
SPDX-FileCopyrightText: Material Design Authors / Google LLC
SPDX-License-Identifier: Apache-2.0
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?android:attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M6,2c-1.1,0 -1.99,0.9 -1.99,2L4,20c0,1.1 0.89,2 1.99,2L18,22c1.1,0 2,-0.9 2,-2L20,8l-6,-6L6,2zM13,9L13,3.5L18.5,9L13,9z" />
</vector>

View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?><!--
SPDX-FileCopyrightText: Material Design Authors / Google LLC
SPDX-License-Identifier: Apache-2.0
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?android:attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M7.41,8.59L12,13.17l4.59,-4.58L18,10l-6,6 -6,-6 1.41,-1.41z" />
</vector>

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?><!--
SPDX-FileCopyrightText: Material Design Authors / Google LLC
SPDX-License-Identifier: Apache-2.0
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?android:attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M14,2H6.01c-1.1,0 -2,0.89 -2,2L4,20c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2V8L14,2zM13,9V3.5L18.5,9H13zM14,14l2,-1.06v4.12L14,16v1c0,0.55 -0.45,1 -1,1H9c-0.55,0 -1,-0.45 -1,-1v-4c0,-0.55 0.45,-1 1,-1h4c0.55,0 1,0.45 1,1V14z" />
</vector>

View file

@ -0,0 +1,51 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.appbar.AppBarLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.google.android.material.appbar.CollapsingToolbarLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_scrollFlags="scroll|enterAlwaysCollapsed">
<ViewStub
android:id="@+id/topStub"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inflatedId="@+id/topStub"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:layout="@layout/item_custom"
tools:visibility="visible" />
</com.google.android.material.appbar.CollapsingToolbarLayout>
</com.google.android.material.appbar.AppBarLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/list"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
app:layout_behavior="@string/appbar_scrolling_view_behavior"
tools:listitem="@layout/item_file" />
<ViewStub
android:id="@+id/bottomStub"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_margin="16dp"
android:inflatedId="@+id/bottomStub"
app:layout_behavior="com.google.android.material.behavior.HideBottomViewOnScrollBehavior"
tools:layout="@layout/item_custom"
tools:visibility="visible" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View file

@ -0,0 +1,55 @@
<?xml version="1.0" encoding="utf-8"?><!--
SPDX-FileCopyrightText: 2021 The Calyx Institute
SPDX-License-Identifier: Apache-2.0
-->
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?selectableItemBackground"
android:paddingVertical="8dp">
<ImageView
android:id="@+id/expandView"
android:layout_width="48dp"
android:layout_height="48dp"
android:scaleType="center"
android:src="@drawable/ic_folder"
app:layout_constraintEnd_toStartOf="@+id/nameView"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/nameView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:textAppearance="?android:attr/textAppearanceListItem"
app:layout_constraintEnd_toStartOf="@+id/checkBox"
app:layout_constraintStart_toEndOf="@+id/expandView"
app:layout_constraintTop_toTopOf="parent"
tools:text="File/folder name which might be quite long, who knows...?" />
<TextView
android:id="@+id/infoView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="@+id/nameView"
app:layout_constraintHorizontal_chainStyle="spread_inside"
app:layout_constraintStart_toStartOf="@+id/nameView"
app:layout_constraintTop_toBottomOf="@+id/nameView"
tools:text="24h ago - 23 MB" />
<CheckBox
android:id="@+id/checkBox"
android:layout_width="48dp"
android:layout_height="48dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/nameView"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -22,4 +22,8 @@
<string name="snapshots_title">Available storage backups</string> <string name="snapshots_title">Available storage backups</string>
<string name="snapshots_empty">No storage backups found\n\nSorry, but there is nothing that can be restored.</string> <string name="snapshots_empty">No storage backups found\n\nSorry, but there is nothing that can be restored.</string>
<string name="snapshots_error">Error loading snapshots</string> <string name="snapshots_error">Error loading snapshots</string>
<string name="select_files_title">Review files for restore</string>
<string name="select_files_number_of_files">%1$d file(s)</string>
<string name="select_files_button_restore">Restore selected files</string>
</resources> </resources>