Store app icons in separate file

so they can be shown when selecting apps for restore which is before we have downloaded the APK files to extract icons from
This commit is contained in:
Torsten Grote 2024-05-21 11:51:05 -03:00
parent 905340770c
commit 5a2f1187a8
No known key found for this signature in database
GPG key ID: 3E5F77D92CF891FF
11 changed files with 257 additions and 13 deletions

View file

@ -46,7 +46,19 @@ class KoinInstrumentationTestApp : App() {
viewModel {
currentRestoreViewModel =
spyk(RestoreViewModel(context, get(), get(), get(), get(), get(), get(), get()))
spyk(
RestoreViewModel(
app = context,
settingsManager = get(),
keyManager = get(),
backupManager = get(),
restoreCoordinator = get(),
apkRestore = get(),
iconManager = get(),
storageBackup = get(),
pluginManager = get(),
)
)
currentRestoreViewModel!!
}

View file

@ -95,7 +95,19 @@ open class App : Application() {
)
}
viewModel { RestoreStorageViewModel(this@App, get(), get(), get(), get()) }
viewModel { RestoreViewModel(this@App, get(), get(), get(), get(), get(), get(), get()) }
viewModel {
RestoreViewModel(
app = this@App,
settingsManager = get(),
keyManager = get(),
backupManager = get(),
restoreCoordinator = get(),
apkRestore = get(),
iconManager = get(),
storageBackup = get(),
pluginManager = get(),
)
}
viewModel { FileSelectionViewModel(this@App, get()) }
}

View file

