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/main/java/com/stevesoltys/seedvault/header/Header.kt b/app/src/main/java/com/stevesoltys/seedvault/header/Header.kt index e95640bc8..9489868a9 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/header/Header.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/header/Header.kt @@ -10,7 +10,7 @@ import com.stevesoltys.seedvault.crypto.TYPE_BACKUP_FULL import com.stevesoltys.seedvault.crypto.TYPE_BACKUP_KV import java.nio.ByteBuffer -internal const val VERSION: Byte = 1 +internal const val VERSION: Byte = 2 internal const val MAX_PACKAGE_LENGTH_SIZE = 255 internal const val MAX_KEY_LENGTH_SIZE = MAX_PACKAGE_LENGTH_SIZE internal const val MAX_VERSION_HEADER_SIZE = diff --git a/app/src/main/java/com/stevesoltys/seedvault/metadata/Metadata.kt b/app/src/main/java/com/stevesoltys/seedvault/metadata/Metadata.kt index 362275acd..e30418641 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/metadata/Metadata.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/metadata/Metadata.kt @@ -8,8 +8,12 @@ package com.stevesoltys.seedvault.metadata import android.content.pm.ApplicationInfo.FLAG_STOPPED import android.os.Build import com.stevesoltys.seedvault.crypto.TYPE_METADATA +import com.stevesoltys.seedvault.encodeBase64 import com.stevesoltys.seedvault.header.VERSION import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR +import com.stevesoltys.seedvault.proto.Snapshot +import com.stevesoltys.seedvault.transport.backup.hexFromProto +import com.stevesoltys.seedvault.worker.BASE_SPLIT import org.calyxos.backup.storage.crypto.StreamCrypto.toByteArray import java.nio.ByteBuffer @@ -91,12 +95,51 @@ data class PackageMetadata( internal val version: Long? = null, internal val installer: String? = null, internal val splits: List? = null, + internal val chunkIds: List? = null, // used for v2 internal val sha256: String? = null, internal val signatures: List? = null, ) { + + companion object { + fun fromSnapshot(app: Snapshot.App) = PackageMetadata( + time = app.time, + state = if (app.state.isBlank()) UNKNOWN_ERROR else PackageState.valueOf(app.state), + backupType = when (app.type) { + Snapshot.BackupType.FULL -> BackupType.FULL + Snapshot.BackupType.KV -> BackupType.KV + else -> null + }, + name = app.name, + system = app.system, + isLaunchableSystemApp = app.launchableSystemApp, + version = app.apk.versionCode, + installer = app.apk.installer, + splits = app.apk.splitsList.filter { it.name != BASE_SPLIT }.map { + ApkSplit( + name = it.name, + size = null, + sha256 = "", + chunkIds = if (it.chunkIdsCount == 0) null else it.chunkIdsList.hexFromProto() + ) + }.takeIf { it.isNotEmpty() }, // expected null if there are no splits + chunkIds = run { + val baseChunk = app.apk.splitsList.find { it.name == BASE_SPLIT } + if (baseChunk == null || baseChunk.chunkIdsCount == 0) { + null + } else { + baseChunk.chunkIdsList.hexFromProto() + } + }, + sha256 = null, + signatures = app.apk.signaturesList.map { it.toByteArray().encodeBase64() }, + ) + } + val isInternalSystem: Boolean = system && !isLaunchableSystemApp fun hasApk(): Boolean { - return version != null && sha256 != null && signatures != null + return version != null && + (sha256 != null || chunkIds?.isNotEmpty() == true) && // v2 doesn't use sha256 here + signatures != null } } @@ -104,6 +147,7 @@ data class ApkSplit( val name: String, val size: Long?, val sha256: String, + val chunkIds: List? = null, // used for v2 // There's also a revisionCode, but it doesn't seem to be used just yet ) 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 ab6009600..df7e1aa23 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 @@ -11,14 +11,16 @@ import android.content.Intent 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.RestoreService import com.stevesoltys.seedvault.restore.install.ApkInstallState.FAILED @@ -26,17 +28,22 @@ import com.stevesoltys.seedvault.restore.install.ApkInstallState.FAILED_SYSTEM_A 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.hexFromProto import com.stevesoltys.seedvault.transport.backup.isSystemApp -import com.stevesoltys.seedvault.worker.copyStreamsAndGetHash -import com.stevesoltys.seedvault.worker.getSignatures +import com.stevesoltys.seedvault.transport.restore.Loader +import com.stevesoltys.seedvault.worker.hashSignature import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update +import org.calyxos.seedvault.core.backends.AppBackupFileType 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 @@ -46,6 +53,7 @@ internal class ApkRestore( private val backupManager: IBackupManager, private val backupStateManager: BackupStateManager, private val backendManager: BackendManager, + private val loader: Loader, @Suppress("Deprecation") private val legacyStoragePlugin: LegacyStoragePlugin, private val crypto: Crypto, @@ -130,6 +138,7 @@ internal class ApkRestore( Log.e(TAG, "Timeout while re-installing APK for $packageName.", e) mInstallResult.update { it.fail(packageName) } } catch (e: Exception) { + if (e::class.simpleName == "MockKException") throw e Log.e(TAG, "Unexpected exception while re-installing APK for $packageName.", e) mInstallResult.update { it.fail(packageName) } } @@ -168,10 +177,10 @@ internal class ApkRestore( } // cache the APK and get its hash - val (cachedApk, sha256) = cacheApk(backup.version, backup.token, backup.salt, packageName) + val (cachedApk, sha256) = cacheApk(backup, packageName, metadata.chunkIds) - // check APK's SHA-256 hash - if (metadata.sha256 != sha256) throw SecurityException( + // check APK's SHA-256 hash for backup versions before 2 + if (backup.version < 2 && metadata.sha256 != sha256) throw SecurityException( "Package $packageName has sha256 '$sha256', but '${metadata.sha256}' expected." ) @@ -262,10 +271,9 @@ internal class ApkRestore( } splits.forEach { apkSplit -> // cache and check all splits val suffix = if (backup.version == 0.toByte()) "_${apkSplit.sha256}" else apkSplit.name - val salt = backup.salt - val (file, sha256) = cacheApk(backup.version, backup.token, salt, packageName, suffix) - // check APK split's SHA-256 hash - if (apkSplit.sha256 != sha256) throw SecurityException( + val (file, sha256) = cacheApk(backup, packageName, apkSplit.chunkIds, suffix) + // check APK split's SHA-256 hash for backup versions before 2 + if (backup.version < 2 && apkSplit.sha256 != sha256) throw SecurityException( "$packageName:${apkSplit.name} has sha256 '$sha256'," + " but '${apkSplit.sha256}' expected." ) @@ -282,20 +290,32 @@ internal class ApkRestore( */ @Throws(IOException::class) private suspend fun cacheApk( - version: Byte, - token: Long, - salt: String, + backup: RestorableBackup, packageName: String, + chunkIds: List?, suffix: String = "", ): Pair { // create a cache file to write the APK into val cachedApk = File.createTempFile(packageName + suffix, ".apk", context.cacheDir) // copy APK to cache file and calculate SHA-256 hash while we are at it - val inputStream = if (version == 0.toByte()) { - legacyStoragePlugin.getApkInputStream(token, packageName, suffix) - } else { - val name = crypto.getNameForApk(salt, packageName, suffix) - backend.load(LegacyAppBackupFile.Blob(token, name)) + val inputStream = when (backup.version) { + 0.toByte() -> { + legacyStoragePlugin.getApkInputStream(backup.token, packageName, suffix) + } + 1.toByte() -> { + val name = crypto.getNameForApk(backup.salt, packageName, suffix) + backend.load(LegacyAppBackupFile.Blob(backup.token, name)) + } + else -> { + val repoId = backup.repoId ?: error("No repoId for v2 backup") + val snapshot = backup.snapshot ?: error("No snapshot for v2 backup") + val handles = chunkIds?.map { chunkId -> + val blobId = snapshot.blobsMap[chunkId]?.id?.hexFromProto() + ?: error("Blob for $chunkId missing from snapshot ${snapshot.token}") + AppBackupFileType.Blob(repoId, blobId) + } ?: error("No chunkIds for $packageName-$suffix") + loader.loadFiles(handles) + } } val sha256 = copyStreamsAndGetHash(inputStream, cachedApk.outputStream()) return Pair(cachedApk, sha256) @@ -343,3 +363,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/restore/install/InstallModule.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/install/InstallModule.kt index 8fbeaaaea..7b676c1a5 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/restore/install/InstallModule.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/restore/install/InstallModule.kt @@ -14,7 +14,7 @@ val installModule = module { factory { DeviceInfo(androidContext()) } factory { ApkSplitCompatibilityChecker(get()) } factory { - ApkRestore(androidContext(), get(), get(), get(), get(), get(), get(), get()) { + ApkRestore(androidContext(), get(), get(), get(), get(), get(), get(), get(), get()) { androidContext().getSystemService(UserManager::class.java)!!.isAllowedToInstallApks() } } 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 8292f1f01..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() } } @@ -107,37 +94,15 @@ internal class ApkBackupManager( } /** - * 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() - return - } 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/WorkerModule.kt b/app/src/main/java/com/stevesoltys/seedvault/worker/WorkerModule.kt index 9583724e1..16cd88047 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/worker/WorkerModule.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/worker/WorkerModule.kt @@ -31,9 +31,10 @@ val workerModule = module { single { ApkBackup( pm = androidContext().packageManager, - crypto = get(), + backupReceiver = get(), + appBackupManager = get(), + snapshotManager = get(), settingsManager = get(), - metadataManager = get() ) } single { @@ -44,7 +45,6 @@ val workerModule = module { packageService = get(), apkBackup = get(), iconManager = get(), - backendManager = get(), nm = get() ) } diff --git a/app/src/test/java/com/stevesoltys/seedvault/restore/AppSelectionManagerTest.kt b/app/src/test/java/com/stevesoltys/seedvault/restore/AppSelectionManagerTest.kt index 61739d5f3..cfc501b12 100644 --- a/app/src/test/java/com/stevesoltys/seedvault/restore/AppSelectionManagerTest.kt +++ b/app/src/test/java/com/stevesoltys/seedvault/restore/AppSelectionManagerTest.kt @@ -412,10 +412,7 @@ internal class AppSelectionManagerTest : TransportTest() { } private fun getRestorableBackup(map: Map) = RestorableBackup( - backupMetadata = backupMetadata.copy( - version = 2, - packageMetadataMap = map as PackageMetadataMap, - ), + backupMetadata = backupMetadata.copy(packageMetadataMap = map as PackageMetadataMap), repoId = repoId, snapshot = snapshot, ) diff --git a/app/src/test/java/com/stevesoltys/seedvault/restore/install/ApkBackupRestoreTest.kt b/app/src/test/java/com/stevesoltys/seedvault/restore/install/ApkBackupRestoreTest.kt index cd75e8577..c94dc1882 100644 --- a/app/src/test/java/com/stevesoltys/seedvault/restore/install/ApkBackupRestoreTest.kt +++ b/app/src/test/java/com/stevesoltys/seedvault/restore/install/ApkBackupRestoreTest.kt @@ -14,29 +14,43 @@ import android.content.pm.Signature import android.graphics.drawable.Drawable import android.util.PackageUtils import app.cash.turbine.test +import com.google.protobuf.ByteString +import com.google.protobuf.ByteString.copyFromUtf8 import com.stevesoltys.seedvault.BackupStateManager import com.stevesoltys.seedvault.assertReadEquals +import com.stevesoltys.seedvault.backend.BackendManager +import com.stevesoltys.seedvault.backend.LegacyStoragePlugin +import com.stevesoltys.seedvault.decodeBase64 import com.stevesoltys.seedvault.getRandomString -import com.stevesoltys.seedvault.metadata.ApkSplit import com.stevesoltys.seedvault.metadata.PackageMetadata import com.stevesoltys.seedvault.metadata.PackageMetadataMap -import com.stevesoltys.seedvault.backend.LegacyStoragePlugin -import com.stevesoltys.seedvault.backend.BackendManager +import com.stevesoltys.seedvault.proto.Snapshot import com.stevesoltys.seedvault.restore.RestorableBackup 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.SnapshotManager import com.stevesoltys.seedvault.transport.TransportTest +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.SnapshotCreator +import com.stevesoltys.seedvault.transport.backup.hexFromProto +import com.stevesoltys.seedvault.transport.restore.Loader import com.stevesoltys.seedvault.worker.ApkBackup +import com.stevesoltys.seedvault.worker.BASE_SPLIT +import io.mockk.Runs import io.mockk.coEvery import io.mockk.every +import io.mockk.just import io.mockk.mockk import io.mockk.mockkStatic import io.mockk.slot import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.runBlocking +import org.calyxos.seedvault.core.backends.AppBackupFileType import org.calyxos.seedvault.core.backends.Backend -import org.calyxos.seedvault.core.backends.LegacyAppBackupFile +import org.calyxos.seedvault.core.toHexString import org.junit.jupiter.api.Assertions.assertArrayEquals import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse @@ -48,7 +62,7 @@ import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream import java.io.File import java.io.FileInputStream -import java.io.OutputStream +import java.io.InputStream import java.nio.file.Path import kotlin.random.Random @@ -63,6 +77,11 @@ internal class ApkBackupRestoreTest : TransportTest() { private val backendManager: BackendManager = mockk() private val backupManager: IBackupManager = mockk() private val backupStateManager: BackupStateManager = mockk() + private val backupReceiver: BackupReceiver = mockk() + private val appBackupManager: AppBackupManager = mockk() + private val snapshotManager: SnapshotManager = mockk() + private val snapshotCreator: SnapshotCreator = mockk() + private val loader: Loader = mockk() @Suppress("Deprecation") private val legacyStoragePlugin: LegacyStoragePlugin = mockk() @@ -71,12 +90,14 @@ internal class ApkBackupRestoreTest : TransportTest() { private val apkInstaller: ApkInstaller = mockk() private val installRestriction: InstallRestriction = mockk() - private val apkBackup = ApkBackup(pm, crypto, settingsManager, metadataManager) + private val apkBackup = + ApkBackup(pm, backupReceiver, appBackupManager, snapshotManager, settingsManager) private val apkRestore: ApkRestore = ApkRestore( context = strictContext, backupManager = backupManager, backupStateManager = backupStateManager, backendManager = backendManager, + loader = loader, legacyStoragePlugin = legacyStoragePlugin, crypto = crypto, splitCompatChecker = splitCompatChecker, @@ -90,29 +111,46 @@ internal class ApkBackupRestoreTest : TransportTest() { private val packageName: String = packageInfo.packageName private val splitName = getRandomString() private val splitBytes = byteArrayOf(0x07, 0x08, 0x09) - private val splitSha256 = "ZqZ1cVH47lXbEncWx-Pc4L6AdLZOIO2lQuXB5GypxB4" - private val packageMetadata = PackageMetadata( - time = Random.nextLong(), - version = packageInfo.longVersionCode - 1, - installer = getRandomString(), - sha256 = "eHx5jjmlvBkQNVuubQzYejay4Q_QICqD47trAF2oNHI", - signatures = listOf("AwIB"), - splits = listOf(ApkSplit(splitName, Random.nextLong(), splitSha256)) - ) - private val packageMetadataMap: PackageMetadataMap = hashMapOf(packageName to packageMetadata) - private val installerName = packageMetadata.installer + private val apkChunkId = Random.nextBytes(32).toHexString() + private val splitChunkId = Random.nextBytes(32).toHexString() + private val apkBlob = + Snapshot.Blob.newBuilder().setId(ByteString.copyFrom(Random.nextBytes(32))).build() + private val splitBlob = + Snapshot.Blob.newBuilder().setId(ByteString.copyFrom(Random.nextBytes(32))).build() + private val apkBackupData = BackupData(listOf(apkChunkId), mapOf(apkChunkId to apkBlob)) + private val splitBackupData = BackupData(listOf(splitChunkId), mapOf(splitChunkId to splitBlob)) + private val chunkMap = apkBackupData.chunkMap + splitBackupData.chunkMap + private val baseSplit = Snapshot.Split.newBuilder().setName(BASE_SPLIT) + .addAllChunkIds(listOf(ByteString.fromHex(apkChunkId))).build() + private val apkSplit = Snapshot.Split.newBuilder().setName(splitName) + .addAllChunkIds(listOf(ByteString.fromHex(splitChunkId))).build() + private val apk = Snapshot.Apk.newBuilder() + .setVersionCode(packageInfo.longVersionCode - 1) + .setInstaller(getRandomString()) + .addAllSignatures(mutableListOf(copyFromUtf8("AwIB".decodeBase64()))) + .addSplits(baseSplit) + .addSplits(apkSplit) + .build() + private val app = Snapshot.App.newBuilder() + .setApk(apk) + .build() + private val snapshot = Snapshot.newBuilder() + .setToken(token) + .putApps(packageName, app) + .putAllBlobs(chunkMap) + .build() + private val packageMetadataMap: PackageMetadataMap = + hashMapOf(packageName to PackageMetadata.fromSnapshot(app)) + private val installerName = apk.installer private val icon: Drawable = mockk() private val appName = getRandomString() - private val suffixName = getRandomString() private val outputStream = ByteArrayOutputStream() private val splitOutputStream = ByteArrayOutputStream() - private val outputStreamGetter: suspend (name: String) -> OutputStream = { name -> - if (name == this.name) outputStream else splitOutputStream - } init { mockkStatic(PackageUtils::class) every { backendManager.backend } returns backend + every { appBackupManager.snapshotCreator } returns snapshotCreator } @Test @@ -128,6 +166,7 @@ internal class ApkBackupRestoreTest : TransportTest() { assertTrue(createNewFile()) writeBytes(splitBytes) }.absolutePath) + val capturedApkStream = slot() // related to starting/stopping service every { strictContext.packageName } returns "org.foo.bar" @@ -141,16 +180,19 @@ internal class ApkBackupRestoreTest : TransportTest() { every { sigInfo.hasMultipleSigners() } returns false every { sigInfo.signingCertificateHistory } returns sigs every { PackageUtils.computeSha256DigestBytes(signatureBytes) } returns signatureHash - every { - metadataManager.getPackageMetadata(packageInfo.packageName) - } returns packageMetadata + every { snapshotManager.latestSnapshot } returns snapshot every { pm.getInstallSourceInfo(packageInfo.packageName) } returns mockk(relaxed = true) - every { metadataManager.salt } returns salt - every { crypto.getNameForApk(salt, packageName) } returns name - every { crypto.getNameForApk(salt, packageName, splitName) } returns suffixName - every { backend.providerPackageName } returns storageProviderPackageName + coEvery { backupReceiver.readFromStream(capture(capturedApkStream)) } answers { + capturedApkStream.captured.copyTo(outputStream) + } andThenAnswer { + capturedApkStream.captured.copyTo(splitOutputStream) + } + coEvery { backupReceiver.finalize() } returns apkBackupData andThen splitBackupData + every { + snapshotCreator.onApkBackedUp(packageName, any(), chunkMap) + } just Runs - apkBackup.backupApkIfNecessary(packageInfo, outputStreamGetter) + apkBackup.backupApkIfNecessary(packageInfo) assertArrayEquals(apkBytes, outputStream.toByteArray()) assertArrayEquals(splitBytes, splitOutputStream.toByteArray()) @@ -159,23 +201,23 @@ internal class ApkBackupRestoreTest : TransportTest() { val splitInputStream = ByteArrayInputStream(splitBytes) val apkPath = slot() val cacheFiles = slot>() + val repoId = getRandomString() + val apkHandle = AppBackupFileType.Blob(repoId, apkBlob.id.hexFromProto()) + val splitHandle = AppBackupFileType.Blob(repoId, splitBlob.id.hexFromProto()) + every { backend.providerPackageName } returns storageProviderPackageName every { installRestriction.isAllowedToInstallApks() } returns true every { backupStateManager.isAutoRestoreEnabled } returns false every { pm.getPackageInfo(packageName, any()) } throws NameNotFoundException() every { strictContext.cacheDir } returns tmpFile - every { crypto.getNameForApk(salt, packageName, "") } returns name - coEvery { backend.load(LegacyAppBackupFile.Blob(token, name)) } returns inputStream + coEvery { loader.loadFiles(listOf(apkHandle)) } returns inputStream every { pm.getPackageArchiveInfo(capture(apkPath), any()) } returns packageInfo every { applicationInfo.loadIcon(pm) } returns icon every { pm.getApplicationLabel(packageInfo.applicationInfo) } returns appName every { splitCompatChecker.isCompatible(metadata.deviceName, listOf(splitName)) } returns true - every { crypto.getNameForApk(salt, packageName, splitName) } returns suffixName - coEvery { - backend.load(LegacyAppBackupFile.Blob(token, suffixName)) - } returns splitInputStream + coEvery { loader.loadFiles(listOf(splitHandle)) } returns splitInputStream val resultMap = mapOf( packageName to ApkInstallResult( packageName, @@ -187,7 +229,11 @@ internal class ApkBackupRestoreTest : TransportTest() { apkInstaller.install(capture(cacheFiles), packageName, installerName, any()) } returns InstallResult(resultMap) - val backup = RestorableBackup(metadata.copy(packageMetadataMap = packageMetadataMap)) + val backup = RestorableBackup( + backupMetadata = metadata.copy(packageMetadataMap = packageMetadataMap), + repoId = repoId, + snapshot = snapshot, + ) apkRestore.installResult.test { awaitItem() // initial empty state apkRestore.restore(backup) diff --git a/app/src/test/java/com/stevesoltys/seedvault/restore/install/ApkRestoreTest.kt b/app/src/test/java/com/stevesoltys/seedvault/restore/install/ApkRestoreTest.kt index ea0aeff0c..4236d3b79 100644 --- a/app/src/test/java/com/stevesoltys/seedvault/restore/install/ApkRestoreTest.kt +++ b/app/src/test/java/com/stevesoltys/seedvault/restore/install/ApkRestoreTest.kt @@ -17,15 +17,19 @@ import android.content.pm.PackageManager.NameNotFoundException import android.graphics.drawable.Drawable import app.cash.turbine.TurbineTestContext import app.cash.turbine.test +import com.google.protobuf.ByteString +import com.google.protobuf.ByteString.copyFromUtf8 +import com.google.protobuf.ByteString.fromHex import com.stevesoltys.seedvault.BackupStateManager +import com.stevesoltys.seedvault.backend.BackendManager +import com.stevesoltys.seedvault.backend.LegacyStoragePlugin +import com.stevesoltys.seedvault.decodeBase64 import com.stevesoltys.seedvault.getRandomBase64 -import com.stevesoltys.seedvault.getRandomByteArray import com.stevesoltys.seedvault.getRandomString import com.stevesoltys.seedvault.metadata.ApkSplit import com.stevesoltys.seedvault.metadata.PackageMetadata import com.stevesoltys.seedvault.metadata.PackageMetadataMap -import com.stevesoltys.seedvault.backend.LegacyStoragePlugin -import com.stevesoltys.seedvault.backend.BackendManager +import com.stevesoltys.seedvault.proto.Snapshot import com.stevesoltys.seedvault.restore.RestorableBackup import com.stevesoltys.seedvault.restore.install.ApkInstallState.FAILED import com.stevesoltys.seedvault.restore.install.ApkInstallState.FAILED_SYSTEM_APP @@ -33,7 +37,10 @@ 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.TransportTest -import com.stevesoltys.seedvault.worker.getSignatures +import com.stevesoltys.seedvault.transport.backup.BackupData +import com.stevesoltys.seedvault.transport.backup.hexFromProto +import com.stevesoltys.seedvault.transport.restore.Loader +import com.stevesoltys.seedvault.worker.BASE_SPLIT import io.mockk.Runs import io.mockk.coEvery import io.mockk.every @@ -43,8 +50,9 @@ import io.mockk.mockkStatic import io.mockk.verifyOrder import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.runBlocking +import org.calyxos.seedvault.core.backends.AppBackupFileType import org.calyxos.seedvault.core.backends.Backend -import org.calyxos.seedvault.core.backends.LegacyAppBackupFile +import org.calyxos.seedvault.core.toHexString import org.junit.jupiter.api.Assertions import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse @@ -68,6 +76,7 @@ internal class ApkRestoreTest : TransportTest() { private val backupManager: IBackupManager = mockk() private val backupStateManager: BackupStateManager = mockk() private val backendManager: BackendManager = mockk() + private val loader: Loader = mockk() private val backend: Backend = mockk() private val legacyStoragePlugin: LegacyStoragePlugin = mockk() private val splitCompatChecker: ApkSplitCompatibilityChecker = mockk() @@ -79,6 +88,7 @@ internal class ApkRestoreTest : TransportTest() { backupManager = backupManager, backupStateManager = backupStateManager, backendManager = backendManager, + loader = loader, legacyStoragePlugin = legacyStoragePlugin, crypto = crypto, splitCompatChecker = splitCompatChecker, @@ -90,20 +100,39 @@ internal class ApkRestoreTest : TransportTest() { private val deviceName = metadata.deviceName private val packageName = packageInfo.packageName - private val packageMetadata = PackageMetadata( - time = Random.nextLong(), - version = packageInfo.longVersionCode - 1, - installer = getRandomString(), - sha256 = "eHx5jjmlvBkQNVuubQzYejay4Q_QICqD47trAF2oNHI", - signatures = listOf("AwIB") - ) - private val packageMetadataMap: PackageMetadataMap = hashMapOf(packageName to packageMetadata) private val apkBytes = byteArrayOf(0x04, 0x05, 0x06) private val apkInputStream = ByteArrayInputStream(apkBytes) private val appName = getRandomString() + private val repoId = Random.nextBytes(32).toHexString() + private val apkChunkId = Random.nextBytes(32).toHexString() + private val apkBlob = + Snapshot.Blob.newBuilder().setId(ByteString.copyFrom(Random.nextBytes(32))).build() + private val apkBlobHandle = AppBackupFileType.Blob(repoId, apkBlob.id.hexFromProto()) + private val apkBackupData = BackupData(listOf(apkChunkId), mapOf(apkChunkId to apkBlob)) + private val baseSplit = Snapshot.Split.newBuilder().setName(BASE_SPLIT) + .addAllChunkIds(listOf(ByteString.fromHex(apkChunkId))).build() + private val apk = Snapshot.Apk.newBuilder() + .setVersionCode(packageInfo.longVersionCode - 1) + .setInstaller(getRandomString()) + .addAllSignatures(mutableListOf(copyFromUtf8("AwIB".decodeBase64()))) + .addSplits(baseSplit) + .build() + private val app = Snapshot.App.newBuilder() + .setApk(apk) + .build() + private val snapshot = Snapshot.newBuilder() + .setToken(token) + .putApps(packageName, app) + .putAllBlobs(apkBackupData.chunkMap) + .build() + private val packageMetadata = PackageMetadata.fromSnapshot(app) + private val packageMetadataMap: PackageMetadataMap = hashMapOf(packageName to packageMetadata) private val installerName = packageMetadata.installer - private val backup = RestorableBackup(metadata.copy(packageMetadataMap = packageMetadataMap)) - private val suffixName = getRandomString() + private val backup = RestorableBackup( + repoId = repoId, + snapshot = snapshot, + backupMetadata = metadata.copy(packageMetadataMap = packageMetadataMap), + ) init { // as we don't do strict signature checking, we can use a relaxed mock @@ -119,26 +148,6 @@ internal class ApkRestoreTest : TransportTest() { every { strictContext.stopService(any()) } returns true } - @Test - fun `signature mismatch causes FAILED status`(@TempDir tmpDir: Path) = runBlocking { - // change SHA256 signature to random - val packageMetadata = packageMetadata.copy(sha256 = getRandomString()) - val backup = swapPackages(hashMapOf(packageName to packageMetadata)) - - every { installRestriction.isAllowedToInstallApks() } returns true - every { backupStateManager.isAutoRestoreEnabled } returns false - every { strictContext.cacheDir } returns File(tmpDir.toString()) - every { crypto.getNameForApk(salt, packageName, "") } returns name - coEvery { backend.load(handle) } returns apkInputStream - every { backend.providerPackageName } returns storageProviderPackageName - - apkRestore.installResult.test { - awaitItem() // initial empty state - apkRestore.restore(backup) - assertQueuedFailFinished() - } - } - @Test fun `test app without APK does not attempt install`(@TempDir tmpDir: Path) = runBlocking { // remove all APK info @@ -201,9 +210,9 @@ internal class ApkRestoreTest : TransportTest() { every { installRestriction.isAllowedToInstallApks() } returns true every { backupStateManager.isAutoRestoreEnabled } returns false + every { pm.getPackageInfo(packageName, any()) } throws NameNotFoundException() every { strictContext.cacheDir } returns File(tmpDir.toString()) - every { crypto.getNameForApk(salt, packageName, "") } returns name - coEvery { backend.load(handle) } returns apkInputStream + coEvery { loader.loadFiles(listOf(apkBlobHandle)) } returns apkInputStream every { pm.getPackageArchiveInfo(any(), any()) } returns packageInfo every { backend.providerPackageName } returns storageProviderPackageName @@ -259,46 +268,10 @@ internal class ApkRestoreTest : TransportTest() { } } - @Test - fun `v0 test successful run`(@TempDir tmpDir: Path) = runBlocking { - // This is a legacy backup with version 0 - val backup = backup.copy(backupMetadata = backup.backupMetadata.copy(version = 0)) - // Install will be successful - val packagesMap = mapOf( - packageName to ApkInstallResult( - packageName, - state = SUCCEEDED, - metadata = PackageMetadata(), - ) - ) - val installResult = InstallResult(packagesMap) - - every { installRestriction.isAllowedToInstallApks() } returns true - every { backupStateManager.isAutoRestoreEnabled } returns false - every { pm.getPackageInfo(packageName, any()) } throws NameNotFoundException() - every { strictContext.cacheDir } returns File(tmpDir.toString()) - coEvery { - legacyStoragePlugin.getApkInputStream(token, packageName, "") - } returns apkInputStream - every { pm.getPackageArchiveInfo(any(), any()) } returns packageInfo - every { applicationInfo.loadIcon(pm) } returns icon - every { pm.getApplicationLabel(packageInfo.applicationInfo) } returns appName - coEvery { - apkInstaller.install(match { it.size == 1 }, packageName, installerName, any()) - } returns installResult - every { backend.providerPackageName } returns storageProviderPackageName - - apkRestore.installResult.test { - awaitItem() // initial empty state - apkRestore.restore(backup) - assertQueuedProgressSuccessFinished() - } - } - @Test fun `test app only installed not already installed`(@TempDir tmpDir: Path) = runBlocking { val packageInfo: PackageInfo = mockk() - mockkStatic("com.stevesoltys.seedvault.worker.ApkBackupKt") + mockkStatic("com.stevesoltys.seedvault.restore.install.ApkRestoreKt") every { installRestriction.isAllowedToInstallApks() } returns true every { backupStateManager.isAutoRestoreEnabled } returns false every { backend.providerPackageName } returns storageProviderPackageName @@ -327,7 +300,7 @@ internal class ApkRestoreTest : TransportTest() { fun `test app still installed if older version is installed`(@TempDir tmpDir: Path) = runBlocking { val packageInfo: PackageInfo = mockk() - mockkStatic("com.stevesoltys.seedvault.worker.ApkBackupKt") + mockkStatic("com.stevesoltys.seedvault.restore.install.ApkRestoreKt") every { installRestriction.isAllowedToInstallApks() } returns true every { backupStateManager.isAutoRestoreEnabled } returns false every { backend.providerPackageName } returns storageProviderPackageName @@ -367,7 +340,7 @@ internal class ApkRestoreTest : TransportTest() { @Test fun `test app fails if installed with different signer`(@TempDir tmpDir: Path) = runBlocking { val packageInfo: PackageInfo = mockk() - mockkStatic("com.stevesoltys.seedvault.worker.ApkBackupKt") + mockkStatic("com.stevesoltys.seedvault.restore.install.ApkRestoreKt") every { installRestriction.isAllowedToInstallApks() } returns true every { backupStateManager.isAutoRestoreEnabled } returns false every { backend.providerPackageName } returns storageProviderPackageName @@ -486,56 +459,32 @@ internal class ApkRestoreTest : TransportTest() { } } - @Test - fun `split signature mismatch causes FAILED state`(@TempDir tmpDir: Path) = runBlocking { - // add one APK split to metadata - val splitName = getRandomString() - packageMetadataMap[packageName] = packageMetadataMap[packageName]!!.copy( - splits = listOf(ApkSplit(splitName, Random.nextLong(), getRandomBase64(23))) - ) - - every { installRestriction.isAllowedToInstallApks() } returns true - every { backupStateManager.isAutoRestoreEnabled } returns false - every { pm.getPackageInfo(packageName, any()) } throws NameNotFoundException() - // cache APK and get icon as well as app name - cacheBaseApkAndGetInfo(tmpDir) - - every { splitCompatChecker.isCompatible(deviceName, listOf(splitName)) } returns true - every { crypto.getNameForApk(salt, packageName, splitName) } returns suffixName - coEvery { - backend.load(LegacyAppBackupFile.Blob(token, suffixName)) - } returns ByteArrayInputStream(getRandomByteArray()) - every { backend.providerPackageName } returns storageProviderPackageName - - apkRestore.installResult.test { - awaitItem() // initial empty state - apkRestore.restore(backup) - assertQueuedProgressFailFinished() - } - } - @Test fun `exception while getting split data causes FAILED state`(@TempDir tmpDir: Path) = runBlocking { // add one APK split to metadata val splitName = getRandomString() val sha256 = getRandomBase64(23) + val splitChunkId = Random.nextBytes(32).toHexString() + val splitBlobId = Random.nextBytes(32).toHexString() + val split = ApkSplit(splitName, Random.nextLong(), sha256, listOf(splitChunkId)) packageMetadataMap[packageName] = packageMetadataMap[packageName]!!.copy( - splits = listOf(ApkSplit(splitName, Random.nextLong(), sha256)) + splits = listOf(split) ) + val blobHandle = AppBackupFileType.Blob(repoId, splitBlobId) + val splitBlob = Snapshot.Blob.newBuilder().setId(fromHex(splitBlobId)).build() + val snapshot = snapshot.toBuilder().putBlobs(splitChunkId, splitBlob).build() + val backup = backup.copy(snapshot = snapshot) every { installRestriction.isAllowedToInstallApks() } returns true every { backupStateManager.isAutoRestoreEnabled } returns false + every { backend.providerPackageName } returns storageProviderPackageName every { pm.getPackageInfo(packageName, any()) } throws NameNotFoundException() // cache APK and get icon as well as app name cacheBaseApkAndGetInfo(tmpDir) every { splitCompatChecker.isCompatible(deviceName, listOf(splitName)) } returns true - every { crypto.getNameForApk(salt, packageName, splitName) } returns suffixName - coEvery { - backend.load(LegacyAppBackupFile.Blob(token, suffixName)) - } throws IOException() - every { backend.providerPackageName } returns storageProviderPackageName + coEvery { loader.loadFiles(listOf(blobHandle)) } throws IOException() apkRestore.installResult.test { awaitItem() // initial empty state @@ -549,17 +498,31 @@ internal class ApkRestoreTest : TransportTest() { // add one APK split to metadata val split1Name = getRandomString() val split2Name = getRandomString() - val split1sha256 = "A5BYxvLAy0ksUzsKTRTvd8wPeKvMztUofYShogEc-4E" - val split2sha256 = "ZqZ1cVH47lXbEncWx-Pc4L6AdLZOIO2lQuXB5GypxB4" - packageMetadataMap[packageName] = packageMetadataMap[packageName]!!.copy( - splits = listOf( - ApkSplit(split1Name, Random.nextLong(), split1sha256), - ApkSplit(split2Name, Random.nextLong(), split2sha256) - ) - ) + val splitChunkId1 = Random.nextBytes(32).toHexString() + val splitChunkId2 = Random.nextBytes(32).toHexString() + val apkSplit1 = Snapshot.Split.newBuilder().setName(split1Name) + .addAllChunkIds(listOf(ByteString.fromHex(splitChunkId1))).build() + val apkSplit2 = Snapshot.Split.newBuilder().setName(split2Name) + .addAllChunkIds(listOf(ByteString.fromHex(splitChunkId2))).build() + val splitBlob1 = + Snapshot.Blob.newBuilder().setId(ByteString.copyFrom(Random.nextBytes(32))).build() + val splitBlob2 = + Snapshot.Blob.newBuilder().setId(ByteString.copyFrom(Random.nextBytes(32))).build() + val apk = apk.toBuilder().addSplits(apkSplit1).addSplits(apkSplit2).build() + val app = app.toBuilder().setApk(apk).build() + val blobMap = apkBackupData.chunkMap + + mapOf(splitChunkId1 to splitBlob1) + + mapOf(splitChunkId2 to splitBlob2) + val snapshot = snapshot.toBuilder() + .putApps(packageName, app) + .putAllBlobs(blobMap) + .build() + packageMetadataMap[packageName] = PackageMetadata.fromSnapshot(app) + val backup = backup.copy(snapshot = snapshot) every { installRestriction.isAllowedToInstallApks() } returns true every { backupStateManager.isAutoRestoreEnabled } returns false + every { backend.providerPackageName } returns storageProviderPackageName every { pm.getPackageInfo(packageName, any()) } throws NameNotFoundException() // cache APK and get icon as well as app name cacheBaseApkAndGetInfo(tmpDir) @@ -573,17 +536,10 @@ internal class ApkRestoreTest : TransportTest() { val split2Bytes = byteArrayOf(0x07, 0x08, 0x09) val split1InputStream = ByteArrayInputStream(split1Bytes) val split2InputStream = ByteArrayInputStream(split2Bytes) - val suffixName1 = getRandomString() - val suffixName2 = getRandomString() - every { crypto.getNameForApk(salt, packageName, split1Name) } returns suffixName1 - coEvery { - backend.load(LegacyAppBackupFile.Blob(token, suffixName1)) - } returns split1InputStream - every { crypto.getNameForApk(salt, packageName, split2Name) } returns suffixName2 - coEvery { - backend.load(LegacyAppBackupFile.Blob(token, suffixName2)) - } returns split2InputStream - every { backend.providerPackageName } returns storageProviderPackageName + val splitHandle1 = AppBackupFileType.Blob(repoId, splitBlob1.id.hexFromProto()) + val splitHandle2 = AppBackupFileType.Blob(repoId, splitBlob2.id.hexFromProto()) + coEvery { loader.loadFiles(listOf(splitHandle1)) } returns split1InputStream + coEvery { loader.loadFiles(listOf(splitHandle2)) } returns split2InputStream val resultMap = mapOf( packageName to ApkInstallResult( @@ -709,8 +665,7 @@ internal class ApkRestoreTest : TransportTest() { private fun cacheBaseApkAndGetInfo(tmpDir: Path) { every { strictContext.cacheDir } returns File(tmpDir.toString()) - every { crypto.getNameForApk(salt, packageName, "") } returns name - coEvery { backend.load(handle) } returns apkInputStream + coEvery { loader.loadFiles(listOf(apkBlobHandle)) } returns apkInputStream every { pm.getPackageArchiveInfo(any(), any()) } returns packageInfo every { applicationInfo.loadIcon(pm) } returns icon every { pm.getApplicationLabel(packageInfo.applicationInfo) } returns appName @@ -718,6 +673,14 @@ internal class ApkRestoreTest : TransportTest() { private suspend fun TurbineTestContext.assertQueuedFailFinished() { awaitQueuedItem() + awaitItem().also { item -> + val result = item[packageName] + assertEquals(IN_PROGRESS, result.state) + assertFalse(item.hasFailed) + assertEquals(1, item.total) + assertEquals(1, item.list.size) + assertNull(result.icon) + } awaitItem().also { failedItem -> val result = failedItem[packageName] assertEquals(FAILED, result.state) @@ -796,6 +759,6 @@ internal class ApkRestoreTest : TransportTest() { } -private operator fun InstallResult.get(packageName: String): ApkInstallResult { +internal operator fun InstallResult.get(packageName: String): ApkInstallResult { return this.installResults[packageName] ?: Assertions.fail("$packageName not found") } diff --git a/app/src/test/java/com/stevesoltys/seedvault/restore/install/ApkRestoreV1Test.kt b/app/src/test/java/com/stevesoltys/seedvault/restore/install/ApkRestoreV1Test.kt new file mode 100644 index 000000000..3cfeccb0f --- /dev/null +++ b/app/src/test/java/com/stevesoltys/seedvault/restore/install/ApkRestoreV1Test.kt @@ -0,0 +1,860 @@ +/* + * SPDX-FileCopyrightText: 2024 The Calyx Institute + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.stevesoltys.seedvault.restore.install + +import android.app.backup.IBackupManager +import android.content.ComponentName +import android.content.Context +import android.content.pm.ApplicationInfo.FLAG_INSTALLED +import android.content.pm.ApplicationInfo.FLAG_SYSTEM +import android.content.pm.ApplicationInfo.FLAG_UPDATED_SYSTEM_APP +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import android.content.pm.PackageManager.NameNotFoundException +import android.graphics.drawable.Drawable +import app.cash.turbine.TurbineTestContext +import app.cash.turbine.test +import com.stevesoltys.seedvault.BackupStateManager +import com.stevesoltys.seedvault.backend.BackendManager +import com.stevesoltys.seedvault.backend.LegacyStoragePlugin +import com.stevesoltys.seedvault.getRandomBase64 +import com.stevesoltys.seedvault.getRandomByteArray +import com.stevesoltys.seedvault.getRandomString +import com.stevesoltys.seedvault.metadata.ApkSplit +import com.stevesoltys.seedvault.metadata.PackageMetadata +import com.stevesoltys.seedvault.metadata.PackageMetadataMap +import com.stevesoltys.seedvault.restore.RestorableBackup +import com.stevesoltys.seedvault.restore.install.ApkInstallState.FAILED +import com.stevesoltys.seedvault.restore.install.ApkInstallState.FAILED_SYSTEM_APP +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.TransportTest +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.mockkStatic +import io.mockk.verifyOrder +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.runBlocking +import org.calyxos.seedvault.core.backends.Backend +import org.calyxos.seedvault.core.backends.LegacyAppBackupFile +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.ByteArrayInputStream +import java.io.File +import java.io.IOException +import java.nio.file.Path +import kotlin.random.Random + +@ExperimentalCoroutinesApi +@Suppress("DEPRECATION") +internal class ApkRestoreV1Test : TransportTest() { + + private val pm: PackageManager = mockk() + private val strictContext: Context = mockk().apply { + every { packageManager } returns pm + } + private val backupManager: IBackupManager = mockk() + private val backupStateManager: BackupStateManager = mockk() + private val backendManager: BackendManager = mockk() + private val loader: Loader = mockk() + private val backend: Backend = mockk() + private val legacyStoragePlugin: LegacyStoragePlugin = mockk() + private val splitCompatChecker: ApkSplitCompatibilityChecker = mockk() + private val apkInstaller: ApkInstaller = mockk() + private val installRestriction: InstallRestriction = mockk() + + private val apkRestore: ApkRestore = ApkRestore( + context = strictContext, + backupManager = backupManager, + backupStateManager = backupStateManager, + backendManager = backendManager, + loader = loader, + legacyStoragePlugin = legacyStoragePlugin, + crypto = crypto, + splitCompatChecker = splitCompatChecker, + apkInstaller = apkInstaller, + installRestriction = installRestriction, + ) + + private val icon: Drawable = mockk() + + private val deviceName = metadata.deviceName + private val packageName = packageInfo.packageName + private val packageMetadata = PackageMetadata( + time = Random.nextLong(), + version = packageInfo.longVersionCode - 1, + installer = getRandomString(), + sha256 = "eHx5jjmlvBkQNVuubQzYejay4Q_QICqD47trAF2oNHI", + signatures = listOf("AwIB") + ) + private val packageMetadataMap: PackageMetadataMap = hashMapOf(packageName to packageMetadata) + private val apkBytes = byteArrayOf(0x04, 0x05, 0x06) + private val apkInputStream = ByteArrayInputStream(apkBytes) + private val appName = getRandomString() + private val installerName = packageMetadata.installer + private val backup = + RestorableBackup(metadata.copy(version = 1, packageMetadataMap = packageMetadataMap)) + private val suffixName = getRandomString() + + init { + // as we don't do strict signature checking, we can use a relaxed mock + packageInfo.signingInfo = mockk(relaxed = true) + + every { backendManager.backend } returns backend + + // related to starting/stopping service + every { strictContext.packageName } returns "org.foo.bar" + every { + strictContext.startService(any()) + } returns ComponentName(strictContext, "org.foo.bar.Class") + every { strictContext.stopService(any()) } returns true + } + + @Test + fun `sha256 mismatch causes FAILED status`(@TempDir tmpDir: Path) = runBlocking { + // change SHA256 signature to random + val packageMetadata = packageMetadata.copy(sha256 = getRandomString()) + val backup = swapPackages(hashMapOf(packageName to packageMetadata)) + + every { installRestriction.isAllowedToInstallApks() } returns true + every { backupStateManager.isAutoRestoreEnabled } returns false + every { strictContext.cacheDir } returns File(tmpDir.toString()) + every { crypto.getNameForApk(salt, packageName, "") } returns name + coEvery { backend.load(handle) } returns apkInputStream + every { backend.providerPackageName } returns storageProviderPackageName + every { pm.getPackageInfo(packageName, any()) } throws NameNotFoundException() + + apkRestore.installResult.test { + awaitItem() // initial empty state + apkRestore.restore(backup) + assertQueuedFailFinished() + } + } + + @Test + fun `test app without APK does not attempt install`(@TempDir tmpDir: Path) = runBlocking { + // remove all APK info + val packageMetadata = packageMetadata.copy( + version = null, + installer = null, + sha256 = null, + signatures = null, + ) + val backup = swapPackages(hashMapOf(packageName to packageMetadata)) + + every { installRestriction.isAllowedToInstallApks() } returns true + every { backupStateManager.isAutoRestoreEnabled } returns false + every { backend.providerPackageName } returns storageProviderPackageName + every { + pm.getPackageInfo( + packageName, + any() + ) + } throws NameNotFoundException() + + apkRestore.installResult.test { + awaitItem() // initial empty state + apkRestore.restore(backup) + assertEquals(QUEUED, awaitItem()[packageName].state) + assertEquals(FAILED, awaitItem()[packageName].state) + assertTrue(awaitItem().isFinished) + ensureAllEventsConsumed() + } + } + + @Test + fun `test app without APK succeeds if installed`(@TempDir tmpDir: Path) = runBlocking { + // remove all APK info + val packageMetadata = packageMetadata.copy( + version = null, + installer = null, + sha256 = null, + signatures = null, + ) + val backup = swapPackages(hashMapOf(packageName to packageMetadata)) + + every { installRestriction.isAllowedToInstallApks() } returns true + every { backupStateManager.isAutoRestoreEnabled } returns false + every { backend.providerPackageName } returns storageProviderPackageName + + val packageInfo: PackageInfo = mockk() + every { pm.getPackageInfo(packageName, any()) } returns packageInfo + every { packageInfo.longVersionCode } returns 42 + + apkRestore.installResult.test { + awaitItem() // initial empty state + apkRestore.restore(backup) + assertEquals(QUEUED, awaitItem()[packageName].state) + assertEquals(SUCCEEDED, awaitItem()[packageName].state) + assertTrue(awaitItem().isFinished) + ensureAllEventsConsumed() + } + } + + @Test + fun `package name mismatch causes FAILED status`(@TempDir tmpDir: Path) = runBlocking { + // change package name to random string + packageInfo.packageName = getRandomString() + + every { installRestriction.isAllowedToInstallApks() } returns true + every { backupStateManager.isAutoRestoreEnabled } returns false + every { pm.getPackageInfo(packageName, any()) } throws NameNotFoundException() + every { strictContext.cacheDir } returns File(tmpDir.toString()) + every { crypto.getNameForApk(salt, packageName, "") } returns name + coEvery { backend.load(handle) } returns apkInputStream + every { pm.getPackageArchiveInfo(any(), any()) } returns packageInfo + every { backend.providerPackageName } returns storageProviderPackageName + + apkRestore.installResult.test { + awaitItem() // initial empty state + apkRestore.restore(backup) + assertQueuedFailFinished() + } + } + + @Test + fun `test apkInstaller throws exceptions`(@TempDir tmpDir: Path) = runBlocking { + every { installRestriction.isAllowedToInstallApks() } returns true + every { backupStateManager.isAutoRestoreEnabled } returns false + every { + pm.getPackageInfo( + packageName, + any() + ) + } throws NameNotFoundException() + cacheBaseApkAndGetInfo(tmpDir) + coEvery { + apkInstaller.install(match { it.size == 1 }, packageName, installerName, any()) + } throws SecurityException() + every { backend.providerPackageName } returns storageProviderPackageName + + apkRestore.installResult.test { + awaitItem() // initial empty state + apkRestore.restore(backup) + assertQueuedProgressFailFinished() + } + } + + @Test + fun `test successful run`(@TempDir tmpDir: Path) = runBlocking { + val packagesMap = mapOf( + packageName to ApkInstallResult( + packageName, + state = SUCCEEDED, + metadata = PackageMetadata(), + ) + ) + val installResult = InstallResult(packagesMap) + + every { installRestriction.isAllowedToInstallApks() } returns true + every { backupStateManager.isAutoRestoreEnabled } returns false + every { + pm.getPackageInfo( + packageName, + any() + ) + } throws NameNotFoundException() + cacheBaseApkAndGetInfo(tmpDir) + coEvery { + apkInstaller.install(match { it.size == 1 }, packageName, installerName, any()) + } returns installResult + every { backend.providerPackageName } returns storageProviderPackageName + + apkRestore.installResult.test { + awaitItem() // initial empty state + apkRestore.restore(backup) + assertQueuedProgressSuccessFinished() + } + } + + @Test + fun `v0 test successful run`(@TempDir tmpDir: Path) = runBlocking { + // This is a legacy backup with version 0 + val backup = backup.copy(backupMetadata = backup.backupMetadata.copy(version = 0)) + // Install will be successful + val packagesMap = mapOf( + packageName to ApkInstallResult( + packageName, + state = SUCCEEDED, + metadata = PackageMetadata(), + ) + ) + val installResult = InstallResult(packagesMap) + + every { installRestriction.isAllowedToInstallApks() } returns true + every { backupStateManager.isAutoRestoreEnabled } returns false + every { + pm.getPackageInfo( + packageName, + any() + ) + } throws NameNotFoundException() + every { strictContext.cacheDir } returns File(tmpDir.toString()) + coEvery { + legacyStoragePlugin.getApkInputStream(token, packageName, "") + } returns apkInputStream + every { pm.getPackageArchiveInfo(any(), any()) } returns packageInfo + every { applicationInfo.loadIcon(pm) } returns icon + every { pm.getApplicationLabel(packageInfo.applicationInfo) } returns appName + coEvery { + apkInstaller.install(match { it.size == 1 }, packageName, installerName, any()) + } returns installResult + every { backend.providerPackageName } returns storageProviderPackageName + + apkRestore.installResult.test { + awaitItem() // initial empty state + apkRestore.restore(backup) + assertQueuedProgressSuccessFinished() + } + } + + @Test + fun `test app only installed not already installed`(@TempDir tmpDir: Path) = runBlocking { + val packageInfo: PackageInfo = mockk() + mockkStatic("com.stevesoltys.seedvault.restore.install.ApkRestoreKt") + every { installRestriction.isAllowedToInstallApks() } returns true + every { backupStateManager.isAutoRestoreEnabled } returns false + every { backend.providerPackageName } returns storageProviderPackageName + every { pm.getPackageInfo(packageName, any()) } returns packageInfo + every { packageInfo.signingInfo.getSignatures() } returns packageMetadata.signatures!! + every { + packageInfo.longVersionCode + } returns packageMetadata.version!! + Random.nextLong(0, 2) // can be newer + + apkRestore.installResult.test { + awaitItem() // initial empty state + apkRestore.restore(backup) + awaitQueuedItem() + awaitItem().also { systemItem -> + val result = systemItem[packageName] + assertEquals(SUCCEEDED, result.state) + } + awaitItem().also { finishedItem -> + assertTrue(finishedItem.isFinished) + } + ensureAllEventsConsumed() + } + } + + @Test + fun `test app still installed if older version is installed`(@TempDir tmpDir: Path) = + runBlocking { + val packageInfo: PackageInfo = mockk() + mockkStatic("com.stevesoltys.seedvault.restore.install.ApkRestoreKt") + every { installRestriction.isAllowedToInstallApks() } returns true + every { backupStateManager.isAutoRestoreEnabled } returns false + every { backend.providerPackageName } returns storageProviderPackageName + every { pm.getPackageInfo(packageName, any()) } returns packageInfo + every { packageInfo.signingInfo.getSignatures() } returns packageMetadata.signatures!! + every { packageInfo.longVersionCode } returns packageMetadata.version!! - 1 + + cacheBaseApkAndGetInfo(tmpDir) + val packagesMap = mapOf( + packageName to ApkInstallResult( + packageName, + state = SUCCEEDED, + metadata = PackageMetadata(), + ) + ) + val installResult = InstallResult(packagesMap) + coEvery { + apkInstaller.install(match { it.size == 1 }, packageName, installerName, any()) + } returns installResult + + apkRestore.installResult.test { + awaitItem() // initial empty state + apkRestore.restore(backup) + awaitQueuedItem() + awaitInProgressItem() + awaitItem().also { systemItem -> + val result = systemItem[packageName] + assertEquals(SUCCEEDED, result.state) + } + awaitItem().also { finishedItem -> + assertTrue(finishedItem.isFinished) + } + ensureAllEventsConsumed() + } + } + + @Test + fun `test app fails if installed with different signer`(@TempDir tmpDir: Path) = runBlocking { + val packageInfo: PackageInfo = mockk() + mockkStatic("com.stevesoltys.seedvault.restore.install.ApkRestoreKt") + every { installRestriction.isAllowedToInstallApks() } returns true + every { backupStateManager.isAutoRestoreEnabled } returns false + every { backend.providerPackageName } returns storageProviderPackageName + every { pm.getPackageInfo(packageName, any()) } returns packageInfo + every { packageInfo.signingInfo.getSignatures() } returns listOf("foobar") + + apkRestore.installResult.test { + awaitItem() // initial empty state + apkRestore.restore(backup) + awaitQueuedItem() + awaitItem().also { systemItem -> + val result = systemItem[packageName] + assertEquals(FAILED, result.state) + } + awaitItem().also { finishedItem -> + assertTrue(finishedItem.isFinished) + } + ensureAllEventsConsumed() + } + } + + @Test + fun `test system apps only reinstalled when older system apps exist`(@TempDir tmpDir: Path) = + runBlocking { + val packageMetadata = this@ApkRestoreV1Test.packageMetadata.copy(system = true) + packageMetadataMap[packageName] = packageMetadata + val installedPackageInfo: PackageInfo = mockk() + val willFail = Random.nextBoolean() + val isSystemApp = Random.nextBoolean() + + every { installRestriction.isAllowedToInstallApks() } returns true + every { backupStateManager.isAutoRestoreEnabled } returns false + every { + pm.getPackageInfo( + packageName, + any() + ) + } throws NameNotFoundException() + cacheBaseApkAndGetInfo(tmpDir) + every { backend.providerPackageName } returns storageProviderPackageName + + if (willFail) { + every { + pm.getPackageInfo(packageName, 0) + } throws NameNotFoundException() + } else { + installedPackageInfo.applicationInfo = mockk { + flags = + if (!isSystemApp) FLAG_INSTALLED else FLAG_SYSTEM or FLAG_UPDATED_SYSTEM_APP + } + every { pm.getPackageInfo(packageName, 0) } returns installedPackageInfo + every { installedPackageInfo.longVersionCode } returns packageMetadata.version!! - 1 + if (isSystemApp) { // if the installed app is not a system app, we don't install + val packagesMap = mapOf( + packageName to ApkInstallResult( + packageName, + state = SUCCEEDED, + metadata = PackageMetadata(), + ) + ) + val installResult = InstallResult(packagesMap) + coEvery { + apkInstaller.install( + match { it.size == 1 }, + packageName, + installerName, + any() + ) + } returns installResult + } + } + + apkRestore.installResult.test { + awaitItem() // initial empty state + apkRestore.restore(backup) + awaitQueuedItem() + awaitInProgressItem() + awaitItem().also { systemItem -> + val result = systemItem[packageName] + if (willFail) { + assertEquals(FAILED_SYSTEM_APP, result.state) + } else { + assertEquals(SUCCEEDED, result.state) + } + } + awaitItem().also { finishedItem -> + assertTrue(finishedItem.isFinished) + } + ensureAllEventsConsumed() + } + } + + @Test + fun `incompatible splits cause FAILED state`(@TempDir tmpDir: Path) = runBlocking { + // add one APK split to metadata + val split1Name = getRandomString() + val split2Name = getRandomString() + packageMetadataMap[packageName] = packageMetadataMap[packageName]!!.copy( + splits = listOf( + ApkSplit(split1Name, Random.nextLong(), getRandomBase64()), + ApkSplit(split2Name, Random.nextLong(), getRandomBase64()) + ) + ) + + every { installRestriction.isAllowedToInstallApks() } returns true + every { backupStateManager.isAutoRestoreEnabled } returns false + every { + pm.getPackageInfo( + packageName, + any() + ) + } throws NameNotFoundException() + // cache APK and get icon as well as app name + cacheBaseApkAndGetInfo(tmpDir) + + // splits are NOT compatible + every { + splitCompatChecker.isCompatible(deviceName, listOf(split1Name, split2Name)) + } returns false + every { backend.providerPackageName } returns storageProviderPackageName + + apkRestore.installResult.test { + awaitItem() // initial empty state + apkRestore.restore(backup) + assertQueuedProgressFailFinished() + } + } + + @Test + fun `split signature mismatch causes FAILED state`(@TempDir tmpDir: Path) = runBlocking { + // add one APK split to metadata + val splitName = getRandomString() + packageMetadataMap[packageName] = packageMetadataMap[packageName]!!.copy( + splits = listOf(ApkSplit(splitName, Random.nextLong(), getRandomBase64(23))) + ) + + every { installRestriction.isAllowedToInstallApks() } returns true + every { backupStateManager.isAutoRestoreEnabled } returns false + every { + pm.getPackageInfo( + packageName, + any() + ) + } throws NameNotFoundException() + // cache APK and get icon as well as app name + cacheBaseApkAndGetInfo(tmpDir) + + every { splitCompatChecker.isCompatible(deviceName, listOf(splitName)) } returns true + every { crypto.getNameForApk(salt, packageName, splitName) } returns suffixName + coEvery { + backend.load(LegacyAppBackupFile.Blob(token, suffixName)) + } returns ByteArrayInputStream(getRandomByteArray()) + every { backend.providerPackageName } returns storageProviderPackageName + + apkRestore.installResult.test { + awaitItem() // initial empty state + apkRestore.restore(backup) + assertQueuedProgressFailFinished() + } + } + + @Test + fun `exception while getting split data causes FAILED state`(@TempDir tmpDir: Path) = + runBlocking { + // add one APK split to metadata + val splitName = getRandomString() + val sha256 = getRandomBase64(23) + packageMetadataMap[packageName] = packageMetadataMap[packageName]!!.copy( + splits = listOf(ApkSplit(splitName, Random.nextLong(), sha256)) + ) + + every { installRestriction.isAllowedToInstallApks() } returns true + every { backupStateManager.isAutoRestoreEnabled } returns false + every { + pm.getPackageInfo( + packageName, + any() + ) + } throws NameNotFoundException() + // cache APK and get icon as well as app name + cacheBaseApkAndGetInfo(tmpDir) + + every { splitCompatChecker.isCompatible(deviceName, listOf(splitName)) } returns true + every { crypto.getNameForApk(salt, packageName, splitName) } returns suffixName + coEvery { + backend.load(LegacyAppBackupFile.Blob(token, suffixName)) + } throws IOException() + every { backend.providerPackageName } returns storageProviderPackageName + + apkRestore.installResult.test { + awaitItem() // initial empty state + apkRestore.restore(backup) + assertQueuedProgressFailFinished() + } + } + + @Test + fun `splits get installed along with base APK`(@TempDir tmpDir: Path) = runBlocking { + // add one APK split to metadata + val split1Name = getRandomString() + val split2Name = getRandomString() + val split1sha256 = "A5BYxvLAy0ksUzsKTRTvd8wPeKvMztUofYShogEc-4E" + val split2sha256 = "ZqZ1cVH47lXbEncWx-Pc4L6AdLZOIO2lQuXB5GypxB4" + packageMetadataMap[packageName] = packageMetadataMap[packageName]!!.copy( + splits = listOf( + ApkSplit(split1Name, Random.nextLong(), split1sha256), + ApkSplit(split2Name, Random.nextLong(), split2sha256) + ) + ) + + every { installRestriction.isAllowedToInstallApks() } returns true + every { backupStateManager.isAutoRestoreEnabled } returns false + every { + pm.getPackageInfo( + packageName, + any() + ) + } throws NameNotFoundException() + // cache APK and get icon as well as app name + cacheBaseApkAndGetInfo(tmpDir) + + every { + splitCompatChecker.isCompatible(deviceName, listOf(split1Name, split2Name)) + } returns true + + // define bytes of splits and return them as stream (matches above hashes) + val split1Bytes = byteArrayOf(0x01, 0x02, 0x03) + val split2Bytes = byteArrayOf(0x07, 0x08, 0x09) + val split1InputStream = ByteArrayInputStream(split1Bytes) + val split2InputStream = ByteArrayInputStream(split2Bytes) + val suffixName1 = getRandomString() + val suffixName2 = getRandomString() + every { crypto.getNameForApk(salt, packageName, split1Name) } returns suffixName1 + coEvery { + backend.load(LegacyAppBackupFile.Blob(token, suffixName1)) + } returns split1InputStream + every { crypto.getNameForApk(salt, packageName, split2Name) } returns suffixName2 + coEvery { + backend.load(LegacyAppBackupFile.Blob(token, suffixName2)) + } returns split2InputStream + every { backend.providerPackageName } returns storageProviderPackageName + + val resultMap = mapOf( + packageName to ApkInstallResult( + packageName, + state = SUCCEEDED, + metadata = PackageMetadata(), + ) + ) + coEvery { + apkInstaller.install(match { it.size == 3 }, packageName, installerName, any()) + } returns InstallResult(resultMap) + + apkRestore.installResult.test { + awaitItem() // initial empty state + apkRestore.restore(backup) + assertQueuedProgressSuccessFinished() + } + } + + @Test + fun `storage provider app does not get reinstalled`() = runBlocking { + every { installRestriction.isAllowedToInstallApks() } returns true + every { backupStateManager.isAutoRestoreEnabled } returns false + // set the storage provider package name to match our current package name, + // and ensure that the current package is therefore skipped. + every { backend.providerPackageName } returns packageName + + apkRestore.installResult.test { + awaitItem() // initial empty state + apkRestore.restore(backup) + awaitItem().also { finishedItem -> + // the only package provided should have been filtered, leaving 0 packages. + assertEquals(0, finishedItem.total) + assertTrue(finishedItem.isFinished) + } + ensureAllEventsConsumed() + } + } + + @Test + fun `system app without APK get filtered out`() = runBlocking { + // only backed up package is a system app without an APK + packageMetadataMap[packageName] = PackageMetadata( + time = 23L, + system = true, + isLaunchableSystemApp = Random.nextBoolean(), + ).also { assertFalse(it.hasApk()) } + + every { installRestriction.isAllowedToInstallApks() } returns true + every { backupStateManager.isAutoRestoreEnabled } returns false + every { backend.providerPackageName } returns storageProviderPackageName + + apkRestore.installResult.test { + awaitItem() // initial empty state + apkRestore.restore(backup) + + awaitItem().also { finishedItem -> + println(finishedItem.installResults.values.toList()) + // the only package provided should have been filtered, leaving 0 packages. + assertEquals(0, finishedItem.total) + assertTrue(finishedItem.isFinished) + } + ensureAllEventsConsumed() + } + } + + @Test + fun `auto restore gets turned off, if it was on`(@TempDir tmpDir: Path) = runBlocking { + val packagesMap = mapOf( + packageName to ApkInstallResult( + packageName, + state = SUCCEEDED, + metadata = PackageMetadata(), + ) + ) + val installResult = InstallResult(packagesMap) + + every { installRestriction.isAllowedToInstallApks() } returns true + every { backupStateManager.isAutoRestoreEnabled } returns true + every { backend.providerPackageName } returns storageProviderPackageName + every { backupManager.setAutoRestore(false) } just Runs + every { + pm.getPackageInfo( + packageName, + any() + ) + } throws NameNotFoundException() + // cache APK and get icon as well as app name + cacheBaseApkAndGetInfo(tmpDir) + coEvery { + apkInstaller.install(match { it.size == 1 }, packageName, installerName, any()) + } returns installResult + every { backupManager.setAutoRestore(true) } just Runs + + apkRestore.installResult.test { + awaitItem() // initial empty state + apkRestore.restore(backup) + assertQueuedProgressSuccessFinished() + } + verifyOrder { + backupManager.setAutoRestore(false) + backupManager.setAutoRestore(true) + } + } + + @Test + fun `no apks get installed when blocked by policy`() = runBlocking { + every { installRestriction.isAllowedToInstallApks() } returns false + every { backend.providerPackageName } returns storageProviderPackageName + + apkRestore.installResult.test { + awaitItem() // initial empty state + apkRestore.restore(backup) + awaitItem().also { queuedItem -> + // single package fails without attempting to install it + assertEquals(1, queuedItem.total) + assertEquals(FAILED, queuedItem[packageName].state) + assertTrue(queuedItem.isFinished) + } + ensureAllEventsConsumed() + } + } + + private fun swapPackages(packageMetadataMap: PackageMetadataMap): RestorableBackup { + val metadata = metadata.copy(version = 1, packageMetadataMap = packageMetadataMap) + return backup.copy(backupMetadata = metadata) + } + + private fun cacheBaseApkAndGetInfo(tmpDir: Path) { + every { strictContext.cacheDir } returns File(tmpDir.toString()) + every { crypto.getNameForApk(salt, packageName, "") } returns name + coEvery { backend.load(handle) } returns apkInputStream + every { pm.getPackageArchiveInfo(any(), any()) } returns packageInfo + every { applicationInfo.loadIcon(pm) } returns icon + every { pm.getApplicationLabel(packageInfo.applicationInfo) } returns appName + } + + private suspend fun TurbineTestContext.assertQueuedFailFinished() { + awaitQueuedItem() + awaitItem().also { item -> + val result = item[packageName] + assertEquals(IN_PROGRESS, result.state) + assertFalse(item.hasFailed) + assertEquals(1, item.total) + assertEquals(1, item.list.size) + assertNull(result.icon) + } + awaitItem().also { failedItem -> + val result = failedItem[packageName] + assertEquals(FAILED, result.state) + assertTrue(failedItem.hasFailed) + assertFalse(failedItem.isFinished) + } + awaitItem().also { finishedItem -> + assertTrue(finishedItem.hasFailed) + assertTrue(finishedItem.isFinished) + } + ensureAllEventsConsumed() + } + + private suspend fun TurbineTestContext.assertQueuedProgressSuccessFinished() { + awaitQueuedItem() + awaitInProgressItem() + awaitItem().also { successItem -> + val result = successItem[packageName] + assertEquals(SUCCEEDED, result.state) + } + awaitItem().also { finishedItem -> + assertFalse(finishedItem.hasFailed) + assertTrue(finishedItem.isFinished) + } + ensureAllEventsConsumed() + } + + private suspend fun TurbineTestContext.assertQueuedProgressFailFinished() { + awaitQueuedItem() + awaitInProgressItem() + awaitItem().also { failedItem -> + // app install has failed + val result = failedItem[packageName] + assertEquals(FAILED, result.state) + assertTrue(failedItem.hasFailed) + assertFalse(failedItem.isFinished) + } + awaitItem().also { finishedItem -> + assertTrue(finishedItem.hasFailed) + assertTrue(finishedItem.isFinished) + } + ensureAllEventsConsumed() + } + + private suspend fun TurbineTestContext.awaitQueuedItem(): InstallResult { + val item = awaitItem() + // single package gets queued + val result = item[packageName] + assertEquals(QUEUED, result.state) + assertEquals(installerName, result.installerPackageName) + assertEquals(1, item.total) + assertEquals(0, item.list.size) // all items still queued + return item + } + + private suspend fun TurbineTestContext.awaitInProgressItem(): InstallResult { + awaitItem().also { item -> + val result = item[packageName] + assertEquals(IN_PROGRESS, result.state) + assertFalse(item.hasFailed) + assertEquals(1, item.total) + assertEquals(1, item.list.size) + assertNull(result.icon) + } + val item = awaitItem() + // name and icon are available now + val result = item[packageName] + assertEquals(IN_PROGRESS, result.state) + assertEquals(appName, result.name) + assertEquals(icon, result.icon) + assertFalse(item.hasFailed) + assertEquals(1, item.total) + assertEquals(1, item.list.size) + return item + } + +} diff --git a/app/src/test/java/com/stevesoltys/seedvault/transport/CoordinatorIntegrationTest.kt b/app/src/test/java/com/stevesoltys/seedvault/transport/CoordinatorIntegrationTest.kt index 7e506ce75..e7a17a4f9 100644 --- a/app/src/test/java/com/stevesoltys/seedvault/transport/CoordinatorIntegrationTest.kt +++ b/app/src/test/java/com/stevesoltys/seedvault/transport/CoordinatorIntegrationTest.kt @@ -157,9 +157,7 @@ internal class CoordinatorIntegrationTest : TransportTest() { appData2.copyInto(value2.captured) // write the app data into the passed ByteArray appData2.size } - coEvery { - apkBackup.backupApkIfNecessary(packageInfo, any()) - } returns packageMetadata + coEvery { apkBackup.backupApkIfNecessary(packageInfo) } just Runs coEvery { backend.save(LegacyAppBackupFile.Metadata(token)) } returns metadataOutputStream @@ -238,7 +236,7 @@ internal class CoordinatorIntegrationTest : TransportTest() { appData.copyInto(value.captured) // write the app data into the passed ByteArray appData.size } - coEvery { apkBackup.backupApkIfNecessary(packageInfo, any()) } returns null + coEvery { apkBackup.backupApkIfNecessary(packageInfo) } just Runs every { settingsManager.getToken() } returns token coEvery { backend.save(LegacyAppBackupFile.Metadata(token)) @@ -307,7 +305,7 @@ internal class CoordinatorIntegrationTest : TransportTest() { } returns bOutputStream every { inputFactory.getInputStream(fileDescriptor) } returns bInputStream every { settingsManager.isQuotaUnlimited() } returns false - coEvery { apkBackup.backupApkIfNecessary(packageInfo, any()) } returns packageMetadata + coEvery { apkBackup.backupApkIfNecessary(packageInfo) } just Runs every { settingsManager.getToken() } returns token every { metadataManager.salt } returns salt coEvery { diff --git a/app/src/test/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinatorTest.kt b/app/src/test/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinatorTest.kt index 1f9c04ac8..ef9498dae 100644 --- a/app/src/test/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinatorTest.kt +++ b/app/src/test/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinatorTest.kt @@ -14,13 +14,13 @@ import android.content.pm.PackageInfo import android.net.Uri import android.os.ParcelFileDescriptor import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER +import com.stevesoltys.seedvault.backend.BackendManager import com.stevesoltys.seedvault.coAssertThrows import com.stevesoltys.seedvault.getRandomString import com.stevesoltys.seedvault.metadata.BackupType import com.stevesoltys.seedvault.metadata.PackageMetadata import com.stevesoltys.seedvault.metadata.PackageState.NO_DATA import com.stevesoltys.seedvault.metadata.PackageState.QUOTA_EXCEEDED -import com.stevesoltys.seedvault.backend.BackendManager import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager import com.stevesoltys.seedvault.worker.ApkBackup import io.mockk.Runs @@ -273,7 +273,7 @@ internal class BackupCoordinatorTest : BackupTest() { coEvery { full.performFullBackup(packageInfo, fileDescriptor, 0, token, salt) } returns TRANSPORT_OK - coEvery { apkBackup.backupApkIfNecessary(packageInfo, any()) } returns null + coEvery { apkBackup.backupApkIfNecessary(packageInfo) } just Runs assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, fileDescriptor, 0)) } @@ -382,7 +382,7 @@ internal class BackupCoordinatorTest : BackupTest() { } private fun expectApkBackupAndMetadataWrite() { - coEvery { apkBackup.backupApkIfNecessary(any(), any()) } returns packageMetadata + coEvery { apkBackup.backupApkIfNecessary(packageInfo) } just Runs every { settingsManager.getToken() } returns token coEvery { backend.save(LegacyAppBackupFile.Metadata(token)) } returns metadataOutputStream every { metadataManager.onApkBackedUp(any(), packageMetadata) } just Runs 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 20ad8d6ac..82ef99c40 100644 --- a/app/src/test/java/com/stevesoltys/seedvault/worker/ApkBackupManagerTest.kt +++ b/app/src/test/java/com/stevesoltys/seedvault/worker/ApkBackupManagerTest.kt @@ -10,16 +10,15 @@ 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 import io.mockk.Runs -import io.mockk.andThenJust import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every @@ -29,11 +28,7 @@ import io.mockk.verify import io.mockk.verifyAll 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 internal class ApkBackupManagerTest : TransportTest() { @@ -49,13 +44,11 @@ internal class ApkBackupManagerTest : TransportTest() { settingsManager = settingsManager, metadataManager = metadataManager, packageService = packageService, - apkBackup = apkBackup, iconManager = iconManager, - backendManager = backendManager, + apkBackup = apkBackup, nm = nm, ) - private val metadataOutputStream = mockk() private val packageMetadata: PackageMetadata = mockk() init { @@ -77,14 +70,12 @@ internal class ApkBackupManagerTest : TransportTest() { every { metadataManager.onPackageDoesNotGetBackedUp(packageInfo, NOT_ALLOWED) } just Runs every { settingsManager.backupApks() } returns false - expectFinalUpload() every { nm.onApkBackupDone() } just Runs apkBackupManager.backup() verify { metadataManager.onPackageDoesNotGetBackedUp(packageInfo, NOT_ALLOWED) - metadataOutputStream.close() } } @@ -102,14 +93,12 @@ internal class ApkBackupManagerTest : TransportTest() { every { metadataManager.onPackageDoesNotGetBackedUp(packageInfo, NOT_ALLOWED) } just Runs every { settingsManager.backupApks() } returns false - expectFinalUpload() every { nm.onApkBackupDone() } just Runs apkBackupManager.backup() verify { metadataManager.onPackageDoesNotGetBackedUp(packageInfo, NOT_ALLOWED) - metadataOutputStream.close() } } @@ -135,14 +124,12 @@ internal class ApkBackupManagerTest : TransportTest() { every { metadataManager.onPackageDoesNotGetBackedUp(packageInfo, WAS_STOPPED) } just Runs every { settingsManager.backupApks() } returns false - expectFinalUpload() every { nm.onApkBackupDone() } just Runs apkBackupManager.backup() verify { metadataManager.onPackageDoesNotGetBackedUp(packageInfo, WAS_STOPPED) - metadataOutputStream.close() } } @@ -160,7 +147,6 @@ internal class ApkBackupManagerTest : TransportTest() { every { packageMetadata.state } returns NOT_ALLOWED every { settingsManager.backupApks() } returns false - expectFinalUpload() every { nm.onApkBackupDone() } just Runs apkBackupManager.backup() @@ -179,7 +165,6 @@ internal class ApkBackupManagerTest : TransportTest() { expectUploadIcons() every { settingsManager.backupApks() } returns false - expectFinalUpload() every { nm.onApkBackupDone() } just Runs apkBackupManager.backup() @@ -211,32 +196,22 @@ internal class ApkBackupManagerTest : TransportTest() { nm.onApkBackup(notAllowedPackages[0].packageName, any(), 0, notAllowedPackages.size) } just Runs // no backup needed - coEvery { - apkBackup.backupApkIfNecessary(notAllowedPackages[0], any()) - } returns null + coEvery { apkBackup.backupApkIfNecessary(notAllowedPackages[0]) } just Runs // update notification for second package every { nm.onApkBackup(notAllowedPackages[1].packageName, any(), 1, notAllowedPackages.size) } just Runs // was backed up, get new packageMetadata - coEvery { - apkBackup.backupApkIfNecessary(notAllowedPackages[1], any()) - } returns packageMetadata + coEvery { apkBackup.backupApkIfNecessary(notAllowedPackages[1]) } just Runs every { metadataManager.onApkBackedUp(notAllowedPackages[1], packageMetadata) } just Runs - expectFinalUpload() every { nm.onApkBackupDone() } just Runs apkBackupManager.backup() coVerify { - apkBackup.backupApkIfNecessary(notAllowedPackages[0], any()) - apkBackup.backupApkIfNecessary(notAllowedPackages[1], any()) - metadataOutputStream.close() - } - // metadata should only get uploaded once - verify(exactly = 1) { - metadataManager.uploadMetadata(metadataOutputStream) + apkBackup.backupApkIfNecessary(notAllowedPackages[0]) + apkBackup.backupApkIfNecessary(notAllowedPackages[1]) } } @@ -256,29 +231,17 @@ internal class ApkBackupManagerTest : TransportTest() { every { settingsManager.backupApks() } returns false - // final upload - every { settingsManager.getToken() } returns token - coEvery { backend.save(LegacyAppBackupFile.Metadata(token)) } returns metadataOutputStream - every { - metadataManager.uploadMetadata(metadataOutputStream) - } throws IOException() andThenThrows SecurityException() andThenJust Runs - every { metadataOutputStream.close() } just Runs - every { nm.onApkBackupDone() } just Runs apkBackupManager.backup() verify { metadataManager.onPackageDoesNotGetBackedUp(packageInfo, NOT_ALLOWED) - metadataOutputStream.close() } } 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() { @@ -286,11 +249,4 @@ internal class ApkBackupManagerTest : TransportTest() { every { packageService.notBackedUpPackages } returns emptyList() } - private fun expectFinalUpload() { - every { settingsManager.getToken() } returns token - coEvery { backend.save(LegacyAppBackupFile.Metadata(token)) } returns metadataOutputStream - every { metadataManager.uploadMetadata(metadataOutputStream) } just Runs - every { metadataOutputStream.close() } just Runs - } - } diff --git a/app/src/test/java/com/stevesoltys/seedvault/worker/ApkBackupTest.kt b/app/src/test/java/com/stevesoltys/seedvault/worker/ApkBackupTest.kt index 2159ff32d..ce230a886 100644 --- a/app/src/test/java/com/stevesoltys/seedvault/worker/ApkBackupTest.kt +++ b/app/src/test/java/com/stevesoltys/seedvault/worker/ApkBackupTest.kt @@ -13,20 +13,25 @@ import android.content.pm.PackageInfo import android.content.pm.PackageManager import android.content.pm.Signature import android.util.PackageUtils +import com.google.protobuf.ByteString import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER import com.stevesoltys.seedvault.getRandomString -import com.stevesoltys.seedvault.metadata.ApkSplit -import com.stevesoltys.seedvault.metadata.PackageMetadata -import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR +import com.stevesoltys.seedvault.proto.Snapshot +import com.stevesoltys.seedvault.transport.SnapshotManager +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.BackupTest +import com.stevesoltys.seedvault.transport.backup.SnapshotCreator +import io.mockk.Runs import io.mockk.coEvery import io.mockk.every +import io.mockk.just import io.mockk.mockk import io.mockk.mockkStatic +import io.mockk.slot import kotlinx.coroutines.runBlocking import org.junit.jupiter.api.Assertions.assertArrayEquals -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertThrows import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test @@ -34,34 +39,41 @@ import org.junit.jupiter.api.io.TempDir import java.io.ByteArrayOutputStream import java.io.File import java.io.IOException -import java.io.OutputStream +import java.io.InputStream import java.nio.file.Path -import kotlin.random.Random internal class ApkBackupTest : BackupTest() { private val pm: PackageManager = mockk() - private val streamGetter: suspend (name: String) -> OutputStream = mockk() + private val backupReceiver: BackupReceiver = mockk() + private val appBackupManager: AppBackupManager = mockk() + private val snapshotManager: SnapshotManager = mockk() + private val snapshotCreator: SnapshotCreator = mockk() - private val apkBackup = ApkBackup(pm, crypto, settingsManager, metadataManager) + private val apkBackup = + ApkBackup(pm, backupReceiver, appBackupManager, snapshotManager, settingsManager) private val signatureBytes = byteArrayOf(0x01, 0x02, 0x03) private val signatureHash = byteArrayOf(0x03, 0x02, 0x01) private val sigs = arrayOf(Signature(signatureBytes)) - private val packageMetadata = PackageMetadata( - time = Random.nextLong(), - version = packageInfo.longVersionCode - 1, - signatures = listOf("AwIB") - ) + private val apk = Snapshot.Apk.newBuilder() + .setVersionCode(packageInfo.longVersionCode - 1) + .addSignatures(ByteString.copyFrom(signatureHash)) + .build() + private val snapshot = Snapshot.newBuilder() + .setToken(token) + .putApps(packageInfo.packageName, Snapshot.App.newBuilder().setApk(apk).build()) + .build() init { mockkStatic(PackageUtils::class) + every { appBackupManager.snapshotCreator } returns snapshotCreator } @Test fun `does not back up @pm@`() = runBlocking { val packageInfo = PackageInfo().apply { packageName = MAGIC_PACKAGE_MANAGER } - assertNull(apkBackup.backupApkIfNecessary(packageInfo, streamGetter)) + apkBackup.backupApkIfNecessary(packageInfo) } @Test @@ -69,7 +81,7 @@ internal class ApkBackupTest : BackupTest() { every { settingsManager.backupApks() } returns false every { settingsManager.isBackupEnabled(any()) } returns true - assertNull(apkBackup.backupApkIfNecessary(packageInfo, streamGetter)) + apkBackup.backupApkIfNecessary(packageInfo) } @Test @@ -77,7 +89,7 @@ internal class ApkBackupTest : BackupTest() { every { settingsManager.backupApks() } returns true every { settingsManager.isBackupEnabled(any()) } returns false - assertNull(apkBackup.backupApkIfNecessary(packageInfo, streamGetter)) + apkBackup.backupApkIfNecessary(packageInfo) } @Test @@ -86,7 +98,7 @@ internal class ApkBackupTest : BackupTest() { every { settingsManager.isBackupEnabled(any()) } returns true every { settingsManager.backupApks() } returns true - assertNull(apkBackup.backupApkIfNecessary(packageInfo, streamGetter)) + apkBackup.backupApkIfNecessary(packageInfo) } @Test @@ -95,45 +107,50 @@ internal class ApkBackupTest : BackupTest() { every { settingsManager.isBackupEnabled(any()) } returns true every { settingsManager.backupApks() } returns true - assertNull(apkBackup.backupApkIfNecessary(packageInfo, streamGetter)) + apkBackup.backupApkIfNecessary(packageInfo) } @Test fun `does not back up the same version`() = runBlocking { packageInfo.applicationInfo.flags = FLAG_UPDATED_SYSTEM_APP - val packageMetadata = packageMetadata.copy( - version = packageInfo.longVersionCode - ) - - expectChecks(packageMetadata) + val apk = apk.toBuilder().setVersionCode(packageInfo.longVersionCode).build() + val app = Snapshot.App.newBuilder().setApk(apk).build() + expectChecks(snapshot.toBuilder().putApps(packageInfo.packageName, app).build()) - assertNull(apkBackup.backupApkIfNecessary(packageInfo, streamGetter)) + apkBackup.backupApkIfNecessary(packageInfo) } @Test fun `does back up the same version when signatures changes`() { packageInfo.applicationInfo.sourceDir = "/tmp/doesNotExist" - - expectChecks() + val apk = apk.toBuilder() + .clearSignatures() + .addSignatures(ByteString.copyFromUtf8("foo")) + .setVersionCode(packageInfo.longVersionCode) + .build() + val app = Snapshot.App.newBuilder().setApk(apk).build() + expectChecks(snapshot.toBuilder().putApps(packageInfo.packageName, app).build()) + every { + pm.getInstallSourceInfo(packageInfo.packageName) + } returns InstallSourceInfo(null, null, null, getRandomString()) assertThrows(IOException::class.java) { runBlocking { - assertNull(apkBackup.backupApkIfNecessary(packageInfo, streamGetter)) + apkBackup.backupApkIfNecessary(packageInfo) } } + Unit } @Test fun `do not accept empty signature`() = runBlocking { every { settingsManager.backupApks() } returns true every { settingsManager.isBackupEnabled(any()) } returns true - every { - metadataManager.getPackageMetadata(packageInfo.packageName) - } returns packageMetadata + every { snapshotManager.latestSnapshot } returns snapshot every { sigInfo.hasMultipleSigners() } returns false every { sigInfo.signingCertificateHistory } returns emptyArray() - assertNull(apkBackup.backupApkIfNecessary(packageInfo, streamGetter)) + apkBackup.backupApkIfNecessary(packageInfo) } @Test @@ -145,27 +162,24 @@ internal class ApkBackupTest : BackupTest() { writeBytes(apkBytes) }.absolutePath val apkOutputStream = ByteArrayOutputStream() - val updatedMetadata = PackageMetadata( - time = packageMetadata.time, - state = UNKNOWN_ERROR, - version = packageInfo.longVersionCode, - installer = getRandomString(), - sha256 = "eHx5jjmlvBkQNVuubQzYejay4Q_QICqD47trAF2oNHI", - signatures = packageMetadata.signatures - ) + val installer = getRandomString() + val capturedStream = slot() expectChecks() - every { metadataManager.salt } returns salt - every { crypto.getNameForApk(salt, packageInfo.packageName) } returns name - coEvery { streamGetter.invoke(name) } returns apkOutputStream every { pm.getInstallSourceInfo(packageInfo.packageName) - } returns InstallSourceInfo(null, null, null, updatedMetadata.installer) + } returns InstallSourceInfo(null, null, null, installer) + coEvery { backupReceiver.readFromStream(capture(capturedStream)) } answers { + capturedStream.captured.copyTo(apkOutputStream) + } + coEvery { backupReceiver.finalize() } returns BackupData(emptyList(), emptyMap()) + every { + snapshotCreator.onApkBackedUp(packageInfo.packageName, match { + it.installer == installer + }, emptyMap()) + } just Runs - assertEquals( - updatedMetadata, - apkBackup.backupApkIfNecessary(packageInfo, streamGetter) - ) + apkBackup.backupApkIfNecessary(packageInfo) assertArrayEquals(apkBytes, apkOutputStream.toByteArray()) } @@ -184,9 +198,7 @@ internal class ApkBackupTest : BackupTest() { packageInfo.splitNames = arrayOf(split1Name, split2Name) // create two split APKs val split1Bytes = byteArrayOf(0x07, 0x08, 0x09) - val split1Sha256 = "ZqZ1cVH47lXbEncWx-Pc4L6AdLZOIO2lQuXB5GypxB4" val split2Bytes = byteArrayOf(0x01, 0x02, 0x03) - val split2Sha256 = "A5BYxvLAy0ksUzsKTRTvd8wPeKvMztUofYShogEc-4E" packageInfo.applicationInfo.splitSourceDirs = arrayOf( File(tmpFile, "test-$split1Name.apk").apply { assertTrue(createNewFile()) @@ -201,54 +213,39 @@ internal class ApkBackupTest : BackupTest() { val apkOutputStream = ByteArrayOutputStream() val split1OutputStream = ByteArrayOutputStream() val split2OutputStream = ByteArrayOutputStream() - // expected new metadata for package - val updatedMetadata = PackageMetadata( - time = packageMetadata.time, - state = UNKNOWN_ERROR, - version = packageInfo.longVersionCode, - installer = getRandomString(), - splits = listOf( - ApkSplit(split1Name, split1Bytes.size.toLong(), split1Sha256), - ApkSplit(split2Name, split2Bytes.size.toLong(), split2Sha256) - ), - sha256 = "eHx5jjmlvBkQNVuubQzYejay4Q_QICqD47trAF2oNHI", - signatures = packageMetadata.signatures - ) - val suffixName1 = getRandomString() - val suffixName2 = getRandomString() + val capturedStream = slot() + val installer = getRandomString() expectChecks() - every { metadataManager.salt } returns salt - every { crypto.getNameForApk(salt, packageInfo.packageName) } returns name - every { - crypto.getNameForApk(salt, packageInfo.packageName, split1Name) - } returns suffixName1 - every { - crypto.getNameForApk(salt, packageInfo.packageName, split2Name) - } returns suffixName2 - coEvery { streamGetter.invoke(name) } returns apkOutputStream - coEvery { streamGetter.invoke(suffixName1) } returns split1OutputStream - coEvery { streamGetter.invoke(suffixName2) } returns split2OutputStream - every { pm.getInstallSourceInfo(packageInfo.packageName) - } returns InstallSourceInfo(null, null, null, updatedMetadata.installer) - - assertEquals( - updatedMetadata, - apkBackup.backupApkIfNecessary(packageInfo, streamGetter) - ) + } returns InstallSourceInfo(null, null, null, installer) + coEvery { backupReceiver.readFromStream(capture(capturedStream)) } answers { + capturedStream.captured.copyTo(apkOutputStream) + } andThenAnswer { + capturedStream.captured.copyTo(split1OutputStream) + } andThenAnswer { + capturedStream.captured.copyTo(split2OutputStream) + } + coEvery { backupReceiver.finalize() } returns BackupData(emptyList(), emptyMap()) + every { + snapshotCreator.onApkBackedUp(packageInfo.packageName, match { + it.installer == installer && + it.getSplits(1).name == split1Name && + it.getSplits(2).name == split2Name + }, emptyMap()) + } just Runs + + apkBackup.backupApkIfNecessary(packageInfo) assertArrayEquals(apkBytes, apkOutputStream.toByteArray()) assertArrayEquals(split1Bytes, split1OutputStream.toByteArray()) assertArrayEquals(split2Bytes, split2OutputStream.toByteArray()) } - private fun expectChecks(packageMetadata: PackageMetadata = this.packageMetadata) { + private fun expectChecks(snapshot: Snapshot = this.snapshot) { every { settingsManager.isBackupEnabled(any()) } returns true every { settingsManager.backupApks() } returns true - every { - metadataManager.getPackageMetadata(packageInfo.packageName) - } returns packageMetadata + every { snapshotManager.latestSnapshot } returns snapshot every { PackageUtils.computeSha256DigestBytes(signatureBytes) } returns signatureHash every { sigInfo.hasMultipleSigners() } returns false every { sigInfo.signingCertificateHistory } returns sigs