diff --git a/app/src/androidTest/java/com/stevesoltys/seedvault/KoinInstrumentationTestApp.kt b/app/src/androidTest/java/com/stevesoltys/seedvault/KoinInstrumentationTestApp.kt index f04319af..60d0ece9 100644 --- a/app/src/androidTest/java/com/stevesoltys/seedvault/KoinInstrumentationTestApp.kt +++ b/app/src/androidTest/java/com/stevesoltys/seedvault/KoinInstrumentationTestApp.kt @@ -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!! } diff --git a/app/src/main/java/com/stevesoltys/seedvault/App.kt b/app/src/main/java/com/stevesoltys/seedvault/App.kt index dc6d8d2d..8daa8eac 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/App.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/App.kt @@ -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()) } } diff --git a/app/src/main/java/com/stevesoltys/seedvault/restore/AppSelectionAdapter.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/AppSelectionAdapter.kt index 4e95a63a..28452f7d 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/restore/AppSelectionAdapter.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/restore/AppSelectionAdapter.kt @@ -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() { @@ -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( diff --git a/app/src/main/java/com/stevesoltys/seedvault/restore/AppSelectionFragment.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/AppSelectionFragment.kt index 72aa3797..fa381f17 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/restore/AppSelectionFragment.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/restore/AppSelectionFragment.kt @@ -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) + } + } diff --git a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreViewModel.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreViewModel.kt index ea055691..4d025c9f 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreViewModel.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreViewModel.kt @@ -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, 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() { diff --git a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsViewModel.kt b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsViewModel.kt index a62ff3f4..f3a71fa0 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/settings/SettingsViewModel.kt @@ -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, diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/RequireProvisioningViewModel.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/RequireProvisioningViewModel.kt index b183948e..505f9944 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/ui/RequireProvisioningViewModel.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/ui/RequireProvisioningViewModel.kt @@ -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 diff --git a/app/src/main/java/com/stevesoltys/seedvault/worker/ApkBackupManager.kt b/app/src/main/java/com/stevesoltys/seedvault/worker/ApkBackupManager.kt index 3dd6f10d..3ebff1f3 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/worker/ApkBackupManager.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/worker/ApkBackupManager.kt @@ -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]. * diff --git a/app/src/main/java/com/stevesoltys/seedvault/worker/IconManager.kt b/app/src/main/java/com/stevesoltys/seedvault/worker/IconManager.kt new file mode 100644 index 00000000..4f5e2b6b --- /dev/null +++ b/app/src/main/java/com/stevesoltys/seedvault/worker/IconManager.kt @@ -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 { + 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() + 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) + } + } + } + +} diff --git a/app/src/main/java/com/stevesoltys/seedvault/worker/WorkerModule.kt b/app/src/main/java/com/stevesoltys/seedvault/worker/WorkerModule.kt index b7ff36de..834fcc30 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/worker/WorkerModule.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/worker/WorkerModule.kt @@ -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() ) diff --git a/app/src/test/java/com/stevesoltys/seedvault/worker/ApkBackupManagerTest.kt b/app/src/test/java/com/stevesoltys/seedvault/worker/ApkBackupManagerTest.kt index dc32063f..1ba643b8 100644 --- a/app/src/test/java/com/stevesoltys/seedvault/worker/ApkBackupManagerTest.kt +++ b/app/src/test/java/com/stevesoltys/seedvault/worker/ApkBackupManagerTest.kt @@ -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()