Back up app icons in new v2 format

We still support downloading in v1 format for some time.
This commit is contained in:
Torsten Grote 2024-09-06 09:41:20 -03:00
parent 1efa8e8f59
commit e17c98857f
No known key found for this signature in database
GPG key ID: 3E5F77D92CF891FF
6 changed files with 299 additions and 95 deletions

View file

@ -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<PackageService>()
private val backupReceiver = mockk<BackupReceiver>()
private val loader = mockk<Loader>()
private val appBackupManager = mockk<AppBackupManager>()
private val snapshotCreatorFactory by inject<SnapshotCreatorFactory>()
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<ByteArray>()
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<ByteArray>()
val output2 = slot<ByteArray>()
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)
}
}

View file

@ -12,9 +12,9 @@ import androidx.lifecycle.asLiveData
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
import com.stevesoltys.seedvault.NO_DATA_END_SENTINEL import com.stevesoltys.seedvault.NO_DATA_END_SENTINEL
import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.backend.BackendManager
import com.stevesoltys.seedvault.metadata.PackageMetadata import com.stevesoltys.seedvault.metadata.PackageMetadata
import com.stevesoltys.seedvault.metadata.PackageMetadataMap 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.PACKAGE_NAME_SYSTEM
import com.stevesoltys.seedvault.ui.systemData import com.stevesoltys.seedvault.ui.systemData
import com.stevesoltys.seedvault.worker.IconManager import com.stevesoltys.seedvault.worker.IconManager
@ -88,11 +88,19 @@ internal class AppSelectionManager(
SelectedAppsState(apps = items, allSelected = isSetupWizard, iconsLoaded = false) SelectedAppsState(apps = items, allSelected = isSetupWizard, iconsLoaded = false)
// download icons // download icons
coroutineScope.launch(workDispatcher) { coroutineScope.launch(workDispatcher) {
val backend = backendManager.backend
val token = restorableBackup.token
val packagesWithIcons = try { val packagesWithIcons = try {
backend.load(LegacyAppBackupFile.IconsFile(token)).use { if (restorableBackup.version == 1.toByte()) {
iconManager.downloadIcons(restorableBackup.version, token, it) 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) { } catch (e: Exception) {
Log.e(TAG, "Error loading icons:", e) Log.e(TAG, "Error loading icons:", e)

View file

@ -100,12 +100,8 @@ internal class ApkBackupManager(
private suspend fun uploadIcons() { private suspend fun uploadIcons() {
try { try {
val token = settingsManager.getToken() ?: throw IOException("no current token") iconManager.uploadIcons()
val handle = LegacyAppBackupFile.IconsFile(token) } catch (e: Exception) {
backendManager.backend.save(handle).use {
iconManager.uploadIcons(token, it)
}
} catch (e: IOException) {
Log.e(TAG, "Error uploading icons: ", e) Log.e(TAG, "Error uploading icons: ", e)
} }
} }

View file

@ -6,7 +6,7 @@
package com.stevesoltys.seedvault.worker package com.stevesoltys.seedvault.worker
import android.content.Context import android.content.Context
import android.graphics.Bitmap.CompressFormat.WEBP_LOSSY import android.graphics.Bitmap.CompressFormat.JPEG
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import android.util.Log import android.util.Log
@ -17,23 +17,29 @@ import androidx.core.graphics.drawable.toDrawable
import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.crypto.Crypto import com.stevesoltys.seedvault.crypto.Crypto
import com.stevesoltys.seedvault.crypto.TYPE_ICONS 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.backup.PackageService
import com.stevesoltys.seedvault.transport.restore.Loader
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.calyxos.backup.storage.crypto.StreamCrypto.toByteArray 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.File
import java.io.IOException import java.io.IOException
import java.io.InputStream import java.io.InputStream
import java.io.OutputStream
import java.nio.ByteBuffer import java.nio.ByteBuffer
import java.nio.file.attribute.FileTime
import java.security.GeneralSecurityException 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.ZipEntry
import java.util.zip.ZipInputStream import java.util.zip.ZipInputStream
import java.util.zip.ZipOutputStream import java.util.zip.ZipOutputStream
internal const val FILE_BACKUP_ICONS = ".backup.icons"
private const val ICON_SIZE = 128 private const val ICON_SIZE = 128
private const val ICON_QUALITY = 75 private const val ICON_QUALITY = 75
private const val CACHE_FOLDER = "restore-icons" private const val CACHE_FOLDER = "restore-icons"
@ -43,47 +49,100 @@ internal class IconManager(
private val context: Context, private val context: Context,
private val packageService: PackageService, private val packageService: PackageService,
private val crypto: Crypto, 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) @Throws(IOException::class, GeneralSecurityException::class)
fun uploadIcons(token: Long, outputStream: OutputStream) { suspend fun uploadIcons() {
Log.d(TAG, "Start uploading icons") Log.d(TAG, "Start uploading icons")
val packageManager = context.packageManager val packageManager = context.packageManager
crypto.newEncryptingStreamV1(outputStream, getAD(VERSION, token)).use { cryptoStream -> val byteArrayOutputStream = ByteArrayOutputStream()
ZipOutputStream(cryptoStream).use { zip -> ZipOutputStream(byteArrayOutputStream).use { zip ->
zip.setLevel(BEST_SPEED) zip.setLevel(NO_COMPRESSION) // we compress with zstd after chunking the zip
val entries = mutableSetOf<String>() val entries = mutableSetOf<String>()
packageService.allUserPackages.forEach { // sort packages by package name to get deterministic ZIP
val applicationInfo = it.applicationInfo ?: return@forEach packageService.allUserPackages.sortedBy { it.packageName }.forEach {
val drawable = packageManager.getApplicationIcon(applicationInfo) val applicationInfo = it.applicationInfo ?: return@forEach
if (packageManager.isDefaultApplicationIcon(drawable)) return@forEach val drawable = packageManager.getApplicationIcon(applicationInfo)
val entry = ZipEntry(it.packageName) if (packageManager.isDefaultApplicationIcon(drawable)) return@forEach
zip.putNextEntry(entry) val entry = ZipEntry(it.packageName).apply {
drawable.toBitmap(ICON_SIZE, ICON_SIZE).compress(WEBP_LOSSY, ICON_QUALITY, zip) // needed to be deterministic
entries.add(it.packageName) setLastModifiedTime(FileTime.fromMillis(0))
zip.closeEntry()
} }
packageService.launchableSystemApps.forEach { zip.putNextEntry(entry)
val drawable = it.loadIcon(packageManager) // WEBP_LOSSY compression wasn't deterministic in our tests, so use JPEG
if (packageManager.isDefaultApplicationIcon(drawable)) return@forEach drawable.toBitmap(ICON_SIZE, ICON_SIZE).compress(JPEG, ICON_QUALITY, zip)
// check for duplicates (e.g. updated launchable system app) entries.add(it.packageName)
if (it.activityInfo.packageName in entries) return@forEach zip.closeEntry()
val entry = ZipEntry(it.activityInfo.packageName) }
zip.putNextEntry(entry) // sort packages by package name to get deterministic ZIP
drawable.toBitmap(ICON_SIZE, ICON_SIZE).compress(WEBP_LOSSY, ICON_QUALITY, zip) packageService.launchableSystemApps.sortedBy { it.activityInfo.packageName }.forEach {
zip.closeEntry() 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") 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<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 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<String>()
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]. * Downloads icons file from given [inputStream].
* @return a set of package names for which icons were found * @return a set of package names for which icons were found
*/ */
@Suppress("DEPRECATION")
@Throws(IOException::class, SecurityException::class, GeneralSecurityException::class) @Throws(IOException::class, SecurityException::class, GeneralSecurityException::class)
fun downloadIcons(version: Byte, token: Long, inputStream: InputStream): Set<String> { fun downloadIconsV1(version: Byte, token: Long, inputStream: InputStream): Set<String> {
Log.d(TAG, "Start downloading icons") Log.d(TAG, "Start downloading icons")
val folder = File(context.cacheDir, CACHE_FOLDER) val folder = File(context.cacheDir, CACHE_FOLDER)
if (!folder.isDirectory && !folder.mkdirs()) if (!folder.isDirectory && !folder.mkdirs())

View file

@ -22,6 +22,9 @@ val workerModule = module {
context = androidContext(), context = androidContext(),
packageService = get(), packageService = get(),
crypto = get(), crypto = get(),
backupReceiver = get(),
loader = get(),
appBackupManager = get(),
) )
} }
single { AppBackupManager(get(), get(), get()) } single { AppBackupManager(get(), get(), get()) }

View file

@ -9,11 +9,12 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import app.cash.turbine.TurbineTestContext import app.cash.turbine.TurbineTestContext
import app.cash.turbine.test import app.cash.turbine.test
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
import com.stevesoltys.seedvault.backend.BackendManager
import com.stevesoltys.seedvault.getRandomString import com.stevesoltys.seedvault.getRandomString
import com.stevesoltys.seedvault.metadata.BackupMetadata import com.stevesoltys.seedvault.metadata.BackupMetadata
import com.stevesoltys.seedvault.metadata.PackageMetadata import com.stevesoltys.seedvault.metadata.PackageMetadata
import com.stevesoltys.seedvault.metadata.PackageMetadataMap 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.transport.TransportTest
import com.stevesoltys.seedvault.ui.PACKAGE_NAME_CONTACTS import com.stevesoltys.seedvault.ui.PACKAGE_NAME_CONTACTS
import com.stevesoltys.seedvault.ui.PACKAGE_NAME_SETTINGS import com.stevesoltys.seedvault.ui.PACKAGE_NAME_SETTINGS
@ -28,6 +29,7 @@ import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.calyxos.seedvault.core.backends.Backend import org.calyxos.seedvault.core.backends.Backend
import org.calyxos.seedvault.core.backends.LegacyAppBackupFile import org.calyxos.seedvault.core.backends.LegacyAppBackupFile
import org.calyxos.seedvault.core.toHexString
import org.junit.Test import org.junit.Test
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse 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.jupiter.api.Assertions.fail
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.robolectric.annotation.Config import org.robolectric.annotation.Config
import java.io.ByteArrayInputStream
import java.io.IOException import java.io.IOException
import kotlin.random.Random import kotlin.random.Random
@ -60,6 +61,12 @@ internal class AppSelectionManagerTest : TransportTest() {
token = Random.nextLong(), token = Random.nextLong(),
salt = getRandomString(), 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( private val appSelectionManager = AppSelectionManager(
context = context, context = context,
@ -70,7 +77,8 @@ internal class AppSelectionManagerTest : TransportTest() {
) )
@Test @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 { appSelectionManager.selectedAppsFlow.test {
val initialState = awaitItem() val initialState = awaitItem()
assertEquals(emptyList<SelectableAppItem>(), initialState.apps) assertEquals(emptyList<SelectableAppItem>(), initialState.apps)
@ -95,11 +103,15 @@ internal class AppSelectionManagerTest : TransportTest() {
assertEquals(PACKAGE_NAME_SYSTEM, initialApps.apps[0].packageName) assertEquals(PACKAGE_NAME_SYSTEM, initialApps.apps[0].packageName)
assertTrue(initialApps.allSelected) assertTrue(initialApps.allSelected)
assertFalse(initialApps.iconsLoaded) assertFalse(initialApps.iconsLoaded)
// now icons have loaded and apps were updated
awaitItem()
} }
} }
@Test @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 { appSelectionManager.selectedAppsFlow.test {
awaitItem() awaitItem()
@ -128,11 +140,17 @@ internal class AppSelectionManagerTest : TransportTest() {
assertEquals(PACKAGE_NAME_SYSTEM, initialApps.apps[1].packageName) assertEquals(PACKAGE_NAME_SYSTEM, initialApps.apps[1].packageName)
assertEquals(packageName2, initialApps.apps[2].packageName) assertEquals(packageName2, initialApps.apps[2].packageName)
assertEquals(packageName1, initialApps.apps[3].packageName) assertEquals(packageName1, initialApps.apps[3].packageName)
// now icons have loaded and apps were updated
awaitItem()
} }
} }
@Test @Test
fun `test app selection`() = runTest { fun `test app selection`() = scope.runTest {
coEvery {
iconManager.downloadIcons(repoId, snapshot)
} returns setOf(packageName1, packageName2)
appSelectionManager.selectedAppsFlow.test { appSelectionManager.selectedAppsFlow.test {
awaitItem() awaitItem()
@ -150,6 +168,9 @@ internal class AppSelectionManagerTest : TransportTest() {
initialApps.apps.forEach { assertTrue(it.selected) } initialApps.apps.forEach { assertTrue(it.selected) }
assertTrue(initialApps.allSelected) assertTrue(initialApps.allSelected)
// now icons have loaded and apps were updated
awaitItem()
// deselect last app in list // deselect last app in list
appSelectionManager.onAppSelected(initialApps.apps[2]) appSelectionManager.onAppSelected(initialApps.apps[2])
val oneDeselected = awaitItem() val oneDeselected = awaitItem()
@ -184,7 +205,9 @@ internal class AppSelectionManagerTest : TransportTest() {
@Test @Test
fun `test icon loading`() = scope.runTest { 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 { appSelectionManager.selectedAppsFlow.test {
awaitItem() awaitItem()
@ -252,7 +275,7 @@ internal class AppSelectionManagerTest : TransportTest() {
} }
@Test @Test
fun `finishing selection filters unselected apps, leaves system apps`() = runTest { fun `finishing selection filters unselected apps, leaves system apps`() = scope.runTest {
testFiltering { backup -> testFiltering { backup ->
val itemsWithIcons = awaitItem() val itemsWithIcons = awaitItem()
@ -287,48 +310,50 @@ internal class AppSelectionManagerTest : TransportTest() {
} }
@Test @Test
fun `finishing selection without system apps only removes non-special system apps`() = runTest { fun `finishing selection without system apps only removes non-special system apps`() =
testFiltering { backup -> scope.runTest {
val itemsWithIcons = awaitItem() testFiltering { backup ->
val itemsWithIcons = awaitItem()
// unselect all system apps and settings, contacts should stay // unselect all system apps and settings, contacts should stay
val systemMeta = itemsWithIcons.apps.find { it.packageName == PACKAGE_NAME_SYSTEM } val systemMeta = itemsWithIcons.apps.find { it.packageName == PACKAGE_NAME_SYSTEM }
?: fail() ?: fail()
val settings = itemsWithIcons.apps.find { it.packageName == PACKAGE_NAME_SETTINGS } val settings = itemsWithIcons.apps.find { it.packageName == PACKAGE_NAME_SETTINGS }
?: fail() ?: fail()
appSelectionManager.onAppSelected(systemMeta) appSelectionManager.onAppSelected(systemMeta)
awaitItem() awaitItem()
appSelectionManager.onAppSelected(settings) appSelectionManager.onAppSelected(settings)
// assert that both apps are unselected // assert that both apps are unselected
val finalSelection = awaitItem() val finalSelection = awaitItem()
// we have 6 real apps (two are hidden) plus system meta item, makes 5 // we have 6 real apps (two are hidden) plus system meta item, makes 5
assertEquals(5, finalSelection.apps.size) assertEquals(5, finalSelection.apps.size)
finalSelection.apps.forEach { finalSelection.apps.forEach {
if (it.packageName in listOf(PACKAGE_NAME_SYSTEM, PACKAGE_NAME_SETTINGS)) { if (it.packageName in listOf(PACKAGE_NAME_SYSTEM, PACKAGE_NAME_SETTINGS)) {
assertFalse(it.selected) assertFalse(it.selected)
} else { } else {
assertTrue(it.selected) assertTrue(it.selected)
}
} }
}
// 4 apps should survive: app1, app2, app4 (hidden) and contacts // 4 apps should survive: app1, app2, app4 (hidden) and contacts
val filteredBackup = appSelectionManager.onAppSelectionFinished(backup) val filteredBackup = appSelectionManager.onAppSelectionFinished(backup)
assertEquals(4, filteredBackup.packageMetadataMap.size) assertEquals(4, filteredBackup.packageMetadataMap.size)
assertEquals( assertEquals(
setOf(packageName1, packageName2, packageName4, PACKAGE_NAME_CONTACTS), setOf(packageName1, packageName2, packageName4, PACKAGE_NAME_CONTACTS),
filteredBackup.packageMetadataMap.keys, filteredBackup.packageMetadataMap.keys,
) )
}
} }
}
@Test @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( val backup = getRestorableBackup(
mutableMapOf( mutableMapOf(
packageName1 to PackageMetadata(system = true, isLaunchableSystemApp = false), packageName1 to PackageMetadata(system = true, isLaunchableSystemApp = false),
) )
) )
coEvery { iconManager.downloadIcons(repoId, snapshot) } returns (emptySet())
// choose restore set in setup wizard // choose restore set in setup wizard
appSelectionManager.selectedAppsFlow.test { appSelectionManager.selectedAppsFlow.test {
awaitItem() awaitItem()
@ -338,6 +363,9 @@ internal class AppSelectionManagerTest : TransportTest() {
assertEquals(1, initialApps.apps.size) assertEquals(1, initialApps.apps.size)
assertEquals(PACKAGE_NAME_SYSTEM, initialApps.apps[0].packageName) assertEquals(PACKAGE_NAME_SYSTEM, initialApps.apps[0].packageName)
assertTrue(initialApps.apps[0].selected) // system settings is selected assertTrue(initialApps.apps[0].selected) // system settings is selected
// now icons have loaded and apps were updated
awaitItem()
} }
appSelectionManager.selectedAppsFlow.test { appSelectionManager.selectedAppsFlow.test {
awaitItem() awaitItem()
@ -347,11 +375,15 @@ internal class AppSelectionManagerTest : TransportTest() {
assertEquals(1, initialApps.apps.size) assertEquals(1, initialApps.apps.size)
assertEquals(PACKAGE_NAME_SYSTEM, initialApps.apps[0].packageName) assertEquals(PACKAGE_NAME_SYSTEM, initialApps.apps[0].packageName)
assertFalse(initialApps.apps[0].selected) // system settings is NOT selected assertFalse(initialApps.apps[0].selected) // system settings is NOT selected
// now icons have loaded and apps were updated
awaitItem()
} }
} }
@Test @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 { appSelectionManager.selectedAppsFlow.test {
awaitItem() awaitItem()
@ -370,6 +402,9 @@ internal class AppSelectionManagerTest : TransportTest() {
assertEquals(1, initialApps.apps.size) assertEquals(1, initialApps.apps.size)
assertEquals(PACKAGE_NAME_SYSTEM, initialApps.apps[0].packageName) assertEquals(PACKAGE_NAME_SYSTEM, initialApps.apps[0].packageName)
// now icons have loaded and apps were updated
awaitItem()
// actual filtered backup includes @pm@ only // actual filtered backup includes @pm@ only
val filteredBackup = appSelectionManager.onAppSelectionFinished(backup) val filteredBackup = appSelectionManager.onAppSelectionFinished(backup)
assertEquals(1, filteredBackup.packageMetadataMap.size) assertEquals(1, filteredBackup.packageMetadataMap.size)
@ -380,14 +415,21 @@ internal class AppSelectionManagerTest : TransportTest() {
} }
} }
private fun getRestorableBackup(map: Map<String, PackageMetadata>): RestorableBackup { private fun getRestorableBackup(map: Map<String, PackageMetadata>) = RestorableBackup(
return RestorableBackup(backupMetadata.copy(packageMetadataMap = map as PackageMetadataMap)) backupMetadata = backupMetadata.copy(
} version = 2,
packageMetadataMap = map as PackageMetadataMap,
),
repoId = repoId,
snapshot = snapshot,
)
private suspend fun testFiltering( private suspend fun testFiltering(
block: suspend TurbineTestContext<SelectedAppsState>.(RestorableBackup) -> Unit, block: suspend TurbineTestContext<SelectedAppsState>.(RestorableBackup) -> Unit,
) { ) {
expectIconLoading() coEvery {
iconManager.downloadIcons(repoId, snapshot)
} returns setOf(packageName1, packageName2)
appSelectionManager.selectedAppsFlow.test { appSelectionManager.selectedAppsFlow.test {
awaitItem() awaitItem()
@ -425,17 +467,4 @@ internal class AppSelectionManagerTest : TransportTest() {
block(backup) block(backup)
} }
} }
private fun expectIconLoading(icons: Set<String> = 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
}
} }