From f5fb9ffffa2520023d1c9e1c0131832bb1f8b1e6 Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Mon, 27 May 2024 17:09:40 -0300 Subject: [PATCH] Factor out app restore selection code into new AppSelectionManager --- .../seedvault/restore/AppSelectionManager.kt | 161 ++++++++++++++++++ .../seedvault/restore/RestoreViewModel.kt | 120 +------------ 2 files changed, 169 insertions(+), 112 deletions(-) create mode 100644 app/src/main/java/com/stevesoltys/seedvault/restore/AppSelectionManager.kt diff --git a/app/src/main/java/com/stevesoltys/seedvault/restore/AppSelectionManager.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/AppSelectionManager.kt new file mode 100644 index 00000000..d849a9ed --- /dev/null +++ b/app/src/main/java/com/stevesoltys/seedvault/restore/AppSelectionManager.kt @@ -0,0 +1,161 @@ +/* + * SPDX-FileCopyrightText: 2024 The Calyx Institute + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.stevesoltys.seedvault.restore + +import android.content.Context +import android.util.Log +import androidx.lifecycle.LiveData +import androidx.lifecycle.asLiveData +import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER +import com.stevesoltys.seedvault.R +import com.stevesoltys.seedvault.metadata.PackageMetadata +import com.stevesoltys.seedvault.metadata.PackageMetadataMap +import com.stevesoltys.seedvault.plugins.StoragePluginManager +import com.stevesoltys.seedvault.ui.PACKAGE_NAME_SYSTEM +import com.stevesoltys.seedvault.ui.systemData +import com.stevesoltys.seedvault.worker.FILE_BACKUP_ICONS +import com.stevesoltys.seedvault.worker.IconManager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import java.util.Locale + +internal class SelectedAppsState( + val apps: List, + val allSelected: Boolean, + val iconsLoaded: Boolean, +) + +private val TAG = AppSelectionManager::class.simpleName + +internal class AppSelectionManager( + private val context: Context, + private val pluginManager: StoragePluginManager, + private val iconManager: IconManager, + private val coroutineScope: CoroutineScope, +) { + + private val initialState = SelectedAppsState( + emptyList(), + allSelected = true, + iconsLoaded = false, + ) + private val selectedApps = MutableStateFlow(initialState) + val selectedAppsFlow = selectedApps.asStateFlow() + val selectedAppsLiveData: LiveData = selectedApps.asLiveData() + + fun onRestoreSetChosen(restorableBackup: RestorableBackup) { + // filter and sort app items for display + val items = restorableBackup.packageMetadataMap.mapNotNull { (packageName, metadata) -> + if (metadata.time == 0L && !metadata.hasApk()) null + else if (metadata.isInternalSystem) null + else SelectableAppItem(packageName, metadata, true) + }.sortedBy { + it.name.lowercase(Locale.getDefault()) + }.toMutableList() + val systemDataItems = systemData.mapNotNull { (packageName, data) -> + val metadata = restorableBackup.packageMetadataMap[packageName] + ?: return@mapNotNull null + if (metadata.time == 0L && !metadata.hasApk()) return@mapNotNull null + val name = context.getString(data.nameRes) + SelectableAppItem(packageName, metadata.copy(name = name), true) + } + val systemItem = SelectableAppItem( + packageName = PACKAGE_NAME_SYSTEM, + metadata = PackageMetadata( + time = restorableBackup.packageMetadataMap.values.maxOf { + if (it.system) it.time else -1 + }, + system = true, + name = context.getString(R.string.backup_system_apps), + ), + selected = true, + ) + items.add(0, systemItem) + items.addAll(0, systemDataItems) + selectedApps.value = + SelectedAppsState(apps = items, allSelected = true, iconsLoaded = false) + // download icons + coroutineScope.launch(Dispatchers.IO) { + val plugin = pluginManager.appPlugin + val token = restorableBackup.token + val packagesWithIcons = try { + plugin.getInputStream(token, FILE_BACKUP_ICONS).use { + iconManager.downloadIcons(restorableBackup.version, token, it) + } + } catch (e: Exception) { + Log.e(TAG, "Error loading icons:", e) + emptySet() + } + systemData.keys + setOf(PACKAGE_NAME_SYSTEM) // special apps have built-in icons + // update state, so it knows that icons have loaded + val updatedItems = items.map { item -> + item.copy(hasIcon = item.packageName in packagesWithIcons) + } + selectedApps.value = + SelectedAppsState(updatedItems, allSelected = true, iconsLoaded = true) + } + } + + fun onCheckAllAppsClicked() { + val apps = selectedApps.value.apps + val allSelected = apps.all { it.selected } + if (allSelected) { + // unselect all + val newApps = apps.map { if (it.selected) it.copy(selected = false) else it } + selectedApps.value = SelectedAppsState(newApps, false, iconsLoaded = true) + } else { + // select all + val newApps = apps.map { if (!it.selected) it.copy(selected = true) else it } + selectedApps.value = SelectedAppsState(newApps, true, iconsLoaded = true) + } + } + + fun onAppSelected(item: SelectableAppItem) { + val apps = selectedApps.value.apps.toMutableList() + 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 + } + } + selectedApps.value = SelectedAppsState(apps, allSelected, iconsLoaded = true) + } + + fun onAppSelectionFinished(backup: RestorableBackup): RestorableBackup { + // map packages names to selection state + val apps = selectedApps.value.apps.associate { + Pair(it.packageName, it.selected) + } + // filter out unselected packages + // Attention: This code is complicated and hard to test, proceed with plenty of care! + val restoreSystemApps = apps[PACKAGE_NAME_SYSTEM] != false + val packages = backup.packageMetadataMap.filter { (packageName, metadata) -> + val isSelected = apps[packageName] + @Suppress("IfThenToElvis") // the code is more readable like this + if (isSelected == null) { // was not in list + if (packageName == MAGIC_PACKAGE_MANAGER) true // @pm@ is essential for restore + // internal system apps were not in the list and are controlled by meta item, + // so allow them only if meta item was selected + else if (metadata.isInternalSystem) restoreSystemApps + else true // non-system packages that weren't found, won't get filtered + } else { // was in list and either selected or not + isSelected + } + } as PackageMetadataMap + // replace original chosen backup with unselected packages removed + return backup.copy( + backupMetadata = backup.backupMetadata.copy(packageMetadataMap = packages), + ) + } + +} 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 ff44615d..b5ae91e9 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreViewModel.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreViewModel.kt @@ -29,8 +29,6 @@ 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.PackageMetadata -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 @@ -66,7 +64,6 @@ import com.stevesoltys.seedvault.ui.PACKAGE_NAME_SYSTEM import com.stevesoltys.seedvault.ui.RequireProvisioningViewModel import com.stevesoltys.seedvault.ui.notification.getAppName import com.stevesoltys.seedvault.ui.systemData -import com.stevesoltys.seedvault.worker.FILE_BACKUP_ICONS import com.stevesoltys.seedvault.worker.IconManager import com.stevesoltys.seedvault.worker.NUM_PACKAGES_PER_TRANSACTION import kotlinx.coroutines.CoroutineDispatcher @@ -82,18 +79,11 @@ 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.restore.SnapshotViewModel import java.util.LinkedList -import java.util.Locale 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, - val iconsLoaded: Boolean, -) - internal class RestoreViewModel( app: Application, settingsManager: SettingsManager, @@ -112,6 +102,8 @@ internal class RestoreViewModel( private var session: IRestoreSession? = null private val monitor = BackupMonitor() + private val appSelectionManager = + AppSelectionManager(app, pluginManager, iconManager, viewModelScope) private val mDisplayFragment = MutableLiveEvent() internal val displayFragment: LiveEvent = mDisplayFragment @@ -122,8 +114,8 @@ 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 selectedApps: LiveData = + appSelectionManager.selectedAppsLiveData internal val installResult: LiveData = mChosenRestorableBackup.switchMap { backup -> @@ -185,56 +177,7 @@ internal class RestoreViewModel( override fun onRestorableBackupClicked(restorableBackup: RestorableBackup) { mChosenRestorableBackup.value = restorableBackup - // filter and sort app items for display - val items = restorableBackup.packageMetadataMap.mapNotNull { (packageName, metadata) -> - if (metadata.time == 0L && !metadata.hasApk()) null - else if (metadata.isInternalSystem) null - else SelectableAppItem(packageName, metadata, true) - }.sortedBy { - it.name.lowercase(Locale.getDefault()) - }.toMutableList() - val systemDataItems = systemData.mapNotNull { (packageName, data) -> - val metadata = restorableBackup.packageMetadataMap[packageName] - ?: return@mapNotNull null - if (metadata.time == 0L && !metadata.hasApk()) return@mapNotNull null - val name = app.getString(data.nameRes) - SelectableAppItem(packageName, metadata.copy(name = name), true) - } - val systemItem = SelectableAppItem( - packageName = PACKAGE_NAME_SYSTEM, - metadata = PackageMetadata( - time = restorableBackup.packageMetadataMap.values.maxOf { - if (it.system) it.time else -1 - }, - system = true, - name = app.getString(R.string.backup_system_apps), - ), - selected = true, - ) - items.add(0, systemItem) - items.addAll(0, systemDataItems) - mSelectedApps.value = - SelectedAppsState(apps = items, allSelected = true, iconsLoaded = false) - // download icons - viewModelScope.launch(Dispatchers.IO) { - val plugin = pluginManager.appPlugin - val token = restorableBackup.token - val packagesWithIcons = try { - plugin.getInputStream(token, FILE_BACKUP_ICONS).use { - iconManager.downloadIcons(restorableBackup.version, token, it) - } - } catch (e: Exception) { - Log.e(TAG, "Error loading icons:", e) - emptySet() - } + systemData.keys + setOf(PACKAGE_NAME_SYSTEM) - // update state, so it knows that icons have loaded - val updatedItems = items.map { item -> - item.copy(hasIcon = item.packageName in packagesWithIcons) - } - val newState = - SelectedAppsState(updatedItems, allSelected = true, iconsLoaded = true) - mSelectedApps.postValue(newState) - } + appSelectionManager.onRestoreSetChosen(restorableBackup) mDisplayFragment.setEvent(SELECT_APPS) } @@ -250,60 +193,13 @@ internal class RestoreViewModel( } } - 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, iconsLoaded = true) - } else { - // select all - val newApps = apps.map { if (!it.selected) it.copy(selected = true) else it } - mSelectedApps.value = SelectedAppsState(newApps, true, iconsLoaded = 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, iconsLoaded = true) - } + fun onCheckAllAppsClicked() = appSelectionManager.onCheckAllAppsClicked() + fun onAppSelected(item: SelectableAppItem) = appSelectionManager.onAppSelected(item) 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 - // Attention: This code is complicated and hard to test, proceed with plenty of care! - val restoreSystemApps = apps[PACKAGE_NAME_SYSTEM] != false - val packages = backup.packageMetadataMap.filter { (packageName, metadata) -> - val isSelected = apps[packageName] - @Suppress("IfThenToElvis") // the code is more readable like this - if (isSelected == null) { // was not in list - // internal system apps were not in the list and are controlled by meta item - if (metadata.isInternalSystem) restoreSystemApps // only if allowed by meta item - else true // non-system packages that weren't found, won't get filtered - } else { // was in list and either selected or not - isSelected - } - } as PackageMetadataMap // replace original chosen backup with unselected packages removed - mChosenRestorableBackup.value = backup.copy( - backupMetadata = backup.backupMetadata.copy(packageMetadataMap = packages), - ) + mChosenRestorableBackup.value = appSelectionManager.onAppSelectionFinished(backup) // tell UI to move to InstallFragment mDisplayFragment.setEvent(RESTORE_APPS) }