Back up app icons in new v2 format
We still support downloading in v1 format for some time.
This commit is contained in:
parent
1efa8e8f59
commit
e17c98857f
6 changed files with 299 additions and 95 deletions
|
@ -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)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -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)
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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())
|
||||||
|
|
|
@ -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()) }
|
||||||
|
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue