Factor out app restore selection code into new AppSelectionManager

This commit is contained in:
Torsten Grote 2024-05-27 17:09:40 -03:00
parent 332387fd58
commit f5fb9ffffa
No known key found for this signature in database
GPG key ID: 3E5F77D92CF891FF
2 changed files with 169 additions and 112 deletions

View file

@ -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<SelectableAppItem>,
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<SelectedAppsState> = 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),
)
}
}

View file

@ -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<SelectableAppItem>,
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<DisplayFragment>()
internal val displayFragment: LiveEvent<DisplayFragment> = mDisplayFragment
@ -122,8 +114,8 @@ 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 selectedApps: LiveData<SelectedAppsState> =
appSelectionManager.selectedAppsLiveData
internal val installResult: LiveData<InstallResult> =
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)
}