Allow user to choose which apps should get restored

This commit is contained in:
Torsten Grote 2024-05-20 15:16:46 -03:00
parent 4803288629
commit 905340770c
No known key found for this signature in database
GPG key ID: 3E5F77D92CF891FF
6 changed files with 376 additions and 5 deletions

View file

@ -0,0 +1,100 @@
package com.stevesoltys.seedvault.restore
import android.text.format.DateUtils
import android.text.format.Formatter
import android.view.LayoutInflater
import android.view.View
import android.view.View.INVISIBLE
import android.view.ViewGroup
import androidx.recyclerview.widget.AsyncListDiffer
import androidx.recyclerview.widget.DiffUtil.ItemCallback
import androidx.recyclerview.widget.RecyclerView.Adapter
import androidx.recyclerview.widget.RecyclerView.VISIBLE
import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.metadata.PackageMetadata
import com.stevesoltys.seedvault.restore.AppSelectionAdapter.AppSelectionViewHolder
import com.stevesoltys.seedvault.ui.AppViewHolder
internal data class SelectableAppItem(
val packageName: String,
val metadata: PackageMetadata,
val selected: Boolean,
) {
val name: String get() = packageName
}
internal class AppSelectionAdapter(
val listener: (SelectableAppItem) -> Unit,
) : Adapter<AppSelectionViewHolder>() {
private val diffCallback = object : ItemCallback<SelectableAppItem>() {
override fun areItemsTheSame(
oldItem: SelectableAppItem,
newItem: SelectableAppItem,
): Boolean = oldItem.packageName == newItem.packageName
override fun areContentsTheSame(
old: SelectableAppItem,
new: SelectableAppItem,
): Boolean {
return old.selected == new.selected
}
}
private val differ = AsyncListDiffer(this, diffCallback)
init {
setHasStableIds(true)
}
override fun getItemId(position: Int): Long = position.toLong() // items never get added/removed
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AppSelectionViewHolder {
val v = LayoutInflater.from(parent.context)
.inflate(R.layout.list_item_app_status, parent, false)
return AppSelectionViewHolder(v)
}
override fun getItemCount() = differ.currentList.size
override fun onBindViewHolder(holder: AppSelectionViewHolder, position: Int) {
holder.bind(differ.currentList[position])
}
fun submitList(items: List<SelectableAppItem>) {
differ.submitList(items)
}
internal inner class AppSelectionViewHolder(v: View) : AppViewHolder(v) {
fun bind(item: SelectableAppItem) {
v.background = clickableBackground
v.setOnClickListener {
checkBox.toggle()
}
checkBox.setOnCheckedChangeListener(null)
checkBox.isChecked = item.selected
checkBox.setOnCheckedChangeListener { _, _ ->
listener(item)
}
checkBox.visibility = VISIBLE
progressBar.visibility = INVISIBLE
appIcon.setImageResource(R.drawable.ic_launcher_default)
appName.text = item.packageName
val time = if (item.metadata.time > 0) DateUtils.getRelativeTimeSpanString(
item.metadata.time,
System.currentTimeMillis(),
DateUtils.HOUR_IN_MILLIS,
DateUtils.FORMAT_ABBREV_RELATIVE,
) else v.context.getString(R.string.settings_backup_last_backup_never)
val size = if (item.metadata.size == null) "" else "(" + Formatter.formatShortFileSize(
v.context,
item.metadata.size ?: 0
) + ")"
appInfo.text =
v.context.getString(R.string.settings_backup_status_summary, "$time $size")
appInfo.visibility = VISIBLE
}
}
}

View file

