Merge pull request #705 from grote/565-choose-files-restore
Allow choosing what files/folders will get restored
This commit is contained in:
commit
b571da787a
41 changed files with 991 additions and 53 deletions
|
@ -57,6 +57,7 @@ class KoinInstrumentationTestApp : App() {
|
|||
iconManager = get(),
|
||||
storageBackup = get(),
|
||||
pluginManager = get(),
|
||||
fileSelectionManager = get(),
|
||||
)
|
||||
)
|
||||
currentRestoreViewModel!!
|
||||
|
|
|
@ -28,8 +28,8 @@ import com.stevesoltys.seedvault.metadata.metadataModule
|
|||
import com.stevesoltys.seedvault.plugins.StoragePluginManager
|
||||
import com.stevesoltys.seedvault.plugins.saf.storagePluginModuleSaf
|
||||
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.restoreUiModule
|
||||
import com.stevesoltys.seedvault.settings.AppListRetriever
|
||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||
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.backup.backupModule
|
||||
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.recoverycode.RecoveryCodeViewModel
|
||||
import com.stevesoltys.seedvault.ui.storage.BackupStorageViewModel
|
||||
|
@ -97,20 +96,6 @@ open class App : Application() {
|
|||
)
|
||||
}
|
||||
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() {
|
||||
|
@ -155,6 +140,7 @@ open class App : Application() {
|
|||
installModule,
|
||||
storageModule,
|
||||
workerModule,
|
||||
restoreUiModule,
|
||||
appModule
|
||||
)
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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_FILES
|
||||
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.install.InstallProgressFragment
|
||||
import com.stevesoltys.seedvault.ui.RequireProvisioningActivity
|
||||
|
@ -35,7 +36,12 @@ class RestoreActivity : RequireProvisioningActivity() {
|
|||
RESTORE_APPS -> showFragment(InstallProgressFragment())
|
||||
RESTORE_BACKUP -> showFragment(RestoreProgressFragment())
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -47,7 +47,7 @@ internal class RestoreFilesFragment : SnapshotFragment() {
|
|||
}
|
||||
|
||||
override fun onSnapshotClicked(item: SnapshotItem) {
|
||||
viewModel.startFilesRestore(item)
|
||||
viewModel.selectFilesForRestore(item)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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()) }
|
||||
}
|
|
@ -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_FILES
|
||||
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.install.ApkRestore
|
||||
import com.stevesoltys.seedvault.restore.install.InstallIntentCreator
|
||||
|
@ -44,8 +45,10 @@ import kotlinx.coroutines.GlobalScope
|
|||
import kotlinx.coroutines.launch
|
||||
import org.calyxos.backup.storage.api.SnapshotItem
|
||||
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_USER_ID
|
||||
import org.calyxos.backup.storage.ui.restore.FileSelectionManager
|
||||
import org.calyxos.backup.storage.ui.restore.SnapshotViewModel
|
||||
import java.util.LinkedList
|
||||
|
||||
|
@ -63,6 +66,7 @@ internal class RestoreViewModel(
|
|||
private val iconManager: IconManager,
|
||||
storageBackup: StorageBackup,
|
||||
pluginManager: StoragePluginManager,
|
||||
override val fileSelectionManager: FileSelectionManager,
|
||||
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||
) : RequireProvisioningViewModel(app, settingsManager, keyManager, pluginManager),
|
||||
RestorableBackupClickListener, SnapshotViewModel {
|
||||
|
@ -98,6 +102,7 @@ internal class RestoreViewModel(
|
|||
get() = appDataRestoreManager.restoreBackupResult
|
||||
|
||||
override val snapshots = storageBackup.getBackupSnapshots().asLiveData(ioDispatcher)
|
||||
private var storedSnapshot: StoredSnapshot? = null
|
||||
|
||||
internal fun loadRestoreSets() = viewModelScope.launch(ioDispatcher) {
|
||||
val backups = restoreCoordinator.getAvailableMetadata()?.mapNotNull { (token, metadata) ->
|
||||
|
@ -179,12 +184,22 @@ internal class RestoreViewModel(
|
|||
}
|
||||
|
||||
@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)
|
||||
i.putExtra(EXTRA_USER_ID, item.storedSnapshot.userId)
|
||||
i.putExtra(EXTRA_TIMESTAMP_START, item.time)
|
||||
i.putExtra(EXTRA_USER_ID, storedSnapshot.userId)
|
||||
i.putExtra(EXTRA_TIMESTAMP_START, storedSnapshot.timestamp)
|
||||
app.startForegroundService(i)
|
||||
mDisplayFragment.setEvent(RESTORE_FILES_STARTED)
|
||||
this.storedSnapshot = null
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -206,5 +221,10 @@ internal class RestoreBackupResult(val errorMsg: String? = null) {
|
|||
}
|
||||
|
||||
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,
|
||||
}
|
||||
|
|
|
@ -224,12 +224,13 @@ internal class SettingsViewModel(
|
|||
|
||||
internal fun backupNow() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val isAppBackupEnabled = backupManager.isBackupEnabled
|
||||
if (settingsManager.isStorageBackupEnabled()) {
|
||||
val i = Intent(app, StorageBackupService::class.java)
|
||||
// this starts an app backup afterwards
|
||||
i.putExtra(EXTRA_START_APP_BACKUP, true)
|
||||
// this starts an app backup afterwards (if enabled)
|
||||
i.putExtra(EXTRA_START_APP_BACKUP, isAppBackupEnabled)
|
||||
startForegroundService(app, i)
|
||||
} else {
|
||||
} else if (isAppBackupEnabled) {
|
||||
AppBackupWorker.scheduleNow(app, reschedule = !pluginManager.isOnRemovableDrive)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -18,6 +18,7 @@ import org.calyxos.backup.storage.backup.BackupService
|
|||
import org.calyxos.backup.storage.backup.NotificationBackupObserver
|
||||
import org.calyxos.backup.storage.restore.NotificationRestoreObserver
|
||||
import org.calyxos.backup.storage.restore.RestoreService
|
||||
import org.calyxos.backup.storage.ui.restore.FileSelectionManager
|
||||
import org.koin.android.ext.android.inject
|
||||
|
||||
/*
|
||||
|
@ -70,6 +71,7 @@ internal class StorageBackupService : BackupService() {
|
|||
|
||||
internal class StorageRestoreService : RestoreService() {
|
||||
override val storageBackup: StorageBackup by inject()
|
||||
override val fileSelectionManager: FileSelectionManager by inject()
|
||||
|
||||
// use lazy delegate because context isn't available during construction time
|
||||
override val restoreObserver: RestoreObserver by lazy {
|
||||
|
|
|
@ -24,8 +24,7 @@ abstract class BackupActivity : AppCompatActivity() {
|
|||
|
||||
protected fun showFragment(f: Fragment, addToBackStack: Boolean = false, tag: String? = null) {
|
||||
supportFragmentManager.beginTransaction().apply {
|
||||
if (tag == null) replace(R.id.fragment, f)
|
||||
else replace(R.id.fragment, f, tag)
|
||||
replace(R.id.fragment, f, tag)
|
||||
if (addToBackStack) addToBackStack(null)
|
||||
commit()
|
||||
}
|
||||
|
|
16
app/src/main/res/layout/footer_files_selection.xml
Normal file
16
app/src/main/res/layout/footer_files_selection.xml
Normal 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" />
|
|
@ -108,10 +108,6 @@
|
|||
android:layout_marginTop="0dp"
|
||||
android:layout_marginEnd="0dp"
|
||||
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" />
|
||||
|
||||
<Button
|
||||
|
@ -122,9 +118,6 @@
|
|||
android:layout_gravity="bottom|end"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:text="@string/restore_backup_button"
|
||||
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" />
|
||||
app:layout_behavior="com.google.android.material.behavior.HideBottomViewOnScrollBehavior" />
|
||||
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
|
|
45
app/src/main/res/layout/header_files_selection.xml
Normal file
45
app/src/main/res/layout/header_files_selection.xml
Normal 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>
|
|
@ -234,6 +234,7 @@
|
|||
|
||||
<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_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_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>
|
||||
|
|
|
@ -12,6 +12,7 @@ import android.util.Log
|
|||
import de.grobox.storagebackuptester.plugin.TestSafStoragePlugin
|
||||
import de.grobox.storagebackuptester.settings.SettingsManager
|
||||
import org.calyxos.backup.storage.api.StorageBackup
|
||||
import org.calyxos.backup.storage.ui.restore.FileSelectionManager
|
||||
|
||||
class App : Application() {
|
||||
|
||||
|
@ -20,6 +21,7 @@ class App : Application() {
|
|||
val plugin = TestSafStoragePlugin(this) { settingsManager.getBackupLocation() }
|
||||
StorageBackup(this, { plugin })
|
||||
}
|
||||
val fileSelectionManager: FileSelectionManager get() = FileSelectionManager()
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
|
|
@ -14,6 +14,7 @@ import org.calyxos.backup.storage.backup.BackupService
|
|||
import org.calyxos.backup.storage.backup.NotificationBackupObserver
|
||||
import org.calyxos.backup.storage.restore.NotificationRestoreObserver
|
||||
import org.calyxos.backup.storage.restore.RestoreService
|
||||
import org.calyxos.backup.storage.ui.restore.FileSelectionManager
|
||||
import java.util.concurrent.TimeUnit.HOURS
|
||||
|
||||
// debug with:
|
||||
|
@ -45,6 +46,8 @@ class DemoBackupService : BackupService() {
|
|||
class DemoRestoreService : RestoreService() {
|
||||
// use lazy delegate because context isn't available during construction time
|
||||
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 {
|
||||
NotificationRestoreObserver(applicationContext)
|
||||
}
|
||||
|
|
|
@ -24,6 +24,7 @@ import kotlinx.coroutines.withContext
|
|||
import org.calyxos.backup.storage.api.SnapshotItem
|
||||
import org.calyxos.backup.storage.api.SnapshotResult
|
||||
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.scanner.DocumentScanner
|
||||
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 settingsManager = app.settingsManager
|
||||
override val storageBackup: StorageBackup = app.storageBackup
|
||||
override val fileSelectionManager = app.fileSelectionManager
|
||||
|
||||
private val _backupLog = MutableLiveData(BackupProgress(0, 0, logEmptyState))
|
||||
val backupLog: LiveData<BackupProgress> = _backupLog
|
||||
|
@ -62,6 +64,7 @@ class MainViewModel(application: Application) : BackupContentViewModel(applicati
|
|||
|
||||
override val snapshots: LiveData<SnapshotResult>
|
||||
get() = storageBackup.getBackupSnapshots().asLiveData(Dispatchers.IO)
|
||||
private var storedSnapshot: StoredSnapshot? = null
|
||||
|
||||
init {
|
||||
viewModelScope.launch { loadContent() }
|
||||
|
@ -124,8 +127,14 @@ class MainViewModel(application: Application) : BackupContentViewModel(applicati
|
|||
}
|
||||
|
||||
fun onSnapshotClicked(item: SnapshotItem) {
|
||||
val snapshot = item.snapshot
|
||||
check(snapshot != null)
|
||||
val snapshot = item.snapshot ?: error("${item.storedSnapshot} had null snapshot")
|
||||
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
|
||||
// app.startForegroundService(Intent(app, DemoRestoreService::class.java).apply {
|
||||
|
@ -137,8 +146,9 @@ class MainViewModel(application: Application) : BackupContentViewModel(applicati
|
|||
_restoreProgressVisible.value = true
|
||||
val restoreObserver = RestoreStats(app, _restoreLog)
|
||||
viewModelScope.launch {
|
||||
storageBackup.restoreBackupSnapshot(item.storedSnapshot, snapshot, restoreObserver)
|
||||
storageBackup.restoreBackupSnapshot(storedSnapshot, snapshot, restoreObserver)
|
||||
_restoreProgressVisible.value = false
|
||||
this@MainViewModel.storedSnapshot = null
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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!!)
|
||||
}
|
||||
}
|
|
@ -39,8 +39,8 @@ class DemoSnapshotFragment : SnapshotFragment() {
|
|||
override fun onSnapshotClicked(item: SnapshotItem) {
|
||||
viewModel.onSnapshotClicked(item)
|
||||
parentFragmentManager.beginTransaction()
|
||||
.replace(R.id.container, RestoreFragment.newInstance())
|
||||
.addToBackStack("RESTORE")
|
||||
.replace(R.id.container, DemoFileSelectionFragment())
|
||||
.addToBackStack("SELECT")
|
||||
.commit()
|
||||
}
|
||||
|
||||
|
|
|
@ -52,17 +52,17 @@ class RestoreFragment : Fragment() {
|
|||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
viewModel.restoreLog.observe(viewLifecycleOwner, { progress ->
|
||||
viewModel.restoreLog.observe(viewLifecycleOwner) { progress ->
|
||||
progress.text?.let { adapter.addItem(it) }
|
||||
horizontalProgressBar.max = progress.total
|
||||
horizontalProgressBar.setProgress(progress.current, true)
|
||||
list.postDelayed({
|
||||
list.scrollToPosition(adapter.itemCount - 1)
|
||||
}, 50)
|
||||
})
|
||||
viewModel.restoreProgressVisible.observe(viewLifecycleOwner, { visible ->
|
||||
}
|
||||
viewModel.restoreProgressVisible.observe(viewLifecycleOwner) { visible ->
|
||||
progressBar.visibility = if (visible) VISIBLE else INVISIBLE
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
|
|
17
storage/demo/src/main/res/layout/footer_files.xml
Normal file
17
storage/demo/src/main/res/layout/footer_files.xml
Normal 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" />
|
20
storage/demo/src/main/res/layout/header_file_select.xml
Normal file
20
storage/demo/src/main/res/layout/header_file_select.xml
Normal 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>
|
|
@ -200,7 +200,7 @@ public class StorageBackup(
|
|||
|
||||
public suspend fun restoreBackupSnapshot(
|
||||
storedSnapshot: StoredSnapshot,
|
||||
snapshot: BackupSnapshot? = null,
|
||||
snapshot: BackupSnapshot,
|
||||
restoreObserver: RestoreObserver? = null,
|
||||
): Boolean = withContext(dispatcher) {
|
||||
if (restoreRunning.getAndSet(true)) {
|
||||
|
|
|
@ -33,6 +33,8 @@ internal class BackupResult(
|
|||
backupMediaFiles = backupMediaFiles + other.backupMediaFiles,
|
||||
backupDocumentFiles = backupDocumentFiles + other.backupDocumentFiles,
|
||||
)
|
||||
|
||||
val isEmpty: Boolean = backupMediaFiles.isEmpty() && backupDocumentFiles.isEmpty()
|
||||
}
|
||||
|
||||
internal class Backup(
|
||||
|
@ -134,6 +136,7 @@ internal class Backup(
|
|||
fileBackup.backupFiles(filesResult.files, availableChunkIds, backupObserver)
|
||||
}
|
||||
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 } +
|
||||
result.backupDocumentFiles.sumOf { it.size }
|
||||
val endTime = System.currentTimeMillis()
|
||||
|
|
|
@ -22,7 +22,6 @@ import org.calyxos.backup.storage.plugin.SnapshotRetriever
|
|||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.security.GeneralSecurityException
|
||||
import kotlin.time.ExperimentalTime
|
||||
|
||||
private const val TAG = "Restore"
|
||||
|
||||
|
@ -99,15 +98,12 @@ internal class Restore(
|
|||
Log.e(TAG, "Decrypting and parsing $numSnapshots snapshots took $time")
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalTime::class)
|
||||
@Throws(IOException::class, GeneralSecurityException::class)
|
||||
suspend fun restoreBackupSnapshot(
|
||||
storedSnapshot: StoredSnapshot,
|
||||
optionalSnapshot: BackupSnapshot? = null,
|
||||
snapshot: BackupSnapshot,
|
||||
observer: RestoreObserver? = null,
|
||||
) {
|
||||
val snapshot = optionalSnapshot ?: snapshotRetriever.getSnapshot(streamKey, storedSnapshot)
|
||||
|
||||
val filesTotal = snapshot.mediaFilesList.size + snapshot.documentFilesList.size
|
||||
val totalSize =
|
||||
snapshot.mediaFilesList.sumOf { it.size } + snapshot.documentFilesList.sumOf { it.size }
|
||||
|
|
|
@ -9,8 +9,10 @@ import android.app.Service
|
|||
import android.content.Intent
|
||||
import android.os.IBinder
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.calyxos.backup.storage.api.RestoreObserver
|
||||
import org.calyxos.backup.storage.api.StorageBackup
|
||||
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.ui.NOTIFICATION_ID_RESTORE
|
||||
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
|
||||
|
@ -36,6 +39,7 @@ public abstract class RestoreService : Service() {
|
|||
|
||||
private val n by lazy { Notifications(applicationContext) }
|
||||
protected abstract val storageBackup: StorageBackup
|
||||
protected abstract val fileSelectionManager: FileSelectionManager
|
||||
protected abstract val restoreObserver: RestoreObserver?
|
||||
|
||||
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())
|
||||
GlobalScope.launch {
|
||||
val snapshot = withContext(Dispatchers.Main) {
|
||||
fileSelectionManager.getBackupSnapshotAndReset()
|
||||
}
|
||||
// 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)
|
||||
}
|
||||
return START_STICKY_COMPATIBILITY
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -22,6 +22,7 @@ import org.calyxos.backup.storage.api.SnapshotResult
|
|||
|
||||
public interface SnapshotViewModel {
|
||||
public val snapshots: LiveData<SnapshotResult>
|
||||
public val fileSelectionManager: FileSelectionManager
|
||||
}
|
||||
|
||||
internal interface SnapshotClickListener {
|
||||
|
@ -46,7 +47,7 @@ public abstract class SnapshotFragment : Fragment(), SnapshotClickListener {
|
|||
|
||||
val adapter = SnapshotAdapter(this)
|
||||
list.adapter = adapter
|
||||
viewModel.snapshots.observe(viewLifecycleOwner, {
|
||||
viewModel.snapshots.observe(viewLifecycleOwner) {
|
||||
progressBar.visibility = INVISIBLE
|
||||
when (it) {
|
||||
is SnapshotResult.Success -> {
|
||||
|
@ -54,6 +55,7 @@ public abstract class SnapshotFragment : Fragment(), SnapshotClickListener {
|
|||
emptyStateView.visibility = VISIBLE
|
||||
} else adapter.submitList(it.snapshots)
|
||||
}
|
||||
|
||||
is SnapshotResult.Error -> {
|
||||
val color = resources.getColor(R.color.design_default_color_error, null)
|
||||
emptyStateView.setTextColor(color)
|
||||
|
@ -61,7 +63,7 @@ public abstract class SnapshotFragment : Fragment(), SnapshotClickListener {
|
|||
emptyStateView.visibility = VISIBLE
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
|
|
15
storage/lib/src/main/res/drawable/ic_audio_file.xml
Normal file
15
storage/lib/src/main/res/drawable/ic_audio_file.xml
Normal 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>
|
15
storage/lib/src/main/res/drawable/ic_chevron_right.xml
Normal file
15
storage/lib/src/main/res/drawable/ic_chevron_right.xml
Normal 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>
|
14
storage/lib/src/main/res/drawable/ic_image.xml
Normal file
14
storage/lib/src/main/res/drawable/ic_image.xml
Normal 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>
|
|
@ -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>
|
15
storage/lib/src/main/res/drawable/ic_insert_drive_file.xml
Normal file
15
storage/lib/src/main/res/drawable/ic_insert_drive_file.xml
Normal 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>
|
15
storage/lib/src/main/res/drawable/ic_keyboard_arrow_down.xml
Normal file
15
storage/lib/src/main/res/drawable/ic_keyboard_arrow_down.xml
Normal 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>
|
14
storage/lib/src/main/res/drawable/ic_video_file.xml
Normal file
14
storage/lib/src/main/res/drawable/ic_video_file.xml
Normal 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>
|
51
storage/lib/src/main/res/layout/fragment_select_files.xml
Normal file
51
storage/lib/src/main/res/layout/fragment_select_files.xml
Normal 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>
|
55
storage/lib/src/main/res/layout/item_file.xml
Normal file
55
storage/lib/src/main/res/layout/item_file.xml
Normal 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>
|
|
@ -22,4 +22,8 @@
|
|||
<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_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>
|
||||
|
|
Loading…
Reference in a new issue