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:
parent
905340770c
commit
5a2f1187a8
11 changed files with 257 additions and 13 deletions
|
@ -46,7 +46,19 @@ class KoinInstrumentationTestApp : App() {
|
||||||
|
|
||||||
viewModel {
|
viewModel {
|
||||||
currentRestoreViewModel =
|
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!!
|
currentRestoreViewModel!!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -95,7 +95,19 @@ open class App : Application() {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
viewModel { RestoreStorageViewModel(this@App, get(), get(), get(), get()) }
|
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()) }
|
viewModel { FileSelectionViewModel(this@App, get()) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
package com.stevesoltys.seedvault.restore
|
package com.stevesoltys.seedvault.restore
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
import android.text.format.DateUtils
|
import android.text.format.DateUtils
|
||||||
import android.text.format.Formatter
|
import android.text.format.Formatter
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
|
@ -14,16 +15,22 @@ 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.restore.AppSelectionAdapter.AppSelectionViewHolder
|
||||||
import com.stevesoltys.seedvault.ui.AppViewHolder
|
import com.stevesoltys.seedvault.ui.AppViewHolder
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
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 name: String get() = packageName
|
val name: String get() = packageName
|
||||||
}
|
}
|
||||||
|
|
||||||
internal class AppSelectionAdapter(
|
internal class AppSelectionAdapter(
|
||||||
|
val scope: CoroutineScope,
|
||||||
|
val iconLoader: suspend (String, (Bitmap) -> Unit) -> Unit,
|
||||||
val listener: (SelectableAppItem) -> Unit,
|
val listener: (SelectableAppItem) -> Unit,
|
||||||
) : Adapter<AppSelectionViewHolder>() {
|
) : Adapter<AppSelectionViewHolder>() {
|
||||||
|
|
||||||
|
@ -37,7 +44,7 @@ internal class AppSelectionAdapter(
|
||||||
old: SelectableAppItem,
|
old: SelectableAppItem,
|
||||||
new: SelectableAppItem,
|
new: SelectableAppItem,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
return old.selected == new.selected
|
return old.selected == new.selected && old.hasIcon == new.hasIcon
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
private val differ = AsyncListDiffer(this, diffCallback)
|
private val differ = AsyncListDiffer(this, diffCallback)
|
||||||
|
@ -64,7 +71,14 @@ internal class AppSelectionAdapter(
|
||||||
differ.submitList(items)
|
differ.submitList(items)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onViewRecycled(holder: AppSelectionViewHolder) {
|
||||||
|
holder.iconJob?.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
internal inner class AppSelectionViewHolder(v: View) : AppViewHolder(v) {
|
internal inner class AppSelectionViewHolder(v: View) : AppViewHolder(v) {
|
||||||
|
|
||||||
|
var iconJob: Job? = null
|
||||||
|
|
||||||
fun bind(item: SelectableAppItem) {
|
fun bind(item: SelectableAppItem) {
|
||||||
v.background = clickableBackground
|
v.background = clickableBackground
|
||||||
v.setOnClickListener {
|
v.setOnClickListener {
|
||||||
|
@ -76,9 +90,23 @@ internal class AppSelectionAdapter(
|
||||||
checkBox.setOnCheckedChangeListener { _, _ ->
|
checkBox.setOnCheckedChangeListener { _, _ ->
|
||||||
listener(item)
|
listener(item)
|
||||||
}
|
}
|
||||||
checkBox.visibility = VISIBLE
|
checkBox.visibility = if (item.hasIcon == null) INVISIBLE else VISIBLE
|
||||||
progressBar.visibility = INVISIBLE
|
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)
|
appIcon.setImageResource(R.drawable.ic_launcher_default)
|
||||||
appName.text = item.packageName
|
appName.text = item.packageName
|
||||||
val time = if (item.metadata.time > 0) DateUtils.getRelativeTimeSpanString(
|
val time = if (item.metadata.time > 0) DateUtils.getRelativeTimeSpanString(
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
package com.stevesoltys.seedvault.restore
|
package com.stevesoltys.seedvault.restore
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
|
@ -7,6 +8,7 @@ import android.view.ViewGroup
|
||||||
import android.widget.Button
|
import android.widget.Button
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import com.google.android.material.checkbox.MaterialCheckBox
|
import com.google.android.material.checkbox.MaterialCheckBox
|
||||||
|
@ -18,7 +20,7 @@ class AppSelectionFragment : Fragment() {
|
||||||
private val viewModel: RestoreViewModel by sharedViewModel()
|
private val viewModel: RestoreViewModel by sharedViewModel()
|
||||||
|
|
||||||
private val layoutManager = LinearLayoutManager(context)
|
private val layoutManager = LinearLayoutManager(context)
|
||||||
private val adapter = AppSelectionAdapter { item ->
|
private val adapter = AppSelectionAdapter(lifecycleScope, this::loadIcon) { item ->
|
||||||
viewModel.onAppSelected(item)
|
viewModel.onAppSelected(item)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -64,7 +66,15 @@ class AppSelectionFragment : Fragment() {
|
||||||
viewModel.selectedApps.observe(viewLifecycleOwner) { state ->
|
viewModel.selectedApps.observe(viewLifecycleOwner) { state ->
|
||||||
adapter.submitList(state.apps)
|
adapter.submitList(state.apps)
|
||||||
toggleAllView.isChecked = state.allSelected
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,6 +13,7 @@ import android.app.backup.IRestoreObserver
|
||||||
import android.app.backup.IRestoreSession
|
import android.app.backup.IRestoreSession
|
||||||
import android.app.backup.RestoreSet
|
import android.app.backup.RestoreSet
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.graphics.Bitmap
|
||||||
import android.os.RemoteException
|
import android.os.RemoteException
|
||||||
import android.os.UserHandle
|
import android.os.UserHandle
|
||||||
import android.util.Log
|
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.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.worker.FILE_BACKUP_ICONS
|
||||||
|
import com.stevesoltys.seedvault.worker.IconManager
|
||||||
import com.stevesoltys.seedvault.worker.NUM_PACKAGES_PER_TRANSACTION
|
import com.stevesoltys.seedvault.worker.NUM_PACKAGES_PER_TRANSACTION
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
@ -83,6 +86,7 @@ internal const val PACKAGES_PER_CHUNK = NUM_PACKAGES_PER_TRANSACTION
|
||||||
internal class SelectedAppsState(
|
internal class SelectedAppsState(
|
||||||
val apps: List<SelectableAppItem>,
|
val apps: List<SelectableAppItem>,
|
||||||
val allSelected: Boolean,
|
val allSelected: Boolean,
|
||||||
|
val iconsLoaded: Boolean,
|
||||||
)
|
)
|
||||||
|
|
||||||
internal class RestoreViewModel(
|
internal class RestoreViewModel(
|
||||||
|
@ -92,6 +96,7 @@ internal class RestoreViewModel(
|
||||||
private val backupManager: IBackupManager,
|
private val backupManager: IBackupManager,
|
||||||
private val restoreCoordinator: RestoreCoordinator,
|
private val restoreCoordinator: RestoreCoordinator,
|
||||||
private val apkRestore: ApkRestore,
|
private val apkRestore: ApkRestore,
|
||||||
|
private val iconManager: IconManager,
|
||||||
storageBackup: StorageBackup,
|
storageBackup: StorageBackup,
|
||||||
pluginManager: StoragePluginManager,
|
pluginManager: StoragePluginManager,
|
||||||
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||||
|
@ -175,6 +180,7 @@ internal class RestoreViewModel(
|
||||||
|
|
||||||
override fun onRestorableBackupClicked(restorableBackup: RestorableBackup) {
|
override fun onRestorableBackupClicked(restorableBackup: RestorableBackup) {
|
||||||
mChosenRestorableBackup.value = restorableBackup
|
mChosenRestorableBackup.value = restorableBackup
|
||||||
|
// 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 (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)
|
if (i1.metadata.system == i2.metadata.system) i1.name.compareTo(i2.name, true)
|
||||||
else i1.metadata.system.compareTo(i2.metadata.system)
|
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)
|
mDisplayFragment.setEvent(SELECT_APPS)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun loadIcon(packageName: String, callback: (Bitmap) -> Unit) {
|
||||||
|
iconManager.loadIcon(packageName, callback)
|
||||||
|
}
|
||||||
|
|
||||||
fun onCheckAllAppsClicked() {
|
fun onCheckAllAppsClicked() {
|
||||||
val apps = selectedApps.value?.apps ?: return
|
val apps = selectedApps.value?.apps ?: return
|
||||||
val allSelected = apps.all { it.selected }
|
val allSelected = apps.all { it.selected }
|
||||||
if (allSelected) {
|
if (allSelected) {
|
||||||
// unselect all
|
// unselect all
|
||||||
val newApps = apps.map { if (it.selected) it.copy(selected = false) else it }
|
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 {
|
} else {
|
||||||
// select all
|
// select all
|
||||||
val newApps = apps.map { if (!it.selected) it.copy(selected = true) else it }
|
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
|
allSelected = allSelected && app.selected
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
mSelectedApps.value = SelectedAppsState(apps, allSelected)
|
mSelectedApps.value = SelectedAppsState(apps, allSelected, iconsLoaded = true)
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun onNextClickedAfterSelectingApps() {
|
internal fun onNextClickedAfterSelectingApps() {
|
||||||
|
|
|
@ -69,7 +69,7 @@ internal class SettingsViewModel(
|
||||||
app: Application,
|
app: Application,
|
||||||
settingsManager: SettingsManager,
|
settingsManager: SettingsManager,
|
||||||
keyManager: KeyManager,
|
keyManager: KeyManager,
|
||||||
private val pluginManager: StoragePluginManager,
|
pluginManager: StoragePluginManager,
|
||||||
private val metadataManager: MetadataManager,
|
private val metadataManager: MetadataManager,
|
||||||
private val appListRetriever: AppListRetriever,
|
private val appListRetriever: AppListRetriever,
|
||||||
private val storageBackup: StorageBackup,
|
private val storageBackup: StorageBackup,
|
||||||
|
|
|
@ -15,7 +15,7 @@ abstract class RequireProvisioningViewModel(
|
||||||
protected val app: Application,
|
protected val app: Application,
|
||||||
protected val settingsManager: SettingsManager,
|
protected val settingsManager: SettingsManager,
|
||||||
protected val keyManager: KeyManager,
|
protected val keyManager: KeyManager,
|
||||||
private val pluginManager: StoragePluginManager,
|
protected val pluginManager: StoragePluginManager,
|
||||||
) : AndroidViewModel(app) {
|
) : AndroidViewModel(app) {
|
||||||
|
|
||||||
abstract val isRestoreOperation: Boolean
|
abstract val isRestoreOperation: Boolean
|
||||||
|
|
|
@ -29,6 +29,7 @@ internal class ApkBackupManager(
|
||||||
private val settingsManager: SettingsManager,
|
private val settingsManager: SettingsManager,
|
||||||
private val metadataManager: MetadataManager,
|
private val metadataManager: MetadataManager,
|
||||||
private val packageService: PackageService,
|
private val packageService: PackageService,
|
||||||
|
private val iconManager: IconManager,
|
||||||
private val apkBackup: ApkBackup,
|
private val apkBackup: ApkBackup,
|
||||||
private val pluginManager: StoragePluginManager,
|
private val pluginManager: StoragePluginManager,
|
||||||
private val nm: BackupNotificationManager,
|
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
|
// Since an APK backup does not change the [packageState], we first record it for all
|
||||||
// packages that don't get backed up.
|
// packages that don't get backed up.
|
||||||
recordNotBackedUpPackages()
|
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.
|
// Now, if APK backups are enabled by the user, we back those up.
|
||||||
if (settingsManager.backupApks()) {
|
if (settingsManager.backupApks()) {
|
||||||
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].
|
* Backs up an APK for the given [PackageInfo].
|
||||||
*
|
*
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -16,6 +16,12 @@ val workerModule = module {
|
||||||
packageService = get(),
|
packageService = get(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
factory {
|
||||||
|
IconManager(
|
||||||
|
context = androidContext(),
|
||||||
|
packageService = get(),
|
||||||
|
)
|
||||||
|
}
|
||||||
single {
|
single {
|
||||||
ApkBackup(
|
ApkBackup(
|
||||||
pm = androidContext().packageManager,
|
pm = androidContext().packageManager,
|
||||||
|
@ -31,6 +37,7 @@ val workerModule = module {
|
||||||
metadataManager = get(),
|
metadataManager = get(),
|
||||||
packageService = get(),
|
packageService = get(),
|
||||||
apkBackup = get(),
|
apkBackup = get(),
|
||||||
|
iconManager = get(),
|
||||||
pluginManager = get(),
|
pluginManager = get(),
|
||||||
nm = get()
|
nm = get()
|
||||||
)
|
)
|
||||||
|
|
|
@ -31,6 +31,7 @@ import io.mockk.verify
|
||||||
import io.mockk.verifyAll
|
import io.mockk.verifyAll
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.io.OutputStream
|
import java.io.OutputStream
|
||||||
|
|
||||||
|
@ -38,6 +39,7 @@ internal class ApkBackupManagerTest : TransportTest() {
|
||||||
|
|
||||||
private val packageService: PackageService = mockk()
|
private val packageService: PackageService = mockk()
|
||||||
private val apkBackup: ApkBackup = mockk()
|
private val apkBackup: ApkBackup = mockk()
|
||||||
|
private val iconManager: IconManager = mockk()
|
||||||
private val storagePluginManager: StoragePluginManager = mockk()
|
private val storagePluginManager: StoragePluginManager = mockk()
|
||||||
private val plugin: StoragePlugin<*> = mockk()
|
private val plugin: StoragePlugin<*> = mockk()
|
||||||
private val nm: BackupNotificationManager = mockk()
|
private val nm: BackupNotificationManager = mockk()
|
||||||
|
@ -48,6 +50,7 @@ internal class ApkBackupManagerTest : TransportTest() {
|
||||||
metadataManager = metadataManager,
|
metadataManager = metadataManager,
|
||||||
packageService = packageService,
|
packageService = packageService,
|
||||||
apkBackup = apkBackup,
|
apkBackup = apkBackup,
|
||||||
|
iconManager = iconManager,
|
||||||
pluginManager = storagePluginManager,
|
pluginManager = storagePluginManager,
|
||||||
nm = nm,
|
nm = nm,
|
||||||
)
|
)
|
||||||
|
@ -64,6 +67,8 @@ internal class ApkBackupManagerTest : TransportTest() {
|
||||||
every { nm.onAppsNotBackedUp() } just Runs
|
every { nm.onAppsNotBackedUp() } just Runs
|
||||||
every { packageService.notBackedUpPackages } returns listOf(packageInfo)
|
every { packageService.notBackedUpPackages } returns listOf(packageInfo)
|
||||||
|
|
||||||
|
expectUploadIcons()
|
||||||
|
|
||||||
every {
|
every {
|
||||||
metadataManager.getPackageMetadata(packageInfo.packageName)
|
metadataManager.getPackageMetadata(packageInfo.packageName)
|
||||||
} returns packageMetadata
|
} returns packageMetadata
|
||||||
|
@ -87,6 +92,8 @@ internal class ApkBackupManagerTest : TransportTest() {
|
||||||
every { nm.onAppsNotBackedUp() } just Runs
|
every { nm.onAppsNotBackedUp() } just Runs
|
||||||
every { packageService.notBackedUpPackages } returns listOf(packageInfo)
|
every { packageService.notBackedUpPackages } returns listOf(packageInfo)
|
||||||
|
|
||||||
|
expectUploadIcons()
|
||||||
|
|
||||||
every {
|
every {
|
||||||
metadataManager.getPackageMetadata(packageInfo.packageName)
|
metadataManager.getPackageMetadata(packageInfo.packageName)
|
||||||
} returns null
|
} returns null
|
||||||
|
@ -116,6 +123,8 @@ internal class ApkBackupManagerTest : TransportTest() {
|
||||||
every { nm.onAppsNotBackedUp() } just Runs
|
every { nm.onAppsNotBackedUp() } just Runs
|
||||||
every { packageService.notBackedUpPackages } returns listOf(packageInfo)
|
every { packageService.notBackedUpPackages } returns listOf(packageInfo)
|
||||||
|
|
||||||
|
expectUploadIcons()
|
||||||
|
|
||||||
every {
|
every {
|
||||||
metadataManager.getPackageMetadata(packageInfo.packageName)
|
metadataManager.getPackageMetadata(packageInfo.packageName)
|
||||||
} returns packageMetadata
|
} returns packageMetadata
|
||||||
|
@ -139,6 +148,8 @@ internal class ApkBackupManagerTest : TransportTest() {
|
||||||
every { nm.onAppsNotBackedUp() } just Runs
|
every { nm.onAppsNotBackedUp() } just Runs
|
||||||
every { packageService.notBackedUpPackages } returns listOf(packageInfo)
|
every { packageService.notBackedUpPackages } returns listOf(packageInfo)
|
||||||
|
|
||||||
|
expectUploadIcons()
|
||||||
|
|
||||||
every {
|
every {
|
||||||
metadataManager.getPackageMetadata(packageInfo.packageName)
|
metadataManager.getPackageMetadata(packageInfo.packageName)
|
||||||
} returns packageMetadata
|
} returns packageMetadata
|
||||||
|
@ -167,7 +178,7 @@ internal class ApkBackupManagerTest : TransportTest() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
expectUploadIcons()
|
||||||
expectAllAppsWillGetBackedUp()
|
expectAllAppsWillGetBackedUp()
|
||||||
every { settingsManager.backupApks() } returns true
|
every { settingsManager.backupApks() } returns true
|
||||||
|
|
||||||
|
@ -207,6 +218,8 @@ internal class ApkBackupManagerTest : TransportTest() {
|
||||||
every { nm.onAppsNotBackedUp() } just Runs
|
every { nm.onAppsNotBackedUp() } just Runs
|
||||||
every { packageService.notBackedUpPackages } returns listOf(packageInfo)
|
every { packageService.notBackedUpPackages } returns listOf(packageInfo)
|
||||||
|
|
||||||
|
expectUploadIcons()
|
||||||
|
|
||||||
every {
|
every {
|
||||||
metadataManager.getPackageMetadata(packageInfo.packageName)
|
metadataManager.getPackageMetadata(packageInfo.packageName)
|
||||||
} returns packageMetadata
|
} 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() {
|
private fun expectAllAppsWillGetBackedUp() {
|
||||||
every { nm.onAppsNotBackedUp() } just Runs
|
every { nm.onAppsNotBackedUp() } just Runs
|
||||||
every { packageService.notBackedUpPackages } returns emptyList()
|
every { packageService.notBackedUpPackages } returns emptyList()
|
||||||
|
|
Loading…
Reference in a new issue