@ -1,5 +1,6 @@
package com.stevesoltys.seedvault.restore
import android.graphics.Bitmap
import android.text.format.DateUtils
import android.text.format.Formatter
import android.view.LayoutInflater
@ -14,16 +15,22 @@ 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
internal data class SelectableAppItem(
val packageName: String,
val metadata: PackageMetadata,
val selected: Boolean,
val hasIcon: Boolean? = null,
) {
val name: String get() = packageName
}
internal class AppSelectionAdapter(
val scope: CoroutineScope,
val iconLoader: suspend (String, (Bitmap) -> Unit) -> Unit,
val listener: (SelectableAppItem) -> Unit,
) : Adapter<AppSelectionViewHolder>() {
@ -37,7 +44,7 @@ internal class AppSelectionAdapter(
old: SelectableAppItem,
new: SelectableAppItem,
): Boolean {
return old.selected == new.selected
return old.selected == new.selected && old.hasIcon == new.hasIcon
}
}
private val differ = AsyncListDiffer(this, diffCallback)
@ -64,7 +71,14 @@ internal class AppSelectionAdapter(
differ.submitList(items)
}
override fun onViewRecycled(holder: AppSelectionViewHolder) {
holder.iconJob?.cancel()
}
internal inner class AppSelectionViewHolder(v: View) : AppViewHolder(v) {
var iconJob: Job? = null
fun bind(item: SelectableAppItem) {
v.background = clickableBackground
v.setOnClickListener {
@ -76,9 +90,23 @@ internal class AppSelectionAdapter(
checkBox.setOnCheckedChangeListener { _, _ ->
listener(item)
}
checkBox.visibility = VISIBLE
progressBar.visibility = INVISIBLE
checkBox.visibility = if (item.hasIcon == null) INVISIBLE else VISIBLE
progressBar.visibility = if (item.hasIcon == null) VISIBLE else INVISIBLE
appIcon.setImageResource(R.drawable.ic_launcher_default)
if (item.hasIcon == null) {
appIcon.alpha = 0.5f
} else if (item.hasIcon) {
appIcon.alpha = 0.5f
iconJob = scope.launch {
iconLoader(item.packageName) { bitmap ->
appIcon.setImageBitmap(bitmap)
appIcon.alpha = 1f
}
}
} else {
appIcon.alpha = 1f
}
appIcon.setImageResource(R.drawable.ic_launcher_default)
appName.text = item.packageName
val time = if (item.metadata.time > 0) DateUtils.getRelativeTimeSpanString(

View file

@ -1,5 +1,6 @@
package com.stevesoltys.seedvault.restore
import android.graphics.Bitmap
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
@ -7,6 +8,7 @@ import android.view.ViewGroup
import android.widget.Button
import android.widget.TextView
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.checkbox.MaterialCheckBox
@ -18,7 +20,7 @@ class AppSelectionFragment : Fragment() {
private val viewModel: RestoreViewModel by sharedViewModel()
private val layoutManager = LinearLayoutManager(context)
private val adapter = AppSelectionAdapter { item ->
private val adapter = AppSelectionAdapter(lifecycleScope, this::loadIcon) { item ->
viewModel.onAppSelected(item)
}
@ -64,7 +66,15 @@ class AppSelectionFragment : Fragment() {
viewModel.selectedApps.observe(viewLifecycleOwner) { state ->
adapter.submitList(state.apps)
toggleAllView.isChecked = state.allSelected
// enable toggle all views only after icons have loaded
toggleAllView.isEnabled = state.iconsLoaded
toggleAllTextView.isEnabled = state.iconsLoaded
button.isEnabled = state.iconsLoaded
}
}
private suspend fun loadIcon(packageName: String, callback: (Bitmap) -> Unit) {
viewModel.loadIcon(packageName, callback)
}
}

View file

@ -13,6 +13,7 @@ import android.app.backup.IRestoreObserver
import android.app.backup.IRestoreSession
import android.app.backup.RestoreSet
import android.content.Intent
import android.graphics.Bitmap
import android.os.RemoteException
import android.os.UserHandle
import android.util.Log
@ -61,6 +62,8 @@ 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.FILE_BACKUP_ICONS
import com.stevesoltys.seedvault.worker.IconManager
import com.stevesoltys.seedvault.worker.NUM_PACKAGES_PER_TRANSACTION
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
@ -83,6 +86,7 @@ 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(
@ -92,6 +96,7 @@ internal class RestoreViewModel(
private val backupManager: IBackupManager,
private val restoreCoordinator: RestoreCoordinator,
private val apkRestore: ApkRestore,
private val iconManager: IconManager,
storageBackup: StorageBackup,
pluginManager: StoragePluginManager,
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
@ -175,6 +180,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 (packageName == MAGIC_PACKAGE_MANAGER) null
@ -183,21 +189,46 @@ internal class RestoreViewModel(
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)
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(it)
}
} catch (e: Exception) {
Log.e(TAG, "Error loading icons:", e)
emptySet()
}
// 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)
}
mDisplayFragment.setEvent(SELECT_APPS)
}
suspend fun loadIcon(packageName: String, callback: (Bitmap) -> Unit) {
iconManager.loadIcon(packageName, callback)
}
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)
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)
mSelectedApps.value = SelectedAppsState(newApps, true, iconsLoaded = true)
}
}
@ -214,7 +245,7 @@ internal class RestoreViewModel(
allSelected = allSelected && app.selected
}
}
mSelectedApps.value = SelectedAppsState(apps, allSelected)
mSelectedApps.value = SelectedAppsState(apps, allSelected, iconsLoaded = true)
}
internal fun onNextClickedAfterSelectingApps() {

View file

@ -69,7 +69,7 @@ internal class SettingsViewModel(
app: Application,
settingsManager: SettingsManager,
keyManager: KeyManager,
private val pluginManager: StoragePluginManager,
pluginManager: StoragePluginManager,
private val metadataManager: MetadataManager,
private val appListRetriever: AppListRetriever,
private val storageBackup: StorageBackup,

View file

@ -15,7 +15,7 @@ abstract class RequireProvisioningViewModel(
protected val app: Application,
protected val settingsManager: SettingsManager,
protected val keyManager: KeyManager,
private val pluginManager: StoragePluginManager,
protected val pluginManager: StoragePluginManager,
) : AndroidViewModel(app) {
abstract val isRestoreOperation: Boolean

View file

@ -29,6 +29,7 @@ internal class ApkBackupManager(
private val settingsManager: SettingsManager,
private val metadataManager: MetadataManager,
private val packageService: PackageService,
private val iconManager: IconManager,
private val apkBackup: ApkBackup,
private val pluginManager: StoragePluginManager,
private val nm: BackupNotificationManager,
@ -44,6 +45,8 @@ internal class ApkBackupManager(
// Since an APK backup does not change the [packageState], we first record it for all
// packages that don't get backed up.
recordNotBackedUpPackages()
// Upload current icons, so we can show them to user before restore
uploadIcons()
// Now, if APK backups are enabled by the user, we back those up.
if (settingsManager.backupApks()) {
backUpApks()
@ -94,6 +97,17 @@ internal class ApkBackupManager(
}
}
private suspend fun uploadIcons() {
try {
val token = settingsManager.getToken() ?: throw IOException("no current token")
pluginManager.appPlugin.getOutputStream(token, FILE_BACKUP_ICONS).use {
iconManager.uploadIcons(it)
}
} catch (e: IOException) {
Log.e(TAG, "Error uploading icons: ", e)
}
}
/**
* Backs up an APK for the given [PackageInfo].
*

View file

@ -0,0 +1,111 @@
/*
* SPDX-FileCopyrightText: 2024 The Calyx Institute
* SPDX-License-Identifier: Apache-2.0
*/
package com.stevesoltys.seedvault.worker
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Bitmap.CompressFormat.WEBP_LOSSY
import android.graphics.BitmapFactory
import android.util.Log
import androidx.appcompat.content.res.AppCompatResources.getDrawable
import androidx.core.graphics.drawable.toBitmap
import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.transport.backup.PackageService
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.util.zip.Deflater.BEST_SPEED
import java.util.zip.ZipEntry
import java.util.zip.ZipInputStream
import java.util.zip.ZipOutputStream
internal const val FILE_BACKUP_ICONS = ".backup.icons"
private const val ICON_SIZE = 128
private const val ICON_QUALITY = 75
private const val CACHE_FOLDER = "restore-icons"
private val TAG = IconManager::class.simpleName
internal class IconManager(
private val context: Context,
private val packageService: PackageService,
) {
@Throws(IOException::class)
fun uploadIcons(outputStream: OutputStream) {
Log.d(TAG, "Start uploading icons")
val packageManager = context.packageManager
ZipOutputStream(outputStream).use { zip ->
zip.setLevel(BEST_SPEED)
packageService.allUserPackages.forEach {
val drawable = packageManager.getApplicationIcon(it.applicationInfo)
if (packageManager.isDefaultApplicationIcon(drawable)) return@forEach
val entry = ZipEntry(it.packageName)
zip.putNextEntry(entry)
drawable.toBitmap(ICON_SIZE, ICON_SIZE).compress(WEBP_LOSSY, ICON_QUALITY, zip)
zip.closeEntry()
}
}
Log.d(TAG, "Finished uploading icons")
}
/**
* Downloads icons file from given [inputStream].
* @return a set of package names for which icons were found
*/
@Throws(IOException::class, SecurityException::class)
fun downloadIcons(inputStream: InputStream): Set<String> {
Log.d(TAG, "Start downloading icons")
val folder = File(context.cacheDir, CACHE_FOLDER)
if (!folder.isDirectory && !folder.mkdirs())
throw IOException("Can't create cache folder for icons")
val set = mutableSetOf<String>()
ZipInputStream(inputStream).use { zip ->
var entry = zip.nextEntry
while (entry != null) {
File(folder, entry.name).outputStream().use { outputStream ->
zip.copyTo(outputStream)
}
set.add(entry.name)
entry = zip.nextEntry
}
}
Log.d(TAG, "Finished downloading icons")
return set
}
private val defaultIcon by lazy {
getDrawable(context, R.drawable.ic_launcher_default)!!.toBitmap()
}
/**
* Tries to load the icons for the given [packageName]
* that was downloaded before with [downloadIcons].
* Calls [callback] on the UiThread with the loaded [Bitmap] or the default icon.
*/
suspend fun loadIcon(packageName: String, callback: (Bitmap) -> Unit) {
try {
withContext(Dispatchers.IO) {
val folder = File(context.cacheDir, CACHE_FOLDER)
val file = File(folder, packageName)
file.inputStream().use { inputStream ->
val bitmap = BitmapFactory.decodeStream(inputStream)
withContext(Dispatchers.Main) {
callback(bitmap)
}
}
}
} catch (e: Exception) {
Log.e(TAG, "Error loading icon for $packageName", e)
withContext(Dispatchers.Main) {
callback(defaultIcon)
}
}
}
}

View file

@ -16,6 +16,12 @@ val workerModule = module {
packageService = get(),
)
}
factory {
IconManager(
context = androidContext(),
packageService = get(),
)
}
single {
ApkBackup(
pm = androidContext().packageManager,
@ -31,6 +37,7 @@ val workerModule = module {
metadataManager = get(),
packageService = get(),
apkBackup = get(),
iconManager = get(),
pluginManager = get(),
nm = get()
)

View file

@ -31,6 +31,7 @@ import io.mockk.verify
import io.mockk.verifyAll
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Test
import java.io.ByteArrayOutputStream
import java.io.IOException
import java.io.OutputStream
@ -38,6 +39,7 @@ internal class ApkBackupManagerTest : TransportTest() {
private val packageService: PackageService = mockk()
private val apkBackup: ApkBackup = mockk()
private val iconManager: IconManager = mockk()
private val storagePluginManager: StoragePluginManager = mockk()
private val plugin: StoragePlugin<*> = mockk()
private val nm: BackupNotificationManager = mockk()
@ -48,6 +50,7 @@ internal class ApkBackupManagerTest : TransportTest() {
metadataManager = metadataManager,
packageService = packageService,
apkBackup = apkBackup,
iconManager = iconManager,
pluginManager = storagePluginManager,
nm = nm,
)
@ -64,6 +67,8 @@ internal class ApkBackupManagerTest : TransportTest() {
every { nm.onAppsNotBackedUp() } just Runs
every { packageService.notBackedUpPackages } returns listOf(packageInfo)
expectUploadIcons()
every {
metadataManager.getPackageMetadata(packageInfo.packageName)
} returns packageMetadata
@ -87,6 +92,8 @@ internal class ApkBackupManagerTest : TransportTest() {
every { nm.onAppsNotBackedUp() } just Runs
every { packageService.notBackedUpPackages } returns listOf(packageInfo)
expectUploadIcons()
every {
metadataManager.getPackageMetadata(packageInfo.packageName)
} returns null
@ -116,6 +123,8 @@ internal class ApkBackupManagerTest : TransportTest() {
every { nm.onAppsNotBackedUp() } just Runs
every { packageService.notBackedUpPackages } returns listOf(packageInfo)
expectUploadIcons()
every {
metadataManager.getPackageMetadata(packageInfo.packageName)
} returns packageMetadata
@ -139,6 +148,8 @@ internal class ApkBackupManagerTest : TransportTest() {
every { nm.onAppsNotBackedUp() } just Runs
every { packageService.notBackedUpPackages } returns listOf(packageInfo)
expectUploadIcons()
every {
metadataManager.getPackageMetadata(packageInfo.packageName)
} returns packageMetadata
@ -167,7 +178,7 @@ internal class ApkBackupManagerTest : TransportTest() {
}
}
)
expectUploadIcons()
expectAllAppsWillGetBackedUp()
every { settingsManager.backupApks() } returns true
@ -207,6 +218,8 @@ internal class ApkBackupManagerTest : TransportTest() {
every { nm.onAppsNotBackedUp() } just Runs
every { packageService.notBackedUpPackages } returns listOf(packageInfo)
expectUploadIcons()
every {
metadataManager.getPackageMetadata(packageInfo.packageName)
} returns packageMetadata
@ -233,6 +246,12 @@ internal class ApkBackupManagerTest : TransportTest() {
}
}
private suspend fun expectUploadIcons() {
val stream = ByteArrayOutputStream()
coEvery { plugin.getOutputStream(token, FILE_BACKUP_ICONS) } returns stream
every { iconManager.uploadIcons(stream) } just Runs
}
private fun expectAllAppsWillGetBackedUp() {
every { nm.onAppsNotBackedUp() } just Runs
every { packageService.notBackedUpPackages } returns emptyList()