Sort app selection like backup status and show sections

system data comes first and then apps
This commit is contained in:
Torsten Grote 2024-05-22 17:16:12 -03:00
parent af1b3de9cb
commit 573e48f393
No known key found for this signature in database
GPG key ID: 3E5F77D92CF891FF
6 changed files with 129 additions and 33 deletions

View file

@ -7,44 +7,66 @@ import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.View.INVISIBLE import android.view.View.INVISIBLE
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.ImageView.ScaleType.CENTER
import android.widget.ImageView.ScaleType.FIT_CENTER
import android.widget.TextView
import androidx.annotation.StringRes
import androidx.recyclerview.widget.AsyncListDiffer import androidx.recyclerview.widget.AsyncListDiffer
import androidx.recyclerview.widget.DiffUtil.ItemCallback import androidx.recyclerview.widget.DiffUtil.ItemCallback
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.Adapter import androidx.recyclerview.widget.RecyclerView.Adapter
import androidx.recyclerview.widget.RecyclerView.VISIBLE import androidx.recyclerview.widget.RecyclerView.VISIBLE
import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.metadata.PackageMetadata import com.stevesoltys.seedvault.metadata.PackageMetadata
import com.stevesoltys.seedvault.restore.AppSelectionAdapter.AppSelectionViewHolder
import com.stevesoltys.seedvault.ui.AppViewHolder import com.stevesoltys.seedvault.ui.AppViewHolder
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
sealed interface AppSelectionItem
internal class AppSelectionSection(@StringRes val titleRes: Int) : AppSelectionItem
internal data class SelectableAppItem( internal data class SelectableAppItem(
val packageName: String, val packageName: String,
val metadata: PackageMetadata, val metadata: PackageMetadata,
val selected: Boolean, val selected: Boolean,
val hasIcon: Boolean? = null, val hasIcon: Boolean? = null,
) { ) : AppSelectionItem {
val name: String get() = metadata.name?.toString() ?: packageName val name: String get() = metadata.name?.toString() ?: packageName
} }
internal class AppSelectionAdapter( internal class AppSelectionAdapter(
val scope: CoroutineScope, val scope: CoroutineScope,
val iconLoader: suspend (String, (Bitmap) -> Unit) -> Unit, val iconLoader: suspend (SelectableAppItem, (Bitmap) -> Unit) -> Unit,
val listener: (SelectableAppItem) -> Unit, val listener: (SelectableAppItem) -> Unit,
) : Adapter<AppSelectionViewHolder>() { ) : Adapter<RecyclerView.ViewHolder>() {
private val diffCallback = object : ItemCallback<SelectableAppItem>() { private val diffCallback = object : ItemCallback<AppSelectionItem>() {
override fun areItemsTheSame( override fun areItemsTheSame(
oldItem: SelectableAppItem, oldItem: AppSelectionItem,
newItem: SelectableAppItem, newItem: AppSelectionItem,
): Boolean = oldItem.packageName == newItem.packageName ): Boolean {
return if (oldItem is AppSelectionSection && newItem is AppSelectionSection) {
oldItem.titleRes == newItem.titleRes
} else if (oldItem is SelectableAppItem && newItem is SelectableAppItem) {
oldItem.packageName == newItem.packageName
} else {
false
}
}
override fun areContentsTheSame( override fun areContentsTheSame(
old: SelectableAppItem, old: AppSelectionItem,
new: SelectableAppItem, new: AppSelectionItem,
): Boolean { ): Boolean {
return old.selected == new.selected && old.hasIcon == new.hasIcon return if (old is AppSelectionSection && new is AppSelectionSection) {
true
} else if (old is SelectableAppItem && new is SelectableAppItem) {
old.selected == new.selected && old.hasIcon == new.hasIcon
} else {
false
}
} }
} }
private val differ = AsyncListDiffer(this, diffCallback) private val differ = AsyncListDiffer(this, diffCallback)
@ -55,27 +77,67 @@ internal class AppSelectionAdapter(
override fun getItemId(position: Int): Long = position.toLong() // items never get added/removed override fun getItemId(position: Int): Long = position.toLong() // items never get added/removed
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AppSelectionViewHolder { override fun getItemViewType(position: Int): Int = when (differ.currentList[position]) {
is SelectableAppItem -> 0
is AppSelectionSection -> 1
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
return when (viewType) {
0 -> {
val v = LayoutInflater.from(parent.context) val v = LayoutInflater.from(parent.context)
.inflate(R.layout.list_item_app_status, parent, false) .inflate(R.layout.list_item_app_status, parent, false)
return AppSelectionViewHolder(v) SelectableAppViewHolder(v)
}
1 -> {
val v = LayoutInflater.from(parent.context)
.inflate(R.layout.list_item_app_section_title, parent, false)
AppSelectionSectionViewHolder(v)
}
else -> throw AssertionError("unknown view type")
}
} }
override fun getItemCount() = differ.currentList.size override fun getItemCount() = differ.currentList.size
override fun onBindViewHolder(holder: AppSelectionViewHolder, position: Int) { override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
holder.bind(differ.currentList[position]) when (holder) {
is SelectableAppViewHolder -> {
holder.bind(differ.currentList[position] as SelectableAppItem)
} }
fun submitList(items: List<SelectableAppItem>) { is AppSelectionSectionViewHolder -> {
differ.submitList(items) holder.bind(differ.currentList[position] as AppSelectionSection)
}
}
} }
override fun onViewRecycled(holder: AppSelectionViewHolder) { fun submitList(items: List<AppSelectionItem>) {
holder.iconJob?.cancel() val itemsWithSections = items.toMutableList().apply {
val i = indexOfLast {
it as SelectableAppItem
it.metadata.system && !it.metadata.isLaunchableSystemApp
}
add(i + 1, AppSelectionSection(R.string.backup_section_user))
add(0, AppSelectionSection(R.string.backup_section_system))
}
differ.submitList(itemsWithSections)
} }
internal inner class AppSelectionViewHolder(v: View) : AppViewHolder(v) { override fun onViewRecycled(holder: RecyclerView.ViewHolder) {
if (holder is SelectableAppViewHolder) holder.iconJob?.cancel()
}
class AppSelectionSectionViewHolder(v: View) : RecyclerView.ViewHolder(v) {
private val titleView: TextView = v as TextView
fun bind(item: AppSelectionSection) {
titleView.setText(item.titleRes)
}
}
internal inner class SelectableAppViewHolder(v: View) : AppViewHolder(v) {
var iconJob: Job? = null var iconJob: Job? = null
@ -99,7 +161,9 @@ internal class AppSelectionAdapter(
} else if (item.hasIcon) { } else if (item.hasIcon) {
appIcon.alpha = 0.5f appIcon.alpha = 0.5f
iconJob = scope.launch { iconJob = scope.launch {
iconLoader(item.packageName) { bitmap -> iconLoader(item) { bitmap ->
val isSpecial = item.metadata.system && !item.metadata.isLaunchableSystemApp
appIcon.scaleType = if (isSpecial) CENTER else FIT_CENTER
appIcon.setImageBitmap(bitmap) appIcon.setImageBitmap(bitmap)
appIcon.alpha = 1f appIcon.alpha = 1f
} }

View file

@ -73,8 +73,8 @@ class AppSelectionFragment : Fragment() {
} }
} }
private suspend fun loadIcon(packageName: String, callback: (Bitmap) -> Unit) { private suspend fun loadIcon(item: SelectableAppItem, callback: (Bitmap) -> Unit) {
viewModel.loadIcon(packageName, callback) viewModel.loadIcon(item, callback)
} }
} }

View file

@ -19,6 +19,8 @@ import android.os.UserHandle
import android.util.Log import android.util.Log
import androidx.annotation.UiThread import androidx.annotation.UiThread
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import androidx.appcompat.content.res.AppCompatResources.getDrawable
import androidx.core.graphics.drawable.toBitmap
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.asLiveData import androidx.lifecycle.asLiveData
@ -28,6 +30,7 @@ import com.stevesoltys.seedvault.BackupMonitor
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.crypto.KeyManager import com.stevesoltys.seedvault.crypto.KeyManager
import com.stevesoltys.seedvault.metadata.PackageMetadata
import com.stevesoltys.seedvault.metadata.PackageMetadataMap import com.stevesoltys.seedvault.metadata.PackageMetadataMap
import com.stevesoltys.seedvault.metadata.PackageState.APK_AND_DATA import com.stevesoltys.seedvault.metadata.PackageState.APK_AND_DATA
import com.stevesoltys.seedvault.metadata.PackageState.NOT_ALLOWED import com.stevesoltys.seedvault.metadata.PackageState.NOT_ALLOWED
@ -62,6 +65,7 @@ import com.stevesoltys.seedvault.ui.LiveEvent
import com.stevesoltys.seedvault.ui.MutableLiveEvent import com.stevesoltys.seedvault.ui.MutableLiveEvent
import com.stevesoltys.seedvault.ui.RequireProvisioningViewModel import com.stevesoltys.seedvault.ui.RequireProvisioningViewModel
import com.stevesoltys.seedvault.ui.notification.getAppName 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.FILE_BACKUP_ICONS
import com.stevesoltys.seedvault.worker.IconManager import com.stevesoltys.seedvault.worker.IconManager
import com.stevesoltys.seedvault.worker.NUM_PACKAGES_PER_TRANSACTION import com.stevesoltys.seedvault.worker.NUM_PACKAGES_PER_TRANSACTION
@ -78,6 +82,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.restore.RestoreService.Companion.EXTRA_USER_ID
import org.calyxos.backup.storage.ui.restore.SnapshotViewModel import org.calyxos.backup.storage.ui.restore.SnapshotViewModel
import java.util.LinkedList import java.util.LinkedList
import java.util.Locale
private val TAG = RestoreViewModel::class.java.simpleName private val TAG = RestoreViewModel::class.java.simpleName
@ -183,12 +188,19 @@ internal class RestoreViewModel(
// filter and sort app items for display // filter and sort app items for display
val items = restorableBackup.packageMetadataMap.mapNotNull { (packageName, metadata) -> val items = restorableBackup.packageMetadataMap.mapNotNull { (packageName, metadata) ->
if (metadata.time == 0L && !metadata.hasApk()) null if (metadata.time == 0L && !metadata.hasApk()) null
else if (packageName == MAGIC_PACKAGE_MANAGER) null else if (metadata.system && !metadata.isLaunchableSystemApp) null
else SelectableAppItem(packageName, metadata, true) else SelectableAppItem(packageName, metadata, true)
}.sortedWith { i1, i2 -> }.sortedBy {
if (i1.metadata.system == i2.metadata.system) i1.name.compareTo(i2.name, true) it.name.lowercase(Locale.getDefault())
else i1.metadata.system.compareTo(i2.metadata.system) }.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, hasIcon = true)
} }
items.addAll(0, systemDataItems)
mSelectedApps.value = mSelectedApps.value =
SelectedAppsState(apps = items, allSelected = true, iconsLoaded = false) SelectedAppsState(apps = items, allSelected = true, iconsLoaded = false)
// download icons // download icons
@ -205,7 +217,7 @@ internal class RestoreViewModel(
} }
// update state, so it knows that icons have loaded // update state, so it knows that icons have loaded
val updatedItems = items.map { item -> val updatedItems = items.map { item ->
item.copy(hasIcon = item.packageName in packagesWithIcons) item.copy(hasIcon = item.hasIcon ?: false || item.packageName in packagesWithIcons)
} }
val newState = val newState =
SelectedAppsState(updatedItems, allSelected = true, iconsLoaded = true) SelectedAppsState(updatedItems, allSelected = true, iconsLoaded = true)
@ -214,8 +226,15 @@ internal class RestoreViewModel(
mDisplayFragment.setEvent(SELECT_APPS) mDisplayFragment.setEvent(SELECT_APPS)
} }
suspend fun loadIcon(packageName: String, callback: (Bitmap) -> Unit) { suspend fun loadIcon(item: SelectableAppItem, callback: (Bitmap) -> Unit) {
iconManager.loadIcon(packageName, callback) if (item.metadata.system && !item.metadata.isLaunchableSystemApp &&
item.packageName in systemData.keys
) {
val bitmap = getDrawable(app, systemData[item.packageName]!!.iconRes)!!.toBitmap()
callback(bitmap)
} else {
iconManager.loadIcon(item.packageName, callback)
}
} }
fun onCheckAllAppsClicked() { fun onCheckAllAppsClicked() {

View file

@ -23,5 +23,5 @@ val systemData = mapOf(
data class SystemData( data class SystemData(
@StringRes val nameRes: Int, @StringRes val nameRes: Int,
@DrawableRes val iconRes: Int?, @DrawableRes val iconRes: Int,
) )

View file

@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M21.81,12.74l-0.82,-0.63v-0.22l0.8,-0.63c0.16,-0.12 0.2,-0.34 0.1,-0.51l-0.85,-1.48c-0.07,-0.13 -0.21,-0.2 -0.35,-0.2 -0.05,0 -0.1,0.01 -0.15,0.03l-0.95,0.38c-0.08,-0.05 -0.11,-0.07 -0.19,-0.11l-0.15,-1.01c-0.03,-0.21 -0.2,-0.36 -0.4,-0.36h-1.71c-0.2,0 -0.37,0.15 -0.4,0.34l-0.14,1.01c-0.03,0.02 -0.07,0.03 -0.1,0.05l-0.09,0.06 -0.95,-0.38c-0.05,-0.02 -0.1,-0.03 -0.15,-0.03 -0.14,0 -0.27,0.07 -0.35,0.2l-0.85,1.48c-0.1,0.17 -0.06,0.39 0.1,0.51l0.8,0.63v0.23l-0.8,0.63c-0.16,0.12 -0.2,0.34 -0.1,0.51l0.85,1.48c0.07,0.13 0.21,0.2 0.35,0.2 0.05,0 0.1,-0.01 0.15,-0.03l0.95,-0.37c0.08,0.05 0.12,0.07 0.2,0.11l0.15,1.01c0.03,0.2 0.2,0.34 0.4,0.34h1.71c0.2,0 0.37,-0.15 0.4,-0.34l0.15,-1.01c0.03,-0.02 0.07,-0.03 0.1,-0.05l0.09,-0.06 0.95,0.38c0.05,0.02 0.1,0.03 0.15,0.03 0.14,0 0.27,-0.07 0.35,-0.2l0.85,-1.48c0.1,-0.17 0.06,-0.39 -0.1,-0.51zM18,13.5c-0.83,0 -1.5,-0.67 -1.5,-1.5s0.67,-1.5 1.5,-1.5 1.5,0.67 1.5,1.5 -0.67,1.5 -1.5,1.5zM17,17h2v4c0,1.1 -0.9,2 -2,2H7c-1.1,0 -2,-0.9 -2,-2V3c0,-1.1 0.9,-2 2,-2h10c1.1,0 2,0.9 2,2v4h-2V6H7v12h10v-1z" />
</vector>

View file

@ -182,6 +182,7 @@
<string name="backup_settings">Device settings</string> <string name="backup_settings">Device settings</string>
<string name="backup_call_log">Call history</string> <string name="backup_call_log">Call history</string>
<string name="backup_contacts">Local contacts</string> <string name="backup_contacts">Local contacts</string>
<string name="backup_system_apps">System apps</string>
<string name="backup_section_user">Apps</string> <string name="backup_section_user">Apps</string>
<!-- This text gets shown for apps that the OS did not try to backup for whatever reason e.g. no backup was run yet --> <!-- This text gets shown for apps that the OS did not try to backup for whatever reason e.g. no backup was run yet -->
<string name="backup_app_not_yet_backed_up">Waiting to back up…</string> <string name="backup_app_not_yet_backed_up">Waiting to back up…</string>