@ -0,0 +1,70 @@
package com.stevesoltys.seedvault.restore
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Button
import android.widget.TextView
import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.checkbox.MaterialCheckBox
import com.stevesoltys.seedvault.R
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
class AppSelectionFragment : Fragment() {
private val viewModel: RestoreViewModel by sharedViewModel()
private val layoutManager = LinearLayoutManager(context)
private val adapter = AppSelectionAdapter { item ->
viewModel.onAppSelected(item)
}
private lateinit var backupNameView: TextView
private lateinit var toggleAllTextView: TextView
private lateinit var toggleAllView: MaterialCheckBox
private lateinit var appList: RecyclerView
private lateinit var button: Button
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
): View {
val v: View = inflater.inflate(R.layout.fragment_restore_app_selection, container, false)
backupNameView = v.requireViewById(R.id.backupNameView)
toggleAllTextView = v.requireViewById(R.id.toggleAllTextView)
toggleAllView = v.requireViewById(R.id.toggleAllView)
appList = v.requireViewById(R.id.appList)
button = v.requireViewById(R.id.button)
return v
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
toggleAllTextView.setOnClickListener {
viewModel.onCheckAllAppsClicked()
}
toggleAllView.setOnClickListener {
viewModel.onCheckAllAppsClicked()
}
appList.apply {
layoutManager = this@AppSelectionFragment.layoutManager
adapter = this@AppSelectionFragment.adapter
}
button.setOnClickListener { viewModel.onNextClickedAfterSelectingApps() }
viewModel.chosenRestorableBackup.observe(viewLifecycleOwner) { restorableBackup ->
backupNameView.text = restorableBackup.name
}
viewModel.selectedApps.observe(viewLifecycleOwner) { state ->
adapter.submitList(state.apps)
toggleAllView.isChecked = state.allSelected
}
}
}

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_FILES
import com.stevesoltys.seedvault.restore.DisplayFragment.RESTORE_FILES_STARTED
import com.stevesoltys.seedvault.restore.DisplayFragment.SELECT_APPS
import com.stevesoltys.seedvault.restore.install.InstallProgressFragment
import com.stevesoltys.seedvault.ui.RequireProvisioningActivity
import com.stevesoltys.seedvault.ui.RequireProvisioningViewModel
@ -28,15 +29,16 @@ class RestoreActivity : RequireProvisioningActivity() {
setContentView(R.layout.activity_fragment_container)
viewModel.displayFragment.observeEvent(this, { fragment ->
viewModel.displayFragment.observeEvent(this) { fragment ->
when (fragment) {
SELECT_APPS -> showFragment(AppSelectionFragment())
RESTORE_APPS -> showFragment(InstallProgressFragment())
RESTORE_BACKUP -> showFragment(RestoreProgressFragment())
RESTORE_FILES -> showFragment(RestoreFilesFragment())
RESTORE_FILES_STARTED -> showFragment(RestoreFilesStartedFragment())
else -> throw AssertionError()
}
})
}
if (savedInstanceState == null) {
showFragment(RestoreSetFragment())

View file

@ -27,6 +27,7 @@ import com.stevesoltys.seedvault.BackupMonitor
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.crypto.KeyManager
import com.stevesoltys.seedvault.metadata.PackageMetadataMap
import com.stevesoltys.seedvault.metadata.PackageState.APK_AND_DATA
import com.stevesoltys.seedvault.metadata.PackageState.NOT_ALLOWED
import com.stevesoltys.seedvault.metadata.PackageState.NO_DATA
@ -38,6 +39,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.SELECT_APPS
import com.stevesoltys.seedvault.restore.install.ApkRestore
import com.stevesoltys.seedvault.restore.install.InstallIntentCreator
import com.stevesoltys.seedvault.restore.install.InstallResult
@ -45,7 +47,6 @@ import com.stevesoltys.seedvault.restore.install.isInstalled
import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.storage.StorageRestoreService
import com.stevesoltys.seedvault.transport.TRANSPORT_ID
import com.stevesoltys.seedvault.worker.NUM_PACKAGES_PER_TRANSACTION
import com.stevesoltys.seedvault.transport.restore.RestoreCoordinator
import com.stevesoltys.seedvault.ui.AppBackupState
import com.stevesoltys.seedvault.ui.AppBackupState.FAILED
@ -60,6 +61,7 @@ import com.stevesoltys.seedvault.ui.LiveEvent
import com.stevesoltys.seedvault.ui.MutableLiveEvent
import com.stevesoltys.seedvault.ui.RequireProvisioningViewModel
import com.stevesoltys.seedvault.ui.notification.getAppName
import com.stevesoltys.seedvault.worker.NUM_PACKAGES_PER_TRANSACTION
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.catch
@ -72,13 +74,17 @@ import org.calyxos.backup.storage.api.StorageBackup
import org.calyxos.backup.storage.restore.RestoreService.Companion.EXTRA_TIMESTAMP_START
import org.calyxos.backup.storage.restore.RestoreService.Companion.EXTRA_USER_ID
import org.calyxos.backup.storage.ui.restore.SnapshotViewModel
import java.lang.IllegalStateException
import java.util.LinkedList
private val TAG = RestoreViewModel::class.java.simpleName
internal const val PACKAGES_PER_CHUNK = NUM_PACKAGES_PER_TRANSACTION
internal class SelectedAppsState(
val apps: List<SelectableAppItem>,
val allSelected: Boolean,
)
internal class RestoreViewModel(
app: Application,
settingsManager: SettingsManager,
@ -106,8 +112,13 @@ internal class RestoreViewModel(
private val mChosenRestorableBackup = MutableLiveData<RestorableBackup>()
internal val chosenRestorableBackup: LiveData<RestorableBackup> get() = mChosenRestorableBackup
private val mSelectedApps = MutableLiveData<SelectedAppsState>()
internal val selectedApps: LiveData<SelectedAppsState> get() = mSelectedApps
internal val installResult: LiveData<InstallResult> =
mChosenRestorableBackup.switchMap { backup ->
// TODO does this stay stable when re-observing this LiveData?
// TODO pass in app selection done by user
getInstallResult(backup)
}
internal val installIntentCreator by lazy { InstallIntentCreator(app.packageManager) }
@ -164,6 +175,63 @@ internal class RestoreViewModel(
override fun onRestorableBackupClicked(restorableBackup: RestorableBackup) {
mChosenRestorableBackup.value = restorableBackup
val items = restorableBackup.packageMetadataMap.mapNotNull { (packageName, metadata) ->
if (metadata.time == 0L && !metadata.hasApk()) null
else if (packageName == MAGIC_PACKAGE_MANAGER) null
else SelectableAppItem(packageName, metadata, true)
}.sortedWith { i1, i2 ->
if (i1.metadata.system == i2.metadata.system) i1.name.compareTo(i2.name, true)
else i1.metadata.system.compareTo(i2.metadata.system)
}
mSelectedApps.value = SelectedAppsState(items, true)
mDisplayFragment.setEvent(SELECT_APPS)
}
fun onCheckAllAppsClicked() {
val apps = selectedApps.value?.apps ?: return
val allSelected = apps.all { it.selected }
if (allSelected) {
// unselect all
val newApps = apps.map { if (it.selected) it.copy(selected = false) else it }
mSelectedApps.value = SelectedAppsState(newApps, false)
} else {
// select all
val newApps = apps.map { if (!it.selected) it.copy(selected = true) else it }
mSelectedApps.value = SelectedAppsState(newApps, true)
}
}
fun onAppSelected(item: SelectableAppItem) {
val apps = selectedApps.value?.apps?.toMutableList() ?: return
val iterator = apps.listIterator()
var allSelected = true
while (iterator.hasNext()) {
val app = iterator.next()
if (app.packageName == item.packageName) {
iterator.set(item.copy(selected = !item.selected))
allSelected = allSelected && !item.selected
} else {
allSelected = allSelected && app.selected
}
}
mSelectedApps.value = SelectedAppsState(apps, allSelected)
}
internal fun onNextClickedAfterSelectingApps() {
val backup = chosenRestorableBackup.value ?: error("No chosen backup")
// map packages names to selection state
val apps = selectedApps.value?.apps?.associate {
Pair(it.packageName, it.selected)
} ?: error("no selected apps")
// filter out unselected packages
val packages = backup.packageMetadataMap.filterKeys { packageName ->
apps[packageName] != true // packages that weren't found, won't get filtered
} as PackageMetadataMap
// replace original chosen backup with unselected packages removed
mChosenRestorableBackup.value = backup.copy(
backupMetadata = backup.backupMetadata.copy(packageMetadataMap = packages),
)
// tell UI to move to InstallFragment
mDisplayFragment.setEvent(RESTORE_APPS)
}
@ -475,5 +543,5 @@ internal class RestoreBackupResult(val errorMsg: String? = null) {
}
internal enum class DisplayFragment {
RESTORE_APPS, RESTORE_BACKUP, RESTORE_FILES, RESTORE_FILES_STARTED
SELECT_APPS, RESTORE_APPS, RESTORE_BACKUP, RESTORE_FILES, RESTORE_FILES_STARTED
}

View file

@ -0,0 +1,129 @@
<?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">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<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="match_parent"
android:layout_height="wrap_content"
android:text="@string/restore_select_packages"
android:textColor="?android:textColorSecondary"
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:textColor="?android:textColorTertiary"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/titleView"
tools:text="Pixel 2 XL - Owner of the device" />
<TextView
android:id="@+id/toggleAllTextView"
android:layout_width="0dp"
android:layout_height="48dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="0dp"
android:background="?selectableItemBackground"
android:clickable="true"
android:gravity="center_vertical"
android:paddingStart="40dp"
android:text="@string/restore_select_packages_all"
android:textSize="16sp"
app:layout_constraintEnd_toStartOf="@+id/toggleAllView"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/backupNameView"
tools:ignore="RtlSymmetry" />
<com.google.android.material.checkbox.MaterialCheckBox
android:id="@+id/toggleAllView"
style="@style/SudContent"
android:layout_width="0dp"
android:layout_height="48dp"
android:layout_marginTop="16dp"
android:clickable="false"
android:focusable="false"
android:gravity="center_vertical"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@+id/backupNameView"
tools:checked="true" />
<com.google.android.material.divider.MaterialDivider
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginHorizontal="40dp"
android:layout_marginTop="8dp"
android:layout_marginBottom="8dp"
app:dividerColor="?attr/colorControlNormal"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/toggleAllView" />
</androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.appbar.CollapsingToolbarLayout>
</com.google.android.material.appbar.AppBarLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/appList"
style="@style/SudContent"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginStart="0dp"
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
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: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" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View file

@ -209,6 +209,8 @@
<string name="restore_invalid_location_message">We could not find any backups at this location.\n\nPlease choose another location that contains a %s folder.</string>
<string name="restore_set_error">An error occurred while loading the backups.</string>
<string name="restore_set_empty_result">No suitable backups found at given location.\n\nThis is most likely due to a wrong recovery code or a storage error.</string>
<string name="restore_select_packages">Select apps to restore</string>
<string name="restore_select_packages_all">All of the following apps</string>
<string name="restore_installing_packages">Re-installing apps</string>
<string name="restore_app_status_installing">Re-installing</string>
<string name="restore_app_status_installed">Re-installed</string>