From 9ad7a58ee4792aa3010c76e565373e6ddd8d1eee Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Wed, 4 Sep 2024 18:42:04 -0300 Subject: [PATCH] WIP: v2 dedup prototype --- Android.bp | 3 + app/build.gradle.kts | 4 + .../seedvault/backend/saf/SafBackendTest.kt | 10 +- .../seedvault/worker/IconManagerTest.kt | 113 ++++++++++ .../seedvault/restore/AppSelectionManager.kt | 15 +- .../seedvault/restore/install/ApkRestore.kt | 54 ++++- .../seedvault/transport/SnapshotManager.kt | 79 +++++++ .../transport/backup/AppBackupManager.kt | 50 +++++ .../transport/backup/BackupModule.kt | 6 + .../transport/backup/BackupReceiver.kt | 92 ++++++++ .../seedvault/transport/backup/BlobCreator.kt | 48 ++++ .../seedvault/transport/backup/BlobsCache.kt | 69 ++++++ .../transport/backup/SnapshotCreator.kt | 132 +++++++++++ .../seedvault/transport/restore/Loader.kt | 54 +++++ .../transport/restore/RestoreModule.kt | 1 + .../NotificationBackupObserver.kt | 8 + .../stevesoltys/seedvault/worker/ApkBackup.kt | 207 +++++++----------- .../seedvault/worker/ApkBackupManager.kt | 52 +---- .../seedvault/worker/AppBackupWorker.kt | 11 + .../seedvault/worker/IconManager.kt | 121 +++++----- .../seedvault/worker/WorkerModule.kt | 12 +- app/src/main/proto/snapshot.proto | 3 + .../restore/AppSelectionManagerTest.kt | 112 ++++++---- .../seedvault/worker/ApkBackupManagerTest.kt | 8 +- libs/Android.bp | 6 + libs/seedvault-chunker-0.1.jar | Bin 0 -> 21309 bytes 26 files changed, 968 insertions(+), 302 deletions(-) create mode 100644 app/src/androidTest/java/com/stevesoltys/seedvault/worker/IconManagerTest.kt create mode 100644 app/src/main/java/com/stevesoltys/seedvault/transport/SnapshotManager.kt create mode 100644 app/src/main/java/com/stevesoltys/seedvault/transport/backup/AppBackupManager.kt create mode 100644 app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupReceiver.kt create mode 100644 app/src/main/java/com/stevesoltys/seedvault/transport/backup/BlobCreator.kt create mode 100644 app/src/main/java/com/stevesoltys/seedvault/transport/backup/BlobsCache.kt create mode 100644 app/src/main/java/com/stevesoltys/seedvault/transport/backup/SnapshotCreator.kt create mode 100644 app/src/main/java/com/stevesoltys/seedvault/transport/restore/Loader.kt create mode 100644 libs/seedvault-chunker-0.1.jar diff --git a/Android.bp b/Android.bp index db349e264..0796923f1 100644 --- a/Android.bp +++ b/Android.bp @@ -32,6 +32,9 @@ android_app { "com.google.android.material_material", "kotlinx-coroutines-android", "kotlinx-coroutines-core", + // app backup related libs + "seedvault-lib-kotlin-logging-jvm", + "seedvault-lib-chunker" "seedvault-lib-zstd-jni", // our own gradle module libs "seedvault-lib-core", diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8af4a16a2..6de1dafca 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -157,6 +157,8 @@ dependencies { implementation(libs.google.protobuf.javalite) implementation(libs.google.tink.android) + implementation(libs.kotlin.logging) + implementation(libs.squareup.okio) /** * Storage Dependencies @@ -175,6 +177,7 @@ dependencies { implementation(fileTree("${rootProject.rootDir}/libs/koin-android").include("*.jar")) implementation(fileTree("${rootProject.rootDir}/libs/koin-android").include("*.aar")) + implementation(fileTree("${rootProject.rootDir}/libs").include("seedvault-chunker-0.1.jar")) implementation(fileTree("${rootProject.rootDir}/libs").include("zstd-jni-1.5.6-5.aar")) implementation(fileTree("${rootProject.rootDir}/libs").include("kotlin-bip39-jvm-1.0.6.jar")) @@ -188,6 +191,7 @@ dependencies { // anything less than 'implementation' fails tests run with gradlew testImplementation(aospLibs) testImplementation("androidx.test.ext:junit:1.1.5") + testImplementation("org.slf4j:slf4j-simple:2.0.3") testImplementation("org.robolectric:robolectric:4.12.2") testImplementation("org.hamcrest:hamcrest:2.2") testImplementation("org.junit.jupiter:junit-jupiter-api:${libs.versions.junit5.get()}") diff --git a/app/src/androidTest/java/com/stevesoltys/seedvault/backend/saf/SafBackendTest.kt b/app/src/androidTest/java/com/stevesoltys/seedvault/backend/saf/SafBackendTest.kt index 6e27738fc..9a2adf88e 100644 --- a/app/src/androidTest/java/com/stevesoltys/seedvault/backend/saf/SafBackendTest.kt +++ b/app/src/androidTest/java/com/stevesoltys/seedvault/backend/saf/SafBackendTest.kt @@ -13,7 +13,6 @@ import kotlinx.coroutines.runBlocking import org.calyxos.seedvault.core.backends.Backend import org.calyxos.seedvault.core.backends.BackendTest import org.calyxos.seedvault.core.backends.saf.SafBackend -import org.calyxos.seedvault.core.backends.saf.SafProperties import org.junit.Test import org.junit.runner.RunWith import org.koin.core.component.KoinComponent @@ -25,14 +24,7 @@ class SafBackendTest : BackendTest(), KoinComponent { private val context = InstrumentationRegistry.getInstrumentation().targetContext private val settingsManager by inject() - private val safStorage = settingsManager.getSafProperties() ?: error("No SAF storage") - private val safProperties = SafProperties( - config = safStorage.config, - name = safStorage.name, - isUsb = safStorage.isUsb, - requiresNetwork = safStorage.requiresNetwork, - rootId = safStorage.rootId, - ) + private val safProperties = settingsManager.getSafProperties() ?: error("No SAF storage") override val backend: Backend = SafBackend(context, safProperties, ".SeedvaultTest") @Test 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 000000000..3bf0f351e --- /dev/null +++ b/app/src/androidTest/java/com/stevesoltys/seedvault/worker/IconManagerTest.kt @@ -0,0 +1,113 @@ +/* + * 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.BackupStateManager +import com.stevesoltys.seedvault.proto.Snapshot +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 java.io.InputStream +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 backupStateManager = mockk() + private val snapshotCreatorFactory by inject() + private val snapshotCreator = snapshotCreatorFactory.createSnapshotCreator() + + private val iconManager = IconManager( + context = context, + packageService = packageService, + backupReceiver = backupReceiver, + loader = loader, + backupStateManager = backupStateManager, + ) + + init { + every { backupStateManager.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), captureLambda()) + } answers { + lambda<(InputStream) -> Unit>().captured.invoke(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 22a0b4ee5..ab5ec1909 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,10 @@ 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.proto.Snapshot import com.stevesoltys.seedvault.ui.PACKAGE_NAME_SYSTEM import com.stevesoltys.seedvault.ui.systemData import com.stevesoltys.seedvault.worker.IconManager @@ -24,7 +25,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -import org.calyxos.seedvault.core.backends.LegacyAppBackupFile import java.util.Locale internal class SelectedAppsState( @@ -37,7 +37,7 @@ private val TAG = AppSelectionManager::class.simpleName internal class AppSelectionManager( private val context: Context, - private val backendManager: BackendManager, + private val backendManager: BackendManager, // TODO remove private val iconManager: IconManager, private val coroutineScope: CoroutineScope, private val workDispatcher: CoroutineDispatcher = Dispatchers.IO, @@ -88,12 +88,11 @@ 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) - } + // TODO get real repoId + val repoId = "3f1f3d9da0fd5a509196cc96b75c668172592fcb5c20b9159f398da2b6149cc1" + // TODO get real snapshot + iconManager.downloadIcons(repoId, Snapshot.newBuilder().build()) } catch (e: Exception) { Log.e(TAG, "Error loading icons:", e) emptySet() diff --git a/app/src/main/java/com/stevesoltys/seedvault/restore/install/ApkRestore.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/install/ApkRestore.kt index 3fe0f700b..5603c3190 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/restore/install/ApkRestore.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/restore/install/ApkRestore.kt @@ -10,14 +10,16 @@ import android.content.Context import android.content.pm.PackageManager import android.content.pm.PackageManager.GET_SIGNATURES import android.content.pm.PackageManager.GET_SIGNING_CERTIFICATES +import android.content.pm.SigningInfo import android.util.Log import com.stevesoltys.seedvault.BackupStateManager import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER +import com.stevesoltys.seedvault.backend.BackendManager +import com.stevesoltys.seedvault.backend.LegacyStoragePlugin import com.stevesoltys.seedvault.crypto.Crypto +import com.stevesoltys.seedvault.encodeBase64 import com.stevesoltys.seedvault.metadata.ApkSplit import com.stevesoltys.seedvault.metadata.PackageMetadata -import com.stevesoltys.seedvault.backend.BackendManager -import com.stevesoltys.seedvault.backend.LegacyStoragePlugin import com.stevesoltys.seedvault.restore.RestorableBackup import com.stevesoltys.seedvault.restore.install.ApkInstallState.FAILED import com.stevesoltys.seedvault.restore.install.ApkInstallState.FAILED_SYSTEM_APP @@ -25,8 +27,7 @@ import com.stevesoltys.seedvault.restore.install.ApkInstallState.IN_PROGRESS import com.stevesoltys.seedvault.restore.install.ApkInstallState.QUEUED import com.stevesoltys.seedvault.restore.install.ApkInstallState.SUCCEEDED import com.stevesoltys.seedvault.transport.backup.isSystemApp -import com.stevesoltys.seedvault.worker.copyStreamsAndGetHash -import com.stevesoltys.seedvault.worker.getSignatures +import com.stevesoltys.seedvault.worker.hashSignature import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -35,6 +36,9 @@ import org.calyxos.seedvault.core.backends.Backend import org.calyxos.seedvault.core.backends.LegacyAppBackupFile import java.io.File import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import java.security.MessageDigest import java.util.Locale private val TAG = ApkRestore::class.java.simpleName @@ -337,3 +341,45 @@ internal class ApkRestore( } } } + +/** + * Copy the APK from the given [InputStream] to the given [OutputStream] + * and calculate the SHA-256 hash while at it. + * + * Both streams will be closed when the method returns. + * + * @return the APK's SHA-256 hash in Base64 format. + */ +@Throws(IOException::class) +fun copyStreamsAndGetHash(inputStream: InputStream, outputStream: OutputStream): String { + val messageDigest = MessageDigest.getInstance("SHA-256") + outputStream.use { oStream -> + inputStream.use { inputStream -> + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + var bytes = inputStream.read(buffer) + while (bytes >= 0) { + oStream.write(buffer, 0, bytes) + messageDigest.update(buffer, 0, bytes) + bytes = inputStream.read(buffer) + } + } + } + return messageDigest.digest().encodeBase64() +} + +/** + * Returns a list of Base64 encoded SHA-256 signature hashes. + */ +fun SigningInfo?.getSignatures(): List { + return if (this == null) { + emptyList() + } else if (hasMultipleSigners()) { + apkContentsSigners.map { signature -> + hashSignature(signature).encodeBase64() + } + } else { + signingCertificateHistory.map { signature -> + hashSignature(signature).encodeBase64() + } + } +} diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/SnapshotManager.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/SnapshotManager.kt new file mode 100644 index 000000000..877fc53ea --- /dev/null +++ b/app/src/main/java/com/stevesoltys/seedvault/transport/SnapshotManager.kt @@ -0,0 +1,79 @@ +/* + * SPDX-FileCopyrightText: 2024 The Calyx Institute + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.stevesoltys.seedvault.transport + +import com.github.luben.zstd.ZstdOutputStream +import com.stevesoltys.seedvault.backend.BackendManager +import com.stevesoltys.seedvault.crypto.Crypto +import com.stevesoltys.seedvault.header.VERSION +import com.stevesoltys.seedvault.proto.Snapshot +import com.stevesoltys.seedvault.transport.restore.Loader +import io.github.oshai.kotlinlogging.KotlinLogging +import okio.Buffer +import okio.buffer +import okio.sink +import org.calyxos.seedvault.core.backends.AppBackupFileType +import org.calyxos.seedvault.core.backends.TopLevelFolder + +internal class SnapshotManager( + private val crypto: Crypto, + private val loader: Loader, + private val backendManager: BackendManager, +) { + + private val log = KotlinLogging.logger {} + + /** + * The latest [Snapshot]. May be stale if [loadSnapshots] has not returned + * or wasn't called since new snapshots have been created. + */ + var latestSnapshot: Snapshot? = null + private set + + suspend fun loadSnapshots(callback: (Snapshot) -> Unit) { + log.info { "Loading snapshots..." } + val handles = mutableListOf() + backendManager.backend.list( + topLevelFolder = TopLevelFolder(crypto.repoId), + AppBackupFileType.Snapshot::class, + ) { fileInfo -> + fileInfo.fileHandle as AppBackupFileType.Snapshot + handles.add(fileInfo.fileHandle as AppBackupFileType.Snapshot) + } + handles.forEach { fileHandle -> + val snapshot = onSnapshotFound(fileHandle) + callback(snapshot) + } + } + + private suspend fun onSnapshotFound(snapshotHandle: AppBackupFileType.Snapshot): Snapshot { + // TODO set up local snapshot cache, so we don't need to download those all the time + val snapshot = loader.loadFile(snapshotHandle) { + Snapshot.parseFrom(it) + } + // update latest snapshot if this one is more recent + if (snapshot.token > (latestSnapshot?.token ?: 0)) latestSnapshot = snapshot + return snapshot + } + + suspend fun saveSnapshot(snapshot: Snapshot) { + val buffer = Buffer() + val bufferStream = buffer.outputStream() + bufferStream.write(VERSION.toInt()) + crypto.newEncryptingStream(bufferStream, crypto.getAdForVersion()).use { cryptoStream -> + ZstdOutputStream(cryptoStream).use { zstdOutputStream -> + snapshot.writeTo(zstdOutputStream) + } + } + val sha256ByteString = buffer.sha256() + val handle = AppBackupFileType.Snapshot(crypto.repoId, sha256ByteString.hex()) + // TODO exception handling + backendManager.backend.save(handle).use { outputStream -> + outputStream.sink().buffer().writeAll(buffer) + } + } + +} diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/AppBackupManager.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/AppBackupManager.kt new file mode 100644 index 000000000..0d1b68736 --- /dev/null +++ b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/AppBackupManager.kt @@ -0,0 +1,50 @@ +/* + * SPDX-FileCopyrightText: 2024 The Calyx Institute + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.stevesoltys.seedvault.transport.backup + +import com.stevesoltys.seedvault.transport.SnapshotManager +import io.github.oshai.kotlinlogging.KotlinLogging +import kotlinx.coroutines.delay + +internal class AppBackupManager( + private val blobsCache: BlobsCache, + private val snapshotManager: SnapshotManager, + private val snapshotCreatorFactory: SnapshotCreatorFactory, +) { + + private val log = KotlinLogging.logger {} + var snapshotCreator: SnapshotCreator? = null + private set + + suspend fun beforeBackup() { + log.info { "Before backup" } + snapshotCreator = snapshotCreatorFactory.createSnapshotCreator() + blobsCache.populateCache() + } + + suspend fun afterBackupFinished() { + log.info { "After backup finished" } + blobsCache.clear() + val snapshot = snapshotCreator?.finalizeSnapshot() ?: error("Had no snapshotCreator") + keepTrying { + snapshotManager.saveSnapshot(snapshot) + } + snapshotCreator = null + } + + private suspend fun keepTrying(n: Int = 3, block: suspend () -> Unit) { + for (i in 1..n) { + try { + block() + } catch (e: Exception) { + if (i == n) throw e + log.error(e) { "Error (#$i), we'll keep trying" } + delay(1000) + } + } + } + +} diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupModule.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupModule.kt index bc18e0cbd..206bee0db 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupModule.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupModule.kt @@ -5,11 +5,17 @@ package com.stevesoltys.seedvault.transport.backup +import com.stevesoltys.seedvault.transport.SnapshotManager import org.koin.android.ext.koin.androidContext import org.koin.dsl.module val backupModule = module { single { BackupInitializer(get()) } + single { BackupReceiver(get(), get(), get()) } + single { BlobsCache(get(), get(), get()) } + single { BlobCreator(get(), get()) } + single { SnapshotManager(get(), get(), get()) } + single { SnapshotCreatorFactory(androidContext(), get(), get(), get()) } single { InputFactory() } single { PackageService( diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupReceiver.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupReceiver.kt new file mode 100644 index 000000000..69cef7f4d --- /dev/null +++ b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupReceiver.kt @@ -0,0 +1,92 @@ +/* + * SPDX-FileCopyrightText: 2024 The Calyx Institute + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.stevesoltys.seedvault.transport.backup + + +import com.stevesoltys.seedvault.crypto.Crypto +import com.stevesoltys.seedvault.proto.Snapshot.Blob +import org.calyxos.seedvault.chunker.Chunk +import org.calyxos.seedvault.chunker.Chunker +import org.calyxos.seedvault.chunker.GearTableCreator +import org.calyxos.seedvault.core.toHexString +import java.io.InputStream + +data class BackupData( + val chunks: List, + val chunkMap: Map, +) + +internal class BackupReceiver( + private val blobsCache: BlobsCache, + private val blobCreator: BlobCreator, + private val crypto: Crypto, + private val chunker: Chunker = Chunker( + minSize = 1536 * 1024, // 1.5 MB + avgSize = 3 * 1024 * 1024, // 3.0 MB + maxSize = 7680 * 1024, // 7.5 MB + normalization = 1, + gearTable = GearTableCreator.create(crypto.gearTableKey), + hashFunction = { bytes -> + crypto.sha256(bytes).toHexString() + }, + ) +) { + + private val chunks = mutableListOf() + private val chunkMap = mutableMapOf() + + suspend fun addBytes(bytes: ByteArray) { + chunker.addBytes(bytes).forEach { chunk -> + onNewChunk(chunk) + } + } + + suspend fun readFromStream(inputStream: InputStream) { + try { + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + var bytes = inputStream.read(buffer) + while (bytes >= 0) { + if (bytes == buffer.size) { + addBytes(buffer) + } else { + addBytes(buffer.copyOfRange(0, bytes)) + } + bytes = inputStream.read(buffer) + } + } catch (e: Exception) { + finalize() + throw e + } + } + + suspend fun finalize(): BackupData { + chunker.finalize().forEach { chunk -> + onNewChunk(chunk) + } + // copy chunks and chunkMap before clearing + val backupData = BackupData(chunks.toList(), chunkMap.toMap()) + chunks.clear() + chunkMap.clear() + return backupData + // TODO add to SnapshotCreator one level up for inclusion in snapshot + } + + private suspend fun onNewChunk(chunk: Chunk) { + chunks.add(chunk.hash) + + val existingBlob = blobsCache.getBlob(chunk.hash) + if (existingBlob == null) { + val blob = blobCreator.createNewBlob(chunk) + chunkMap[chunk.hash] = blob + blobsCache.saveNewBlob(chunk.hash, blob) + } else { + chunkMap[chunk.hash] = existingBlob + } + } + + + +} diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BlobCreator.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BlobCreator.kt new file mode 100644 index 000000000..15b9f6208 --- /dev/null +++ b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BlobCreator.kt @@ -0,0 +1,48 @@ +/* + * SPDX-FileCopyrightText: 2024 The Calyx Institute + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.stevesoltys.seedvault.transport.backup + +import com.github.luben.zstd.ZstdOutputStream +import com.google.protobuf.ByteString +import com.stevesoltys.seedvault.backend.BackendManager +import com.stevesoltys.seedvault.crypto.Crypto +import com.stevesoltys.seedvault.header.VERSION +import com.stevesoltys.seedvault.proto.Snapshot.Blob +import okio.Buffer +import okio.buffer +import okio.sink +import org.calyxos.seedvault.chunker.Chunk +import org.calyxos.seedvault.core.backends.AppBackupFileType + +internal class BlobCreator( + private val crypto: Crypto, + private val backendManager: BackendManager, +) { + + private val buffer = Buffer() + + suspend fun createNewBlob(chunk: Chunk): Blob { + buffer.clear() + val bufferStream = buffer.outputStream() + bufferStream.write(VERSION.toInt()) + crypto.newEncryptingStream(bufferStream, crypto.getAdForVersion()).use { cryptoStream -> + ZstdOutputStream(cryptoStream).use { zstdOutputStream -> + zstdOutputStream.write(chunk.data) + } + } + val sha256ByteString = buffer.sha256() + val handle = AppBackupFileType.Blob(crypto.repoId, sha256ByteString.hex()) + // TODO exception handling and retries + val size = backendManager.backend.save(handle).use { outputStream -> + outputStream.sink().buffer().writeAll(buffer) + } + return Blob.newBuilder() + .setId(ByteString.copyFrom(sha256ByteString.asByteBuffer())) + .setLength(size.toInt()) + .setUncompressedLength(chunk.length) + .build() + } +} diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BlobsCache.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BlobsCache.kt new file mode 100644 index 000000000..9d7f715e3 --- /dev/null +++ b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BlobsCache.kt @@ -0,0 +1,69 @@ +/* + * SPDX-FileCopyrightText: 2024 The Calyx Institute + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.stevesoltys.seedvault.transport.backup + +import com.stevesoltys.seedvault.backend.BackendManager +import com.stevesoltys.seedvault.crypto.Crypto +import com.stevesoltys.seedvault.proto.Snapshot.Blob +import com.stevesoltys.seedvault.transport.SnapshotManager +import io.github.oshai.kotlinlogging.KotlinLogging +import org.calyxos.seedvault.core.backends.AppBackupFileType +import org.calyxos.seedvault.core.backends.TopLevelFolder + +internal class BlobsCache( + private val crypto: Crypto, + private val backendManager: BackendManager, + private val snapshotManager: SnapshotManager, +) { + + private val log = KotlinLogging.logger {} + private val blobMap = mutableMapOf() + + /** + * This must be called before saving files to the backend to avoid uploading duplicate blobs. + */ + suspend fun populateCache() { + log.info { "Getting all blobs from backend..." } + blobMap.clear() + val blobs = mutableSetOf() + backendManager.backend.list( + topLevelFolder = TopLevelFolder(crypto.repoId), + AppBackupFileType.Blob::class, + ) { fileInfo -> + fileInfo.fileHandle as AppBackupFileType.Blob + // TODO we could save size info here and later check it is as expected + blobs.add(fileInfo.fileHandle.name) + } + snapshotManager.loadSnapshots { snapshot -> + snapshot.blobsMap.forEach { (chunkId, blob) -> + // check if referenced blob still exists on backend + if (blobs.contains(blob.id.hexFromProto())) { + // only add blob to our mapping, if it still exists + blobMap.putIfAbsent(chunkId, blob)?.let { previous -> + if (previous.id != blob.id) log.warn { + "Chunk ID ${chunkId.substring(0..5)} had more than one blob" + } + } + } else log.warn { + "Blob ${blob.id.hexFromProto()} referenced in snapshot ${snapshot.token}" + } + } + } + } + + fun getBlob(hash: String): Blob? = blobMap[hash] + + fun saveNewBlob(chunkId: String, blob: Blob) { + blobMap[chunkId] = blob + // TODO persist this blob locally in case backup gets interrupted + } + + fun clear() { + log.info { "Clearing cache..." } + blobMap.clear() + } + +} diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/SnapshotCreator.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/SnapshotCreator.kt new file mode 100644 index 000000000..adbf64eb1 --- /dev/null +++ b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/SnapshotCreator.kt @@ -0,0 +1,132 @@ +/* + * SPDX-FileCopyrightText: 2024 The Calyx Institute + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.stevesoltys.seedvault.transport.backup + +import android.annotation.SuppressLint +import android.content.Context +import android.content.pm.PackageInfo +import android.content.pm.PackageManager.PERMISSION_GRANTED +import android.os.Build +import android.os.UserManager +import android.provider.Settings +import android.provider.Settings.Secure.ANDROID_ID +import com.google.protobuf.ByteString +import com.stevesoltys.seedvault.Clock +import com.stevesoltys.seedvault.metadata.BackupType +import com.stevesoltys.seedvault.metadata.PackageState.APK_AND_DATA +import com.stevesoltys.seedvault.proto.Snapshot +import com.stevesoltys.seedvault.proto.Snapshot.Apk +import com.stevesoltys.seedvault.proto.Snapshot.App +import com.stevesoltys.seedvault.proto.Snapshot.Blob +import com.stevesoltys.seedvault.settings.SettingsManager +import org.calyxos.seedvault.core.toHexString + +internal class SnapshotCreatorFactory( + private val context: Context, + private val clock: Clock, + private val packageService: PackageService, + private val settingsManager: SettingsManager, +) { + fun createSnapshotCreator() = SnapshotCreator(context, clock, packageService, settingsManager) +} + +internal class SnapshotCreator( + private val context: Context, + private val clock: Clock, + private val packageService: PackageService, + private val settingsManager: SettingsManager, +) { + + private val snapshotBuilder = Snapshot.newBuilder() + .setToken(clock.time()) + private val appBuilderMap = mutableMapOf() + private val blobsMap = mutableMapOf() + + private val launchableSystemApps by lazy { + packageService.launchableSystemApps.map { it.activityInfo.packageName }.toSet() + } + + fun onApkBackedUp( + packageName: String, + apk: Apk, + chunkMap: Map, + ) { + val appBuilder = appBuilderMap.getOrPut(packageName) { + App.newBuilder() + } + appBuilder.setApk(apk) + blobsMap.putAll(chunkMap) + } + + fun onPackageBackedUp( + packageInfo: PackageInfo, + type: BackupType, + backupData: BackupData, + ) { + val packageName = packageInfo.packageName + val builder = appBuilderMap.getOrPut(packageName) { + App.newBuilder() + } + val isSystemApp = packageInfo.isSystemApp() + val chunkIds = backupData.chunks.forProto() + blobsMap.putAll(backupData.chunkMap) + builder + .setTime(clock.time()) + .setState(APK_AND_DATA.name) + .setType(type.forSnapshot()) + .setName(packageInfo.applicationInfo?.loadLabel(context.packageManager)?.toString()) + .setSystem(isSystemApp) + .setLaunchableSystemApp(isSystemApp && launchableSystemApps.contains(packageName)) + .addAllChunkIds(chunkIds) + } + + fun onIconsBackedUp(backupData: BackupData) { + snapshotBuilder.addAllIconChunkIds(backupData.chunks.forProto()) + blobsMap.putAll(backupData.chunkMap) + } + + fun finalizeSnapshot(): Snapshot { + val userName = getUserName() + val deviceName = if (userName == null) { + "${Build.MANUFACTURER} ${Build.MODEL}" + } else { + "${Build.MANUFACTURER} ${Build.MODEL} - $userName" + } + + @SuppressLint("HardwareIds") + val androidId = Settings.Secure.getString(context.contentResolver, ANDROID_ID) + val snapshot = snapshotBuilder + .setName(deviceName) + .setAndroidId(androidId) + .setSdkInt(Build.VERSION.SDK_INT) + .setAndroidIncremental(Build.VERSION.INCREMENTAL) + .setD2D(settingsManager.d2dBackupsEnabled()) + .putAllApps(appBuilderMap.mapValues { it.value.build() }) + .putAllBlobs(blobsMap) + .build() + appBuilderMap.clear() + snapshotBuilder.clear() + return snapshot + } + + private fun getUserName(): String? { + val perm = "android.permission.QUERY_USERS" + return if (context.checkSelfPermission(perm) == PERMISSION_GRANTED) { + val userManager = context.getSystemService(UserManager::class.java) ?: return null + userManager.userName + } else null + } + + private fun BackupType.forSnapshot(): Snapshot.BackupType = when (this) { + BackupType.KV -> Snapshot.BackupType.KV + BackupType.FULL -> Snapshot.BackupType.FULL + } + +} + +fun Iterable.forProto() = map { ByteString.fromHex(it) } +fun Iterable.hexFromProto() = map { it.toByteArray().toHexString() } +fun ByteString.hexFromProto() = toByteArray().toHexString() diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/restore/Loader.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/restore/Loader.kt new file mode 100644 index 000000000..e8e42d734 --- /dev/null +++ b/app/src/main/java/com/stevesoltys/seedvault/transport/restore/Loader.kt @@ -0,0 +1,54 @@ +/* + * SPDX-FileCopyrightText: 2024 The Calyx Institute + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.stevesoltys.seedvault.transport.restore + +import com.github.luben.zstd.ZstdInputStream +import com.stevesoltys.seedvault.backend.BackendManager +import com.stevesoltys.seedvault.crypto.Crypto +import com.stevesoltys.seedvault.header.UnsupportedVersionException +import com.stevesoltys.seedvault.header.VERSION +import org.calyxos.seedvault.core.backends.AppBackupFileType +import org.calyxos.seedvault.core.toHexString +import java.io.ByteArrayInputStream +import java.io.InputStream +import java.security.GeneralSecurityException + +internal class Loader( + private val crypto: Crypto, + private val backendManager: BackendManager, +) { + + suspend fun loadFile(handle: AppBackupFileType, reader: (InputStream) -> T): T { + // We load the entire ciphertext into memory, + // so we can check the SHA-256 hash before decrypting and parsing the data. + val cipherText = backendManager.backend.load(handle).use { inputStream -> + inputStream.readAllBytes() + } + // check SHA-256 hash first thing + val sha256 = crypto.sha256(cipherText).toHexString() + val expectedHash = when (handle) { + is AppBackupFileType.Snapshot -> handle.hash + is AppBackupFileType.Blob -> handle.name + } + if (sha256 != expectedHash) { + throw GeneralSecurityException("Snapshot had wrong SHA-256 hash: $handle") + } + // check that we can handle the version of that snapshot + val version = cipherText[0] + if (version <= 1) throw GeneralSecurityException("Unexpected version: $version") + if (version > VERSION) throw UnsupportedVersionException(version) + // get associated data for version, used for authenticated decryption + val ad = crypto.getAdForVersion(version) + // skip first version byte when creating cipherText stream + val inputStream = ByteArrayInputStream(cipherText, 1, cipherText.size - 1) + // decrypt and decompress cipherText stream and parse snapshot + return crypto.newDecryptingStream(inputStream, ad).use { cryptoStream -> + ZstdInputStream(cryptoStream).use { zstdInputStream -> + reader(zstdInputStream) + } + } + } +} diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/restore/RestoreModule.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/restore/RestoreModule.kt index 869e1b0ca..fd1d835d6 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/transport/restore/RestoreModule.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/transport/restore/RestoreModule.kt @@ -10,6 +10,7 @@ import org.koin.dsl.module val restoreModule = module { single { OutputFactory() } + single { Loader(get(), get()) } single { KVRestore(get(), get(), get(), get(), get(), get()) } single { FullRestore(get(), get(), get(), get(), get()) } single { diff --git a/app/src/main/java/com/stevesoltys/seedvault/ui/notification/NotificationBackupObserver.kt b/app/src/main/java/com/stevesoltys/seedvault/ui/notification/NotificationBackupObserver.kt index d45dca919..1c93f9f7b 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/ui/notification/NotificationBackupObserver.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/ui/notification/NotificationBackupObserver.kt @@ -19,8 +19,10 @@ import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.metadata.MetadataManager import com.stevesoltys.seedvault.settings.SettingsManager +import com.stevesoltys.seedvault.transport.backup.AppBackupManager import com.stevesoltys.seedvault.transport.backup.PackageService import com.stevesoltys.seedvault.worker.BackupRequester +import kotlinx.coroutines.runBlocking import org.koin.core.component.KoinComponent import org.koin.core.component.inject @@ -36,6 +38,7 @@ internal class NotificationBackupObserver( private val metadataManager: MetadataManager by inject() private val packageService: PackageService by inject() private val settingsManager: SettingsManager by inject() + private val appBackupManager: AppBackupManager by inject() private var currentPackage: String? = null private var numPackages: Int = 0 private var numPackagesToReport: Int = 0 @@ -141,6 +144,11 @@ internal class NotificationBackupObserver( Log.e(TAG, "Error getting number of all user packages: ", e) requestedPackages } + // TODO handle exceptions + runBlocking { + // TODO check if UI thread + appBackupManager.afterBackupFinished() + } nm.onBackupFinished(success, numPackagesToReport, total, size) } } diff --git a/app/src/main/java/com/stevesoltys/seedvault/worker/ApkBackup.kt b/app/src/main/java/com/stevesoltys/seedvault/worker/ApkBackup.kt index 786975c72..898cb1933 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/worker/ApkBackup.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/worker/ApkBackup.kt @@ -5,7 +5,6 @@ package com.stevesoltys.seedvault.worker -import android.annotation.SuppressLint import android.content.pm.PackageInfo import android.content.pm.PackageManager import android.content.pm.Signature @@ -13,94 +12,91 @@ import android.content.pm.SigningInfo import android.util.Log import android.util.PackageUtils.computeSha256DigestBytes import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER -import com.stevesoltys.seedvault.crypto.Crypto -import com.stevesoltys.seedvault.encodeBase64 -import com.stevesoltys.seedvault.metadata.ApkSplit -import com.stevesoltys.seedvault.metadata.MetadataManager import com.stevesoltys.seedvault.metadata.PackageMetadata +import com.stevesoltys.seedvault.proto.Snapshot import com.stevesoltys.seedvault.settings.SettingsManager +import com.stevesoltys.seedvault.transport.SnapshotManager +import com.stevesoltys.seedvault.transport.backup.AppBackupManager +import com.stevesoltys.seedvault.transport.backup.BackupReceiver +import com.stevesoltys.seedvault.transport.backup.forProto +import com.stevesoltys.seedvault.transport.backup.hexFromProto import com.stevesoltys.seedvault.transport.backup.isNotUpdatedSystemApp import com.stevesoltys.seedvault.transport.backup.isTestOnly +import org.calyxos.seedvault.core.toHexString import java.io.File import java.io.FileInputStream import java.io.IOException -import java.io.InputStream -import java.io.OutputStream -import java.security.MessageDigest private val TAG = ApkBackup::class.java.simpleName +internal const val BASE_SPLIT = "org.calyxos.seedvault.BASE_SPLIT" internal class ApkBackup( private val pm: PackageManager, - private val crypto: Crypto, + private val backupReceiver: BackupReceiver, + private val appBackupManager: AppBackupManager, + private val snapshotManager: SnapshotManager, private val settingsManager: SettingsManager, - private val metadataManager: MetadataManager, ) { + private val snapshotCreator + get() = appBackupManager.snapshotCreator ?: error("No SnapshotCreator") + /** * Checks if a new APK needs to get backed up, * because the version code or the signatures have changed. - * Only if an APK needs a backup, an [OutputStream] is obtained from the given streamGetter - * and the APK binary written to it. + * Only if APKs need backup, they get chunked and uploaded. * * @return new [PackageMetadata] if an APK backup was made or null if no backup was made. */ @Throws(IOException::class) - @SuppressLint("NewApi") // can be removed when minSdk is set to 30 - suspend fun backupApkIfNecessary( - packageInfo: PackageInfo, - streamGetter: suspend (name: String) -> OutputStream, - ): PackageMetadata? { + suspend fun backupApkIfNecessary(packageInfo: PackageInfo) { // do not back up @pm@ val packageName = packageInfo.packageName - if (packageName == MAGIC_PACKAGE_MANAGER) return null + if (packageName == MAGIC_PACKAGE_MANAGER) return // do not back up when setting is not enabled - if (!settingsManager.backupApks()) return null + if (!settingsManager.backupApks()) return // do not back up if package is blacklisted if (!settingsManager.isBackupEnabled(packageName)) { Log.d(TAG, "Package $packageName is blacklisted. Not backing it up.") - return null + return } // do not back up test-only apps as we can't re-install them anyway // see: https://commonsware.com/blog/2017/10/31/android-studio-3p0-flag-test-only.html if (packageInfo.isTestOnly()) { Log.d(TAG, "Package $packageName is test-only app. Not backing it up.") - return null + return } // do not back up system apps that haven't been updated if (packageInfo.isNotUpdatedSystemApp()) { Log.d(TAG, "Package $packageName is vanilla system app. Not backing it up.") - return null + return } // TODO remove when adding support for packages with multiple signers - val signingInfo = packageInfo.signingInfo ?: return null + val signingInfo = packageInfo.signingInfo ?: return if (signingInfo.hasMultipleSigners()) { Log.e(TAG, "Package $packageName has multiple signers. Not backing it up.") - return null + return } // get signatures - val signatures = signingInfo.getSignatures() + val signatures = signingInfo.getSignaturesHex() if (signatures.isEmpty()) { Log.e(TAG, "Package $packageName has no signatures. Not backing it up.") - return null + return } - // get cached metadata about package - val packageMetadata = metadataManager.getPackageMetadata(packageName) - ?: PackageMetadata() - - // get version codes + // get info from latest snapshot val version = packageInfo.longVersionCode - val backedUpVersion = packageMetadata.version ?: 0L // no version will cause backup + val oldApk = snapshotManager.latestSnapshot?.appsMap?.get(packageName)?.apk + val backedUpVersion = oldApk?.versionCode ?: 0L // no version will cause backup // do not backup if we have the version already and signatures did not change - if (version <= backedUpVersion && !signaturesChanged(packageMetadata, signatures)) { + if (version <= backedUpVersion && !signaturesChanged(oldApk, signatures)) { Log.d( TAG, "Package $packageName with version $version" + " already has a backup ($backedUpVersion)" + @@ -108,40 +104,52 @@ internal class ApkBackup( ) // We could also check if there are new feature module splits to back up, // but we rely on the app themselves to re-download those, if needed after restore. - return null + return } + // builder for Apk object + val apkBuilder = Snapshot.Apk.newBuilder() + .setVersionCode(version) + .setInstaller(pm.getInstallSourceInfo(packageName).installingPackageName) + .addAllSignatures(signatures.forProto()) + // get an InputStream for the APK - val sourceDir = packageInfo.applicationInfo?.sourceDir ?: return null - val inputStream = getApkInputStream(sourceDir) - // copy the APK to the storage's output and calculate SHA-256 hash while at it - val name = crypto.getNameForApk(metadataManager.salt, packageName) - val sha256 = copyStreamsAndGetHash(inputStream, streamGetter(name)) + val sourceDir = packageInfo.applicationInfo?.sourceDir ?: return + // upload the APK to the backend + getApkInputStream(sourceDir).use { inputStream -> + backupReceiver.readFromStream(inputStream) + } + val backupData = backupReceiver.finalize() + // store base split in builder + val baseSplit = Snapshot.Split.newBuilder() + .setName(BASE_SPLIT) + .addAllChunkIds(backupData.chunks.forProto()) + apkBuilder + .addSplits(baseSplit) + val chunkMap = backupData.chunkMap.toMutableMap() // back up splits if they exist - val splits = - if (packageInfo.splitNames == null) null else backupSplitApks(packageInfo, streamGetter) + val splits = if (packageInfo.splitNames == null) { + emptyList() + } else { + backupSplitApks(packageInfo, chunkMap) + } + apkBuilder.addAllSplits(splits) + val apk = apkBuilder.build() + snapshotCreator.onApkBackedUp(packageName, apk, chunkMap) Log.d(TAG, "Backed up new APK of $packageName with version ${packageInfo.versionName}.") - - // return updated metadata - return packageMetadata.copy( - version = version, - installer = pm.getInstallSourceInfo(packageName).installingPackageName, - splits = splits, - sha256 = sha256, - signatures = signatures - ) } private fun signaturesChanged( - packageMetadata: PackageMetadata, + apk: Snapshot.Apk?, signatures: List, ): Boolean { - // no signatures in package metadata counts as them not having changed - if (packageMetadata.signatures == null) return false + // no signatures counts as them not having changed + if (apk == null || apk.signaturesList.isNullOrEmpty()) return false + val sigHex = apk.signaturesList.hexFromProto() // TODO to support multiple signers check if lists differ - return packageMetadata.signatures.intersect(signatures).isEmpty() + return sigHex.intersect(signatures.toSet()).isEmpty() } @Throws(IOException::class) @@ -159,8 +167,8 @@ internal class ApkBackup( @Throws(IOException::class) private suspend fun backupSplitApks( packageInfo: PackageInfo, - streamGetter: suspend (name: String) -> OutputStream, - ): List { + chunkMap: MutableMap, + ): List { check(packageInfo.splitNames != null) // attention: though not documented, splitSourceDirs can be null val splitSourceDirs = packageInfo.applicationInfo?.splitSourceDirs ?: emptyArray() @@ -169,97 +177,42 @@ internal class ApkBackup( "splitNames is ${packageInfo.splitNames.toList()}, " + "but splitSourceDirs is ${splitSourceDirs.toList()}" } - val splits = ArrayList(packageInfo.splitNames.size) + val splits = ArrayList(packageInfo.splitNames.size) for (i in packageInfo.splitNames.indices) { - val split = backupSplitApk( - packageName = packageInfo.packageName, - splitName = packageInfo.splitNames[i], - sourceDir = splitSourceDirs[i], - streamGetter = streamGetter - ) + // copy the split APK to the storage stream + getApkInputStream(splitSourceDirs[i]).use { inputStream -> + backupReceiver.readFromStream(inputStream) + } + val backupData = backupReceiver.finalize() + val split = Snapshot.Split.newBuilder() + .setName(packageInfo.splitNames[i]) + .addAllChunkIds(backupData.chunks.forProto()) + .build() splits.add(split) + chunkMap.putAll(backupData.chunkMap) } return splits } - @Throws(IOException::class) - private suspend fun backupSplitApk( - packageName: String, - splitName: String, - sourceDir: String, - streamGetter: suspend (name: String) -> OutputStream, - ): ApkSplit { - // Calculate sha256 hash first to determine file name suffix. - // We could also just use the split name as a suffix, but there is a theoretical risk - // that we exceed the maximum file name length, so we use the hash instead. - // The downside is that we need to read the file two times. - val messageDigest = MessageDigest.getInstance("SHA-256") - val size = getApkInputStream(sourceDir).use { inputStream -> - val buffer = ByteArray(DEFAULT_BUFFER_SIZE) - var readCount = 0 - var bytes = inputStream.read(buffer) - while (bytes >= 0) { - readCount += bytes - messageDigest.update(buffer, 0, bytes) - bytes = inputStream.read(buffer) - } - readCount - } - val sha256 = messageDigest.digest().encodeBase64() - val name = crypto.getNameForApk(metadataManager.salt, packageName, splitName) - // copy the split APK to the storage stream - getApkInputStream(sourceDir).use { inputStream -> - streamGetter(name).use { outputStream -> - inputStream.copyTo(outputStream) - } - } - return ApkSplit(splitName, size.toLong(), sha256) - } - -} - -/** - * Copy the APK from the given [InputStream] to the given [OutputStream] - * and calculate the SHA-256 hash while at it. - * - * Both streams will be closed when the method returns. - * - * @return the APK's SHA-256 hash in Base64 format. - */ -@Throws(IOException::class) -fun copyStreamsAndGetHash(inputStream: InputStream, outputStream: OutputStream): String { - val messageDigest = MessageDigest.getInstance("SHA-256") - outputStream.use { oStream -> - inputStream.use { inputStream -> - val buffer = ByteArray(DEFAULT_BUFFER_SIZE) - var bytes = inputStream.read(buffer) - while (bytes >= 0) { - oStream.write(buffer, 0, bytes) - messageDigest.update(buffer, 0, bytes) - bytes = inputStream.read(buffer) - } - } - } - return messageDigest.digest().encodeBase64() } /** - * Returns a list of Base64 encoded SHA-256 signature hashes. + * Returns a list of lowercase hex encoded SHA-256 signature hashes. */ -fun SigningInfo?.getSignatures(): List { +fun SigningInfo?.getSignaturesHex(): List { return if (this == null) { emptyList() } else if (hasMultipleSigners()) { apkContentsSigners.map { signature -> - hashSignature(signature).encodeBase64() + hashSignature(signature).toHexString() } } else { signingCertificateHistory.map { signature -> - hashSignature(signature).encodeBase64() + hashSignature(signature).toHexString() } } } -private fun hashSignature(signature: Signature): ByteArray { +internal fun hashSignature(signature: Signature): ByteArray { return computeSha256DigestBytes(signature.toByteArray()) ?: throw AssertionError() } 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 3311a728f..5d7723337 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/worker/ApkBackupManager.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/worker/ApkBackupManager.kt @@ -8,19 +8,15 @@ package com.stevesoltys.seedvault.worker import android.content.Context import android.content.pm.PackageInfo import android.util.Log +import com.stevesoltys.seedvault.backend.isOutOfSpace import com.stevesoltys.seedvault.metadata.MetadataManager import com.stevesoltys.seedvault.metadata.PackageState.NOT_ALLOWED import com.stevesoltys.seedvault.metadata.PackageState.WAS_STOPPED -import com.stevesoltys.seedvault.backend.BackendManager -import com.stevesoltys.seedvault.backend.getMetadataOutputStream -import com.stevesoltys.seedvault.backend.isOutOfSpace import com.stevesoltys.seedvault.settings.SettingsManager import com.stevesoltys.seedvault.transport.backup.PackageService import com.stevesoltys.seedvault.transport.backup.isStopped import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager import com.stevesoltys.seedvault.ui.notification.getAppName -import kotlinx.coroutines.delay -import org.calyxos.seedvault.core.backends.LegacyAppBackupFile import java.io.IOException internal class ApkBackupManager( @@ -30,7 +26,6 @@ internal class ApkBackupManager( private val packageService: PackageService, private val iconManager: IconManager, private val apkBackup: ApkBackup, - private val backendManager: BackendManager, private val nm: BackupNotificationManager, ) { @@ -51,14 +46,6 @@ internal class ApkBackupManager( backUpApks() } } finally { - keepTrying { - // upload all local changes only at the end, - // so we don't have to re-upload the metadata - val token = settingsManager.getToken() ?: error("no token") - backendManager.backend.getMetadataOutputStream(token).use { outputStream -> - metadataManager.uploadMetadata(outputStream) - } - } nm.onApkBackupDone() } } @@ -100,47 +87,22 @@ 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) } } /** - * Backs up an APK for the given [PackageInfo]. - * - * @return true if a backup was performed and false if no backup was needed or it failed. + * Backs up one (or more split) APK(s) for the given [PackageInfo], if needed. */ - private suspend fun backUpApk(packageInfo: PackageInfo): Boolean { + private suspend fun backUpApk(packageInfo: PackageInfo) { val packageName = packageInfo.packageName - return try { - apkBackup.backupApkIfNecessary(packageInfo) { name -> - val token = settingsManager.getToken() ?: throw IOException("no current token") - backendManager.backend.save(LegacyAppBackupFile.Blob(token, name)) - }?.let { packageMetadata -> - metadataManager.onApkBackedUp(packageInfo, packageMetadata) - true - } ?: false + try { + apkBackup.backupApkIfNecessary(packageInfo) } catch (e: IOException) { Log.e(TAG, "Error while writing APK for $packageName", e) if (e.isOutOfSpace()) nm.onInsufficientSpaceError() - false - } - } - - private suspend fun keepTrying(n: Int = 3, block: suspend () -> Unit) { - for (i in 1..n) { - try { - block() - } catch (e: Exception) { - if (i == n) throw e - Log.e(TAG, "Error (#$i), we'll keep trying", e) - delay(1000) - } } } } diff --git a/app/src/main/java/com/stevesoltys/seedvault/worker/AppBackupWorker.kt b/app/src/main/java/com/stevesoltys/seedvault/worker/AppBackupWorker.kt index b7041901e..e97ff922d 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/worker/AppBackupWorker.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/worker/AppBackupWorker.kt @@ -24,6 +24,7 @@ import androidx.work.WorkManager import androidx.work.WorkerParameters import com.stevesoltys.seedvault.backend.BackendManager import com.stevesoltys.seedvault.settings.SettingsManager +import com.stevesoltys.seedvault.transport.backup.AppBackupManager import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager import com.stevesoltys.seedvault.ui.notification.NOTIFICATION_ID_OBSERVER import org.koin.core.component.KoinComponent @@ -101,6 +102,7 @@ class AppBackupWorker( private val backupRequester: BackupRequester by inject() private val settingsManager: SettingsManager by inject() private val apkBackupManager: ApkBackupManager by inject() + private val appBackupManager: AppBackupManager by inject() private val backendManager: BackendManager by inject() private val nm: BackupNotificationManager by inject() @@ -137,6 +139,15 @@ class AppBackupWorker( private suspend fun doBackup(): Result { var result: Result = Result.success() + if (!isStopped) { + Log.i(TAG, "Initializing backup info...") + try { + appBackupManager.beforeBackup() + } catch (e: Exception) { + Log.e(TAG, "Error populating blobs cache: ", e) + return Result.retry() + } + } try { Log.i(TAG, "Starting APK backup... (stopped: $isStopped)") if (!isStopped) apkBackupManager.backup() 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 821a527d8..fe05b0b29 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 @@ -15,25 +15,26 @@ import androidx.appcompat.content.res.AppCompatResources.getDrawable import androidx.core.graphics.drawable.toBitmap 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" @@ -42,63 +43,87 @@ private val TAG = IconManager::class.simpleName 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 [inputStream]. + * 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) - fun downloadIcons(version: Byte, token: Long, inputStream: InputStream): Set { + 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) { inputStream -> + inputStream.copyTo(outputStream) + } + } val set = mutableSetOf() - crypto.newDecryptingStreamV1(inputStream, getAD(version, token)).use { cryptoStream -> - ZipInputStream(cryptoStream).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 + 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") @@ -142,10 +167,4 @@ internal class IconManager( Log.e(TAG, "Could delete icons: $result") } - private fun getAD(version: Byte, token: Long) = ByteBuffer.allocate(2 + 8) - .put(version) - .put(TYPE_ICONS) - .put(token.toByteArray()) - .array() - } 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 dea0b9410..768306965 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/worker/WorkerModule.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/worker/WorkerModule.kt @@ -5,6 +5,7 @@ package com.stevesoltys.seedvault.worker +import com.stevesoltys.seedvault.transport.backup.AppBackupManager import org.koin.android.ext.koin.androidContext import org.koin.dsl.module @@ -20,15 +21,19 @@ val workerModule = module { IconManager( context = androidContext(), packageService = get(), - crypto = get(), + backupReceiver = get(), + loader = get(), + appBackupManager = get(), ) } + single { AppBackupManager(get(), get(), get()) } single { ApkBackup( pm = androidContext().packageManager, - crypto = get(), + backupReceiver = get(), + appBackupManager = get(), + snapshotManager = get(), settingsManager = get(), - metadataManager = get() ) } single { @@ -39,7 +44,6 @@ val workerModule = module { packageService = get(), apkBackup = get(), iconManager = get(), - backendManager = get(), nm = get() ) } diff --git a/app/src/main/proto/snapshot.proto b/app/src/main/proto/snapshot.proto index 80792d863..4885ca45d 100644 --- a/app/src/main/proto/snapshot.proto +++ b/app/src/main/proto/snapshot.proto @@ -32,6 +32,9 @@ message Snapshot { } message Apk { + /** + * Attention: Has default value of 0 + */ uint64 versionCode = 1; string installer = 2; repeated bytes signatures = 3; 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 79d5945cc..bd76c6ff5 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,11 @@ 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.transport.TransportTest import com.stevesoltys.seedvault.ui.PACKAGE_NAME_CONTACTS import com.stevesoltys.seedvault.ui.PACKAGE_NAME_SETTINGS @@ -35,7 +35,6 @@ import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Assertions.fail import org.junit.runner.RunWith -import java.io.ByteArrayInputStream import java.io.IOException import kotlin.random.Random @@ -66,7 +65,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 { + expectIconLoading(emptySet()) appSelectionManager.selectedAppsFlow.test { val initialState = awaitItem() assertEquals(emptyList(), initialState.apps) @@ -91,11 +91,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 { + expectIconLoading(emptySet()) appSelectionManager.selectedAppsFlow.test { awaitItem() @@ -124,11 +128,15 @@ 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 { + expectIconLoading() appSelectionManager.selectedAppsFlow.test { awaitItem() @@ -146,6 +154,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() @@ -248,7 +259,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() @@ -283,48 +294,50 @@ internal class AppSelectionManagerTest : TransportTest() { } @Test - fun `finishing selection without system apps only removes non-special system apps`() = 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) - - // 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) + 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) + + // 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), ) ) + expectIconLoading(emptySet()) // choose restore set in setup wizard appSelectionManager.selectedAppsFlow.test { awaitItem() @@ -334,6 +347,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() @@ -343,11 +359,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 { + expectIconLoading(emptySet()) appSelectionManager.selectedAppsFlow.test { awaitItem() @@ -366,6 +386,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) @@ -423,15 +446,8 @@ internal class AppSelectionManagerTest : TransportTest() { } 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 + // TODO adapt to new code + coEvery { iconManager.downloadIcons(any(), any()) } returns icons } } 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 246ae82de..8a6fb032a 100644 --- a/app/src/test/java/com/stevesoltys/seedvault/worker/ApkBackupManagerTest.kt +++ b/app/src/test/java/com/stevesoltys/seedvault/worker/ApkBackupManagerTest.kt @@ -10,11 +10,11 @@ import android.content.pm.ApplicationInfo.FLAG_ALLOW_BACKUP import android.content.pm.ApplicationInfo.FLAG_INSTALLED import android.content.pm.ApplicationInfo.FLAG_STOPPED import android.content.pm.PackageInfo +import com.stevesoltys.seedvault.backend.BackendManager import com.stevesoltys.seedvault.metadata.PackageMetadata import com.stevesoltys.seedvault.metadata.PackageState.NOT_ALLOWED import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR import com.stevesoltys.seedvault.metadata.PackageState.WAS_STOPPED -import com.stevesoltys.seedvault.backend.BackendManager import com.stevesoltys.seedvault.transport.TransportTest import com.stevesoltys.seedvault.transport.backup.PackageService import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager @@ -31,7 +31,6 @@ import kotlinx.coroutines.runBlocking import org.calyxos.seedvault.core.backends.Backend import org.calyxos.seedvault.core.backends.LegacyAppBackupFile import org.junit.jupiter.api.Test -import java.io.ByteArrayOutputStream import java.io.IOException import java.io.OutputStream @@ -271,10 +270,7 @@ internal class ApkBackupManagerTest : TransportTest() { } private suspend fun expectUploadIcons() { - every { settingsManager.getToken() } returns token - val stream = ByteArrayOutputStream() - coEvery { backend.save(LegacyAppBackupFile.IconsFile(token)) } returns stream - every { iconManager.uploadIcons(token, stream) } just Runs + coEvery { iconManager.uploadIcons() } just Runs } private fun expectAllAppsWillGetBackedUp() { diff --git a/libs/Android.bp b/libs/Android.bp index 36158313a..ae63f75ad 100644 --- a/libs/Android.bp +++ b/libs/Android.bp @@ -16,6 +16,12 @@ java_import { sdk_version: "current", } +java_import { + name: "seedvault-lib-chunker", + jars: ["seedvault-chunker-0.1.jar"], + sdk_version: "current", +} + java_import { name: "seedvault-lib-kotlin-logging-jvm", jars: ["kotlin-logging-jvm-6.0.3.jar"], diff --git a/libs/seedvault-chunker-0.1.jar b/libs/seedvault-chunker-0.1.jar new file mode 100644 index 0000000000000000000000000000000000000000..a452dfe529902ad0c07d29ad8843114c25fdd52a GIT binary patch literal 21309 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