diff --git a/app/src/main/java/com/stevesoltys/seedvault/restore/AppSelectionAdapter.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/AppSelectionAdapter.kt new file mode 100644 index 00000000..4e95a63a --- /dev/null +++ b/app/src/main/java/com/stevesoltys/seedvault/restore/AppSelectionAdapter.kt @@ -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() { + + private val diffCallback = object : ItemCallback() { + 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) { + 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 + } + } + +} diff --git a/app/src/main/java/com/stevesoltys/seedvault/restore/AppSelectionFragment.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/AppSelectionFragment.kt new file mode 100644 index 00000000..72aa3797 --- /dev/null +++ b/app/src/main/java/com/stevesoltys/seedvault/restore/AppSelectionFragment.kt @@ -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 + } + } + +} diff --git a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreActivity.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreActivity.kt index 4e8c1819..5b5c30bd 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreActivity.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreActivity.kt @@ -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()) diff --git a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreViewModel.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreViewModel.kt index 5d8905d9..ea055691 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreViewModel.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreViewModel.kt @@ -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, + val allSelected: Boolean, +) + internal class RestoreViewModel( app: Application, settingsManager: SettingsManager, @@ -106,8 +112,13 @@ internal class RestoreViewModel( private val mChosenRestorableBackup = MutableLiveData() internal val chosenRestorableBackup: LiveData get() = mChosenRestorableBackup + private val mSelectedApps = MutableLiveData() + internal val selectedApps: LiveData get() = mSelectedApps + internal val installResult: LiveData = 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 } diff --git a/app/src/main/res/layout/fragment_restore_app_selection.xml b/app/src/main/res/layout/fragment_restore_app_selection.xml new file mode 100644 index 00000000..36f68bf5 --- /dev/null +++ b/app/src/main/res/layout/fragment_restore_app_selection.xml @@ -0,0 +1,129 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +