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.INVISIBLE
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.DiffUtil.ItemCallback
import androidx.recyclerview.widget.RecyclerView
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
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
sealed interface AppSelectionItem
internal class AppSelectionSection(@StringRes val titleRes: Int) : AppSelectionItem
internal data class SelectableAppItem(
val packageName: String,
val metadata: PackageMetadata,
val selected: Boolean,
val hasIcon: Boolean? = null,
) {
) : AppSelectionItem {
val name: String get() = metadata.name?.toString() ?: packageName
}
internal class AppSelectionAdapter(
val scope: CoroutineScope,
val iconLoader: suspend (String, (Bitmap) -> Unit) -> Unit,
val iconLoader: suspend (SelectableAppItem, (Bitmap) -> Unit) -> 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(
oldItem: SelectableAppItem,
newItem: SelectableAppItem,
): Boolean = oldItem.packageName == newItem.packageName
oldItem: AppSelectionItem,
newItem: AppSelectionItem,
): 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(
old: SelectableAppItem,
new: SelectableAppItem,
old: AppSelectionItem,
new: AppSelectionItem,
): 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)
@ -55,27 +77,67 @@ internal class AppSelectionAdapter(
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 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)
.inflate(R.layout.list_item_app_status, parent, false)
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 onBindViewHolder(holder: AppSelectionViewHolder, position: Int) {
holder.bind(differ.currentList[position])
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (holder) {
is SelectableAppViewHolder -> {
holder.bind(differ.currentList[position] as SelectableAppItem)
}
is AppSelectionSectionViewHolder -> {
holder.bind(differ.currentList[position] as AppSelectionSection)
}
}
}
fun submitList(items: List<SelectableAppItem>) {
differ.submitList(items)
fun submitList(items: List<AppSelectionItem>) {
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)
}
override fun onViewRecycled(holder: AppSelectionViewHolder) {
holder.iconJob?.cancel()
override fun onViewRecycled(holder: RecyclerView.ViewHolder) {
if (holder is SelectableAppViewHolder) holder.iconJob?.cancel()
}
internal inner class AppSelectionViewHolder(v: View) : AppViewHolder(v) {
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
@ -99,7 +161,9 @@ internal class AppSelectionAdapter(
} else if (item.hasIcon) {
appIcon.alpha = 0.5f
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.alpha = 1f
}

View file

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

View file

@ -19,6 +19,8 @@ import android.os.UserHandle
import android.util.Log
import androidx.annotation.UiThread
import androidx.annotation.WorkerThread
import androidx.appcompat.content.res.AppCompatResources.getDrawable
import androidx.core.graphics.drawable.toBitmap
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.asLiveData
@ -28,6 +30,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.PackageMetadata
import com.stevesoltys.seedvault.metadata.PackageMetadataMap
import com.stevesoltys.seedvault.metadata.PackageState.APK_AND_DATA
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.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
@ -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.ui.restore.SnapshotViewModel
import java.util.LinkedList
import java.util.Locale
private val TAG = RestoreViewModel::class.java.simpleName
@ -183,12 +188,19 @@ internal class RestoreViewModel(
// filter and sort app items for display
val items = restorableBackup.packageMetadataMap.mapNotNull { (packageName, metadata) ->
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)
}.sortedWith { i1, i2 ->
if (i1.metadata.system == i2.metadata.system) i1.name.compareTo(i2.name, true)
else i1.metadata.system.compareTo(i2.metadata.system)
}.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, hasIcon = true)
}
items.addAll(0, systemDataItems)
mSelectedApps.value =
SelectedAppsState(apps = items, allSelected = true, iconsLoaded = false)
// download icons
@ -205,7 +217,7 @@ internal class RestoreViewModel(
}
// update state, so it knows that icons have loaded
val updatedItems = items.map { item ->
item.copy(hasIcon = item.packageName in packagesWithIcons)
item.copy(hasIcon = item.hasIcon ?: false || item.packageName in packagesWithIcons)
}
val newState =
SelectedAppsState(updatedItems, allSelected = true, iconsLoaded = true)
@ -214,8 +226,15 @@ internal class RestoreViewModel(
mDisplayFragment.setEvent(SELECT_APPS)
}
suspend fun loadIcon(packageName: String, callback: (Bitmap) -> Unit) {
iconManager.loadIcon(packageName, callback)
suspend fun loadIcon(item: SelectableAppItem, callback: (Bitmap) -> Unit) {
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() {

View file

@ -23,5 +23,5 @@ val systemData = mapOf(
data class SystemData(
@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_call_log">Call history</string>
<string name="backup_contacts">Local contacts</string>
<string name="backup_system_apps">System 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 -->
<string name="backup_app_not_yet_backed_up">Waiting to back up…</string>