diff --git a/app/src/androidTest/java/com/stevesoltys/seedvault/worker/IconManagerTest.kt b/app/src/androidTest/java/com/stevesoltys/seedvault/worker/IconManagerTest.kt new file mode 100644 index 00000000..a16413e3 --- /dev/null +++ b/app/src/androidTest/java/com/stevesoltys/seedvault/worker/IconManagerTest.kt @@ -0,0 +1,109 @@ +/* + * SPDX-FileCopyrightText: 2024 The Calyx Institute + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.stevesoltys.seedvault.worker + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import androidx.test.platform.app.InstrumentationRegistry +import com.google.protobuf.ByteString +import com.stevesoltys.seedvault.proto.Snapshot +import com.stevesoltys.seedvault.transport.backup.AppBackupManager +import com.stevesoltys.seedvault.transport.backup.BackupData +import com.stevesoltys.seedvault.transport.backup.BackupReceiver +import com.stevesoltys.seedvault.transport.backup.PackageService +import com.stevesoltys.seedvault.transport.backup.SnapshotCreatorFactory +import com.stevesoltys.seedvault.transport.restore.Loader +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.slot +import junit.framework.TestCase.assertTrue +import kotlinx.coroutines.runBlocking +import org.calyxos.seedvault.core.backends.AppBackupFileType +import org.calyxos.seedvault.core.toHexString +import org.junit.Assert.assertArrayEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import java.io.ByteArrayInputStream +import kotlin.random.Random + +@RunWith(AndroidJUnit4::class) +@MediumTest +class IconManagerTest : KoinComponent { + + private val context = InstrumentationRegistry.getInstrumentation().targetContext + private val packageService by inject() + private val backupReceiver = mockk() + private val loader = mockk() + private val appBackupManager = mockk() + private val snapshotCreatorFactory by inject() + private val snapshotCreator = snapshotCreatorFactory.createSnapshotCreator() + + private val iconManager = IconManager( + context = context, + packageService = packageService, + crypto = mockk(), + backupReceiver = backupReceiver, + loader = loader, + appBackupManager = appBackupManager, + ) + + init { + every { appBackupManager.snapshotCreator } returns snapshotCreator + } + + @Test + fun `test upload and then download`(): Unit = runBlocking { + // prepare output data + val output = slot() + val chunkId = Random.nextBytes(32).toHexString() + val chunkList = listOf(chunkId) + val blobId = Random.nextBytes(32).toHexString() + val blob = Snapshot.Blob.newBuilder().setId(ByteString.fromHex(blobId)).build() + + // upload icons and capture plaintext bytes + coEvery { backupReceiver.addBytes(capture(output)) } just Runs + coEvery { backupReceiver.finalize() } returns BackupData(chunkList, mapOf(chunkId to blob)) + iconManager.uploadIcons() + assertTrue(output.captured.isNotEmpty()) + + // get snapshot and assert it has icon chunks + val snapshot = snapshotCreator.finalizeSnapshot() + assertTrue(snapshot.iconChunkIdsCount > 0) + + // prepare data for downloading icons + val repoId = Random.nextBytes(32).toHexString() + val inputStream = ByteArrayInputStream(output.captured) + coEvery { loader.loadFile(AppBackupFileType.Blob(repoId, blobId)) } returns inputStream + + // download icons and ensure we had an icon for at least one app + val iconSet = iconManager.downloadIcons(repoId, snapshot) + assertTrue(iconSet.isNotEmpty()) + } + + @Test + fun `test upload produces deterministic output`(): Unit = runBlocking { + val output1 = slot() + val output2 = slot() + + coEvery { backupReceiver.addBytes(capture(output1)) } just Runs + coEvery { backupReceiver.finalize() } returns BackupData(emptyList(), emptyMap()) + iconManager.uploadIcons() + assertTrue(output1.captured.isNotEmpty()) + + coEvery { backupReceiver.addBytes(capture(output2)) } just Runs + coEvery { backupReceiver.finalize() } returns BackupData(emptyList(), emptyMap()) + iconManager.uploadIcons() + assertTrue(output2.captured.isNotEmpty()) + + assertArrayEquals(output1.captured, output2.captured) + } + +} diff --git a/app/src/main/java/com/stevesoltys/seedvault/restore/AppSelectionManager.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/AppSelectionManager.kt index 22a0b4ee..f2088fe7 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/restore/AppSelectionManager.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/restore/AppSelectionManager.kt @@ -12,9 +12,9 @@ import androidx.lifecycle.asLiveData import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER import com.stevesoltys.seedvault.NO_DATA_END_SENTINEL import com.stevesoltys.seedvault.R +import com.stevesoltys.seedvault.backend.BackendManager import com.stevesoltys.seedvault.metadata.PackageMetadata import com.stevesoltys.seedvault.metadata.PackageMetadataMap -import com.stevesoltys.seedvault.backend.BackendManager import com.stevesoltys.seedvault.ui.PACKAGE_NAME_SYSTEM import com.stevesoltys.seedvault.ui.systemData import com.stevesoltys.seedvault.worker.IconManager @@ -88,11 +88,19 @@ internal class AppSelectionManager( SelectedAppsState(apps = items, allSelected = isSetupWizard, iconsLoaded = false) // download icons coroutineScope.launch(workDispatcher) { - val backend = backendManager.backend - val token = restorableBackup.token val packagesWithIcons = try { - backend.load(LegacyAppBackupFile.IconsFile(token)).use { - iconManager.downloadIcons(restorableBackup.version, token, it) + if (restorableBackup.version == 1.toByte()) { + val backend = backendManager.backend + val token = restorableBackup.token + backend.load(LegacyAppBackupFile.IconsFile(token)).use { + iconManager.downloadIconsV1(restorableBackup.version, token, it) + } + } else if (restorableBackup.version >= 2) { + val repoId = restorableBackup.repoId ?: error("No repoId in v2 backup") + val snapshot = restorableBackup.snapshot ?: error("No snapshot in v2 backup") + iconManager.downloadIcons(repoId, snapshot) + } else { + emptySet() } } catch (e: Exception) { Log.e(TAG, "Error loading icons:", e) 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 b661a181..8292f1f0 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/worker/ApkBackupManager.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/worker/ApkBackupManager.kt @@ -100,12 +100,8 @@ internal class ApkBackupManager( private suspend fun uploadIcons() { try { - val token = settingsManager.getToken() ?: throw IOException("no current token") - val handle = LegacyAppBackupFile.IconsFile(token) - backendManager.backend.save(handle).use { - iconManager.uploadIcons(token, it) - } - } catch (e: IOException) { + iconManager.uploadIcons() + } catch (e: Exception) { Log.e(TAG, "Error uploading icons: ", e) } } diff --git a/app/src/main/java/com/stevesoltys/seedvault/worker/IconManager.kt b/app/src/main/java/com/stevesoltys/seedvault/worker/IconManager.kt index 821a527d..6165dd69 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/worker/IconManager.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/worker/IconManager.kt @@ -6,7 +6,7 @@ package com.stevesoltys.seedvault.worker import android.content.Context -import android.graphics.Bitmap.CompressFormat.WEBP_LOSSY +import android.graphics.Bitmap.CompressFormat.JPEG import android.graphics.BitmapFactory import android.graphics.drawable.Drawable import android.util.Log @@ -17,23 +17,29 @@ import androidx.core.graphics.drawable.toDrawable import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.crypto.Crypto import com.stevesoltys.seedvault.crypto.TYPE_ICONS -import com.stevesoltys.seedvault.header.VERSION +import com.stevesoltys.seedvault.proto.Snapshot +import com.stevesoltys.seedvault.transport.backup.AppBackupManager +import com.stevesoltys.seedvault.transport.backup.BackupReceiver import com.stevesoltys.seedvault.transport.backup.PackageService +import com.stevesoltys.seedvault.transport.restore.Loader import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.calyxos.backup.storage.crypto.StreamCrypto.toByteArray +import org.calyxos.seedvault.core.backends.AppBackupFileType +import org.calyxos.seedvault.core.toHexString +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream import java.io.File import java.io.IOException import java.io.InputStream -import java.io.OutputStream import java.nio.ByteBuffer +import java.nio.file.attribute.FileTime import java.security.GeneralSecurityException -import java.util.zip.Deflater.BEST_SPEED +import java.util.zip.Deflater.NO_COMPRESSION 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" @@ -43,47 +49,100 @@ internal class IconManager( private val context: Context, private val packageService: PackageService, private val crypto: Crypto, + private val backupReceiver: BackupReceiver, + private val loader: Loader, + private val appBackupManager: AppBackupManager, ) { + private val snapshotCreator + get() = appBackupManager.snapshotCreator ?: error("No SnapshotCreator") + @Throws(IOException::class, GeneralSecurityException::class) - fun uploadIcons(token: Long, outputStream: OutputStream) { + suspend fun uploadIcons() { Log.d(TAG, "Start uploading icons") val packageManager = context.packageManager - crypto.newEncryptingStreamV1(outputStream, getAD(VERSION, token)).use { cryptoStream -> - ZipOutputStream(cryptoStream).use { zip -> - zip.setLevel(BEST_SPEED) - val entries = mutableSetOf() - packageService.allUserPackages.forEach { - val applicationInfo = it.applicationInfo ?: return@forEach - val drawable = packageManager.getApplicationIcon(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) - entries.add(it.packageName) - zip.closeEntry() + val byteArrayOutputStream = ByteArrayOutputStream() + ZipOutputStream(byteArrayOutputStream).use { zip -> + zip.setLevel(NO_COMPRESSION) // we compress with zstd after chunking the zip + val entries = mutableSetOf() + // sort packages by package name to get deterministic ZIP + packageService.allUserPackages.sortedBy { it.packageName }.forEach { + val applicationInfo = it.applicationInfo ?: return@forEach + val drawable = packageManager.getApplicationIcon(applicationInfo) + if (packageManager.isDefaultApplicationIcon(drawable)) return@forEach + val entry = ZipEntry(it.packageName).apply { + // needed to be deterministic + setLastModifiedTime(FileTime.fromMillis(0)) } - packageService.launchableSystemApps.forEach { - val drawable = it.loadIcon(packageManager) - if (packageManager.isDefaultApplicationIcon(drawable)) return@forEach - // check for duplicates (e.g. updated launchable system app) - if (it.activityInfo.packageName in entries) return@forEach - val entry = ZipEntry(it.activityInfo.packageName) - zip.putNextEntry(entry) - drawable.toBitmap(ICON_SIZE, ICON_SIZE).compress(WEBP_LOSSY, ICON_QUALITY, zip) - zip.closeEntry() + zip.putNextEntry(entry) + // WEBP_LOSSY compression wasn't deterministic in our tests, so use JPEG + drawable.toBitmap(ICON_SIZE, ICON_SIZE).compress(JPEG, ICON_QUALITY, zip) + entries.add(it.packageName) + zip.closeEntry() + } + // sort packages by package name to get deterministic ZIP + packageService.launchableSystemApps.sortedBy { it.activityInfo.packageName }.forEach { + val drawable = it.loadIcon(packageManager) + if (packageManager.isDefaultApplicationIcon(drawable)) return@forEach + // check for duplicates (e.g. updated launchable system app) + if (it.activityInfo.packageName in entries) return@forEach + val entry = ZipEntry(it.activityInfo.packageName).apply { + // needed to be deterministic + setLastModifiedTime(FileTime.fromMillis(0)) } + zip.putNextEntry(entry) + // WEBP_LOSSY compression wasn't deterministic in our tests, so use JPEG + drawable.toBitmap(ICON_SIZE, ICON_SIZE).compress(JPEG, ICON_QUALITY, zip) + zip.closeEntry() } } + backupReceiver.addBytes(byteArrayOutputStream.toByteArray()) + val backupData = backupReceiver.finalize() + snapshotCreator.onIconsBackedUp(backupData) Log.d(TAG, "Finished uploading icons") } + /** + * Downloads icons file from given [snapshot] from the repository with [repoId]. + * @return a set of package names for which icons were found + */ + @Throws(IOException::class, SecurityException::class, GeneralSecurityException::class) + suspend fun downloadIcons(repoId: String, snapshot: Snapshot): 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 outputStream = ByteArrayOutputStream() + snapshot.iconChunkIdsList.forEach { + val blob = snapshot.getBlobsOrThrow(it.toByteArray().toHexString()) + val handle = AppBackupFileType.Blob(repoId, blob.id.toByteArray().toHexString()) + loader.loadFile(handle).use { inputStream -> + inputStream.copyTo(outputStream) + } + } + val set = mutableSetOf() + ZipInputStream(ByteArrayInputStream(outputStream.toByteArray())).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 + } + /** * Downloads icons file from given [inputStream]. * @return a set of package names for which icons were found */ + @Suppress("DEPRECATION") @Throws(IOException::class, SecurityException::class, GeneralSecurityException::class) - fun downloadIcons(version: Byte, token: Long, inputStream: InputStream): Set { + fun downloadIconsV1(version: Byte, token: Long, inputStream: InputStream): Set { Log.d(TAG, "Start downloading icons") val folder = File(context.cacheDir, CACHE_FOLDER) if (!folder.isDirectory && !folder.mkdirs()) 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 a41d1155..9583724e 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/worker/WorkerModule.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/worker/WorkerModule.kt @@ -22,6 +22,9 @@ val workerModule = module { context = androidContext(), packageService = get(), crypto = get(), + backupReceiver = get(), + loader = get(), + appBackupManager = get(), ) } single { AppBackupManager(get(), get(), get()) } diff --git a/app/src/test/java/com/stevesoltys/seedvault/restore/AppSelectionManagerTest.kt b/app/src/test/java/com/stevesoltys/seedvault/restore/AppSelectionManagerTest.kt index 4a51fdd0..f06c33ad 100644 --- a/app/src/test/java/com/stevesoltys/seedvault/restore/AppSelectionManagerTest.kt +++ b/app/src/test/java/com/stevesoltys/seedvault/restore/AppSelectionManagerTest.kt @@ -9,11 +9,12 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import app.cash.turbine.TurbineTestContext import app.cash.turbine.test import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER +import com.stevesoltys.seedvault.backend.BackendManager import com.stevesoltys.seedvault.getRandomString import com.stevesoltys.seedvault.metadata.BackupMetadata import com.stevesoltys.seedvault.metadata.PackageMetadata import com.stevesoltys.seedvault.metadata.PackageMetadataMap -import com.stevesoltys.seedvault.backend.BackendManager +import com.stevesoltys.seedvault.proto.Snapshot import com.stevesoltys.seedvault.transport.TransportTest import com.stevesoltys.seedvault.ui.PACKAGE_NAME_CONTACTS import com.stevesoltys.seedvault.ui.PACKAGE_NAME_SETTINGS @@ -28,6 +29,7 @@ import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest import org.calyxos.seedvault.core.backends.Backend import org.calyxos.seedvault.core.backends.LegacyAppBackupFile +import org.calyxos.seedvault.core.toHexString import org.junit.Test import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse @@ -36,7 +38,6 @@ import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Assertions.fail import org.junit.runner.RunWith import org.robolectric.annotation.Config -import java.io.ByteArrayInputStream import java.io.IOException import kotlin.random.Random @@ -60,6 +61,12 @@ internal class AppSelectionManagerTest : TransportTest() { token = Random.nextLong(), salt = getRandomString(), ) + private val repoId = Random.nextBytes(32).toHexString() + private val snapshot = Snapshot.newBuilder() + .setToken(token) + .putApps(packageInfo.packageName, Snapshot.App.getDefaultInstance()) + .putAllBlobs(emptyMap()) + .build() private val appSelectionManager = AppSelectionManager( context = context, @@ -70,7 +77,8 @@ internal class AppSelectionManagerTest : TransportTest() { ) @Test - fun `apps without backup and APK, as well as system apps are filtered out`() = runTest { + fun `apps without backup and APK, as well as system apps are filtered out`() = scope.runTest { + coEvery { iconManager.downloadIcons(repoId, snapshot) } returns emptySet() appSelectionManager.selectedAppsFlow.test { val initialState = awaitItem() assertEquals(emptyList(), initialState.apps) @@ -95,11 +103,15 @@ internal class AppSelectionManagerTest : TransportTest() { assertEquals(PACKAGE_NAME_SYSTEM, initialApps.apps[0].packageName) assertTrue(initialApps.allSelected) assertFalse(initialApps.iconsLoaded) + + // now icons have loaded and apps were updated + awaitItem() } } @Test - fun `apps get sorted by name, special items on top`() = runTest { + fun `apps get sorted by name, special items on top`() = scope.runTest { + coEvery { iconManager.downloadIcons(repoId, snapshot) } returns emptySet() appSelectionManager.selectedAppsFlow.test { awaitItem() @@ -128,11 +140,17 @@ internal class AppSelectionManagerTest : TransportTest() { assertEquals(PACKAGE_NAME_SYSTEM, initialApps.apps[1].packageName) assertEquals(packageName2, initialApps.apps[2].packageName) assertEquals(packageName1, initialApps.apps[3].packageName) + + // now icons have loaded and apps were updated + awaitItem() } } @Test - fun `test app selection`() = runTest { + fun `test app selection`() = scope.runTest { + coEvery { + iconManager.downloadIcons(repoId, snapshot) + } returns setOf(packageName1, packageName2) appSelectionManager.selectedAppsFlow.test { awaitItem() @@ -150,6 +168,9 @@ internal class AppSelectionManagerTest : TransportTest() { initialApps.apps.forEach { assertTrue(it.selected) } assertTrue(initialApps.allSelected) + // now icons have loaded and apps were updated + awaitItem() + // deselect last app in list appSelectionManager.onAppSelected(initialApps.apps[2]) val oneDeselected = awaitItem() @@ -184,7 +205,9 @@ internal class AppSelectionManagerTest : TransportTest() { @Test fun `test icon loading`() = scope.runTest { - expectIconLoading(setOf(packageName1)) // only icons found for packageName1 + coEvery { + iconManager.downloadIcons(repoId, snapshot) + } returns setOf(packageName1) // only icons found for packageName1 appSelectionManager.selectedAppsFlow.test { awaitItem() @@ -252,7 +275,7 @@ internal class AppSelectionManagerTest : TransportTest() { } @Test - fun `finishing selection filters unselected apps, leaves system apps`() = runTest { + fun `finishing selection filters unselected apps, leaves system apps`() = scope.runTest { testFiltering { backup -> val itemsWithIcons = awaitItem() @@ -287,48 +310,50 @@ internal class AppSelectionManagerTest : TransportTest() { } @Test - fun `finishing selection without system apps only removes non-special system apps`() = runTest { - testFiltering { backup -> - val itemsWithIcons = awaitItem() + fun `finishing selection without system apps only removes non-special system apps`() = + scope.runTest { + testFiltering { backup -> + val itemsWithIcons = awaitItem() - // unselect all system apps and settings, contacts should stay - val systemMeta = itemsWithIcons.apps.find { it.packageName == PACKAGE_NAME_SYSTEM } - ?: fail() - val settings = itemsWithIcons.apps.find { it.packageName == PACKAGE_NAME_SETTINGS } - ?: fail() - appSelectionManager.onAppSelected(systemMeta) - awaitItem() - appSelectionManager.onAppSelected(settings) + // unselect all system apps and settings, contacts should stay + val systemMeta = itemsWithIcons.apps.find { it.packageName == PACKAGE_NAME_SYSTEM } + ?: fail() + val settings = itemsWithIcons.apps.find { it.packageName == PACKAGE_NAME_SETTINGS } + ?: fail() + appSelectionManager.onAppSelected(systemMeta) + awaitItem() + appSelectionManager.onAppSelected(settings) - // assert that both apps are unselected - val finalSelection = awaitItem() - // we have 6 real apps (two are hidden) plus system meta item, makes 5 - assertEquals(5, finalSelection.apps.size) - finalSelection.apps.forEach { - if (it.packageName in listOf(PACKAGE_NAME_SYSTEM, PACKAGE_NAME_SETTINGS)) { - assertFalse(it.selected) - } else { - assertTrue(it.selected) + // assert that both apps are unselected + val finalSelection = awaitItem() + // we have 6 real apps (two are hidden) plus system meta item, makes 5 + assertEquals(5, finalSelection.apps.size) + finalSelection.apps.forEach { + if (it.packageName in listOf(PACKAGE_NAME_SYSTEM, PACKAGE_NAME_SETTINGS)) { + assertFalse(it.selected) + } else { + assertTrue(it.selected) + } } - } - // 4 apps should survive: app1, app2, app4 (hidden) and contacts - val filteredBackup = appSelectionManager.onAppSelectionFinished(backup) - assertEquals(4, filteredBackup.packageMetadataMap.size) - assertEquals( - setOf(packageName1, packageName2, packageName4, PACKAGE_NAME_CONTACTS), - filteredBackup.packageMetadataMap.keys, - ) + // 4 apps should survive: app1, app2, app4 (hidden) and contacts + val filteredBackup = appSelectionManager.onAppSelectionFinished(backup) + assertEquals(4, filteredBackup.packageMetadataMap.size) + assertEquals( + setOf(packageName1, packageName2, packageName4, PACKAGE_NAME_CONTACTS), + filteredBackup.packageMetadataMap.keys, + ) + } } - } @Test - fun `system apps only pre-selected in setup wizard`() = runTest { + fun `system apps only pre-selected in setup wizard`() = scope.runTest { val backup = getRestorableBackup( mutableMapOf( packageName1 to PackageMetadata(system = true, isLaunchableSystemApp = false), ) ) + coEvery { iconManager.downloadIcons(repoId, snapshot) } returns (emptySet()) // choose restore set in setup wizard appSelectionManager.selectedAppsFlow.test { awaitItem() @@ -338,6 +363,9 @@ internal class AppSelectionManagerTest : TransportTest() { assertEquals(1, initialApps.apps.size) assertEquals(PACKAGE_NAME_SYSTEM, initialApps.apps[0].packageName) assertTrue(initialApps.apps[0].selected) // system settings is selected + + // now icons have loaded and apps were updated + awaitItem() } appSelectionManager.selectedAppsFlow.test { awaitItem() @@ -347,11 +375,15 @@ internal class AppSelectionManagerTest : TransportTest() { assertEquals(1, initialApps.apps.size) assertEquals(PACKAGE_NAME_SYSTEM, initialApps.apps[0].packageName) assertFalse(initialApps.apps[0].selected) // system settings is NOT selected + + // now icons have loaded and apps were updated + awaitItem() } } @Test - fun `@pm@ doesn't get filtered out`() = runTest { + fun `@pm@ doesn't get filtered out`() = scope.runTest { + coEvery { iconManager.downloadIcons(repoId, snapshot) } returns emptySet() appSelectionManager.selectedAppsFlow.test { awaitItem() @@ -370,6 +402,9 @@ internal class AppSelectionManagerTest : TransportTest() { assertEquals(1, initialApps.apps.size) assertEquals(PACKAGE_NAME_SYSTEM, initialApps.apps[0].packageName) + // now icons have loaded and apps were updated + awaitItem() + // actual filtered backup includes @pm@ only val filteredBackup = appSelectionManager.onAppSelectionFinished(backup) assertEquals(1, filteredBackup.packageMetadataMap.size) @@ -380,14 +415,21 @@ internal class AppSelectionManagerTest : TransportTest() { } } - private fun getRestorableBackup(map: Map): RestorableBackup { - return RestorableBackup(backupMetadata.copy(packageMetadataMap = map as PackageMetadataMap)) - } + private fun getRestorableBackup(map: Map) = RestorableBackup( + backupMetadata = backupMetadata.copy( + version = 2, + packageMetadataMap = map as PackageMetadataMap, + ), + repoId = repoId, + snapshot = snapshot, + ) private suspend fun testFiltering( block: suspend TurbineTestContext.(RestorableBackup) -> Unit, ) { - expectIconLoading() + coEvery { + iconManager.downloadIcons(repoId, snapshot) + } returns setOf(packageName1, packageName2) appSelectionManager.selectedAppsFlow.test { awaitItem() @@ -425,17 +467,4 @@ internal class AppSelectionManagerTest : TransportTest() { block(backup) } } - - private fun expectIconLoading(icons: Set = setOf(packageName1, packageName2)) { - val backend: Backend = mockk() - val inputStream = ByteArrayInputStream(Random.nextBytes(42)) - every { backendManager.backend } returns backend - coEvery { - backend.load(LegacyAppBackupFile.IconsFile(backupMetadata.token)) - } returns inputStream - every { - iconManager.downloadIcons(backupMetadata.version, backupMetadata.token, inputStream) - } returns icons - } - }