diff --git a/app/src/main/java/com/stevesoltys/seedvault/crypto/Crypto.kt b/app/src/main/java/com/stevesoltys/seedvault/crypto/Crypto.kt index fb0d509c..4d6cf6c5 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/crypto/Crypto.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/crypto/Crypto.kt @@ -44,6 +44,10 @@ internal interface Crypto { fun getNameForPackage(salt: String, packageName: String): String + /** + * Returns the name that identifies an APK in the backup storage plugin. + * @param suffix empty string for normal APKs and the name of the split in case of an APK split + */ fun getNameForApk(salt: String, packageName: String, suffix: String = ""): String /** diff --git a/app/src/main/java/com/stevesoltys/seedvault/restore/RestorableBackup.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/RestorableBackup.kt index ddcaeb82..f1c33c16 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/restore/RestorableBackup.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/restore/RestorableBackup.kt @@ -8,9 +8,15 @@ data class RestorableBackup(val backupMetadata: BackupMetadata) { val name: String get() = backupMetadata.deviceName + val version: Byte + get() = backupMetadata.version + val token: Long get() = backupMetadata.token + val salt: String + get() = backupMetadata.salt + val time: Long get() = backupMetadata.time diff --git a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreViewModel.kt b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreViewModel.kt index 99f8c206..80a23e0d 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreViewModel.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/restore/RestoreViewModel.kt @@ -122,6 +122,7 @@ internal class RestoreViewModel( @Throws(RemoteException::class) private fun getOrStartSession(): IRestoreSession { + @Suppress("UNRESOLVED_REFERENCE") val session = this.session ?: backupManager.beginRestoreSessionForUser(UserHandle.myUserId(), null, TRANSPORT_ID) ?: throw RemoteException("beginRestoreSessionForUser returned null") @@ -155,7 +156,7 @@ internal class RestoreViewModel( private fun getInstallResult(backup: RestorableBackup): LiveData { @Suppress("EXPERIMENTAL_API_USAGE") - return apkRestore.restore(backup.token, backup.deviceName, backup.packageMetadataMap) + return apkRestore.restore(backup) .onStart { Log.d(TAG, "Start InstallResult Flow") }.catch { e -> 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 d3215326..dcc41656 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 @@ -5,13 +5,15 @@ import android.content.pm.PackageManager import android.content.pm.PackageManager.GET_SIGNATURES import android.content.pm.PackageManager.GET_SIGNING_CERTIFICATES import android.util.Log +import com.stevesoltys.seedvault.crypto.Crypto 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_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.backup.BackupPlugin import com.stevesoltys.seedvault.transport.backup.copyStreamsAndGetHash import com.stevesoltys.seedvault.transport.backup.getSignatures import com.stevesoltys.seedvault.transport.backup.isSystemApp @@ -26,16 +28,19 @@ private val TAG = ApkRestore::class.java.simpleName internal class ApkRestore( private val context: Context, + private val backupPlugin: BackupPlugin, private val restorePlugin: RestorePlugin, + private val crypto: Crypto, private val splitCompatChecker: ApkSplitCompatibilityChecker, private val apkInstaller: ApkInstaller ) { private val pm = context.packageManager - fun restore(token: Long, deviceName: String, packageMetadataMap: PackageMetadataMap) = flow { + @Suppress("BlockingMethodInNonBlockingContext") + fun restore(backup: RestorableBackup) = flow { // filter out packages without APK and get total - val packages = packageMetadataMap.filter { it.value.hasApk() } + val packages = backup.packageMetadataMap.filter { it.value.hasApk() } val total = packages.size var progress = 0 @@ -55,7 +60,7 @@ internal class ApkRestore( // re-install individual packages and emit updates for ((packageName, metadata) in packages) { try { - restore(this, token, deviceName, packageName, metadata, installResult) + restore(this, backup, packageName, metadata, installResult) } catch (e: IOException) { Log.e(TAG, "Error re-installing APK for $packageName.", e) emit(installResult.fail(packageName)) @@ -75,14 +80,13 @@ internal class ApkRestore( @Throws(IOException::class, SecurityException::class) private suspend fun restore( collector: FlowCollector, - token: Long, - deviceName: String, + backup: RestorableBackup, packageName: String, metadata: PackageMetadata, installResult: MutableInstallResult ) { // cache the APK and get its hash - val (cachedApk, sha256) = cacheApk(token, packageName) + val (cachedApk, sha256) = cacheApk(backup.version, backup.token, backup.salt, packageName) // check APK's SHA-256 hash if (metadata.sha256 != sha256) throw SecurityException( @@ -139,7 +143,7 @@ internal class ApkRestore( // process further APK splits, if available val cachedApks = - cacheSplitsIfNeeded(token, deviceName, packageName, cachedApk, metadata.splits) + cacheSplitsIfNeeded(backup, packageName, cachedApk, metadata.splits) if (cachedApks == null) { Log.w(TAG, "Not installing $packageName because of incompatible splits.") collector.emit(installResult.fail(packageName)) @@ -161,8 +165,7 @@ internal class ApkRestore( */ @Throws(IOException::class, SecurityException::class) private suspend fun cacheSplitsIfNeeded( - token: Long, - deviceName: String, + backup: RestorableBackup, packageName: String, cachedApk: File, splits: List? @@ -171,15 +174,16 @@ internal class ApkRestore( val splitNames = splits?.map { it.name } ?: return listOf(cachedApk) // return null when splits are incompatible - if (!splitCompatChecker.isCompatible(deviceName, splitNames)) return null + if (!splitCompatChecker.isCompatible(backup.deviceName, splitNames)) return null // store coming splits in a list val cachedApks = ArrayList(splits.size + 1).apply { add(cachedApk) // don't forget the base APK } splits.forEach { apkSplit -> // cache and check all splits - val suffix = "_${apkSplit.sha256}" - val (file, sha256) = cacheApk(token, packageName, suffix) + 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( "$packageName:${apkSplit.name} has sha256 '$sha256'," + @@ -199,14 +203,22 @@ internal class ApkRestore( @Throws(IOException::class) @Suppress("BlockingMethodInNonBlockingContext") // flows on Dispatcher.IO private suspend fun cacheApk( + version: Byte, token: Long, + salt: String, packageName: String, 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 = restorePlugin.getApkInputStream(token, packageName, suffix) + val inputStream = if (version == 0.toByte()) { + @Suppress("Deprecation") + restorePlugin.getApkInputStream(token, packageName, suffix) + } else { + val name = crypto.getNameForApk(salt, packageName, suffix) + backupPlugin.getInputStream(token, name) + } val sha256 = copyStreamsAndGetHash(inputStream, cachedApk.outputStream()) return Pair(cachedApk, sha256) } 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 60cc4488..33e640b3 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 @@ -7,5 +7,5 @@ val installModule = module { factory { ApkInstaller(androidContext()) } factory { DeviceInfo(androidContext()) } factory { ApkSplitCompatibilityChecker(get()) } - factory { ApkRestore(androidContext(), get(), get(), get()) } + factory { ApkRestore(androidContext(), get(), get(), get(), get(), get()) } } diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/ApkBackup.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/ApkBackup.kt index b38fbc7e..7f27e402 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/ApkBackup.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/ApkBackup.kt @@ -8,6 +8,7 @@ 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 @@ -27,6 +28,7 @@ private val TAG = ApkBackup::class.java.simpleName @Suppress("BlockingMethodInNonBlockingContext") internal class ApkBackup( private val pm: PackageManager, + private val crypto: Crypto, private val settingsManager: SettingsManager, private val metadataManager: MetadataManager ) { @@ -44,7 +46,7 @@ internal class ApkBackup( suspend fun backupApkIfNecessary( packageInfo: PackageInfo, packageState: PackageState, - streamGetter: suspend (suffix: String) -> OutputStream + streamGetter: suspend (name: String) -> OutputStream ): PackageMetadata? { // do not back up @pm@ val packageName = packageInfo.packageName @@ -102,7 +104,8 @@ internal class ApkBackup( // get an InputStream for the APK val inputStream = getApkInputStream(packageInfo.applicationInfo.sourceDir) // copy the APK to the storage's output and calculate SHA-256 hash while at it - val sha256 = copyStreamsAndGetHash(inputStream, streamGetter("")) + val name = crypto.getNameForApk(metadataManager.salt, packageName) + val sha256 = copyStreamsAndGetHash(inputStream, streamGetter(name)) // back up splits if they exist val splits = @@ -148,7 +151,7 @@ internal class ApkBackup( @Throws(IOException::class) private suspend fun backupSplitApks( packageInfo: PackageInfo, - streamGetter: suspend (suffix: String) -> OutputStream + streamGetter: suspend (name: String) -> OutputStream ): List { check(packageInfo.splitNames != null) val splitSourceDirs = packageInfo.applicationInfo.splitSourceDirs @@ -159,7 +162,12 @@ internal class ApkBackup( } val splits = ArrayList(packageInfo.splitNames.size) for (i in packageInfo.splitNames.indices) { - val split = backupSplitApk(packageInfo.splitNames[i], splitSourceDirs[i], streamGetter) + val split = backupSplitApk( + packageName = packageInfo.packageName, + splitName = packageInfo.splitNames[i], + sourceDir = splitSourceDirs[i], + streamGetter = streamGetter + ) splits.add(split) } return splits @@ -167,9 +175,10 @@ internal class ApkBackup( @Throws(IOException::class) private suspend fun backupSplitApk( - name: String, + packageName: String, + splitName: String, sourceDir: String, - streamGetter: suspend (suffix: String) -> OutputStream + 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 @@ -185,14 +194,14 @@ internal class ApkBackup( } } val sha256 = messageDigest.digest().encodeBase64() - val suffix = "_$sha256" + val name = crypto.getNameForApk(metadataManager.salt, packageName, splitName) // copy the split APK to the storage stream getApkInputStream(sourceDir).use { inputStream -> - streamGetter(suffix).use { outputStream -> + streamGetter(name).use { outputStream -> inputStream.copyTo(outputStream) } } - return ApkSplit(name, sha256) + return ApkSplit(splitName, sha256) } } diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinator.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinator.kt index ef12f764..7e19d9dd 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinator.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinator.kt @@ -453,9 +453,8 @@ internal class BackupCoordinator( ): Boolean { val packageName = packageInfo.packageName return try { - apkBackup.backupApkIfNecessary(packageInfo, packageState) { suffix -> + apkBackup.backupApkIfNecessary(packageInfo, packageState) { name -> val token = settingsManager.getToken() ?: throw IOException("no current token") - val name = "${packageInfo.packageName}$suffix.apk" plugin.getOutputStream(token, name) }?.let { packageMetadata -> plugin.getMetadataOutputStream().use { diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupModule.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupModule.kt index 5e119b4b..8be00ac6 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupModule.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupModule.kt @@ -14,6 +14,7 @@ val backupModule = module { single { ApkBackup( pm = androidContext().packageManager, + crypto = get(), settingsManager = get(), metadataManager = get() ) diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/restore/RestorePlugin.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/restore/RestorePlugin.kt index a0e23b26..6fa75cda 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/transport/restore/RestorePlugin.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/transport/restore/RestorePlugin.kt @@ -13,6 +13,7 @@ interface RestorePlugin { * Returns an [InputStream] for the given token, for reading an APK that is to be restored. */ @Throws(IOException::class) + @Deprecated("Use only for v0 restores") suspend fun getApkInputStream(token: Long, packageName: String, suffix: String): InputStream } 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 new file mode 100644 index 00000000..981ace2e --- /dev/null +++ b/app/src/test/java/com/stevesoltys/seedvault/restore/install/ApkBackupRestoreTest.kt @@ -0,0 +1,174 @@ +package com.stevesoltys.seedvault.restore.install + +import android.content.Context +import android.content.pm.PackageManager +import android.content.pm.Signature +import android.graphics.drawable.Drawable +import android.util.PackageUtils +import com.stevesoltys.seedvault.assertReadEquals +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.metadata.PackageState +import com.stevesoltys.seedvault.restore.RestorableBackup +import com.stevesoltys.seedvault.transport.TransportTest +import com.stevesoltys.seedvault.transport.backup.ApkBackup +import com.stevesoltys.seedvault.transport.backup.BackupPlugin +import com.stevesoltys.seedvault.transport.restore.RestorePlugin +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.slot +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.collectIndexed +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Assertions.assertArrayEquals +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +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.ByteArrayOutputStream +import java.io.File +import java.io.FileInputStream +import java.io.OutputStream +import java.nio.file.Path +import kotlin.random.Random + +@ExperimentalCoroutinesApi +@Suppress("BlockingMethodInNonBlockingContext") +internal class ApkBackupRestoreTest : TransportTest() { + + private val pm: PackageManager = mockk() + private val strictContext: Context = mockk().apply { + every { packageManager } returns pm + } + private val backupPlugin: BackupPlugin = mockk() + private val restorePlugin: RestorePlugin = mockk() + private val splitCompatChecker: ApkSplitCompatibilityChecker = mockk() + private val apkInstaller: ApkInstaller = mockk() + + private val apkBackup = ApkBackup(pm, crypto, settingsManager, metadataManager) + private val apkRestore: ApkRestore = ApkRestore( + context = strictContext, + backupPlugin = backupPlugin, + restorePlugin = restorePlugin, + crypto = crypto, + splitCompatChecker = splitCompatChecker, + apkInstaller = apkInstaller + ) + + private val signatureBytes = byteArrayOf(0x01, 0x02, 0x03) + private val signatureHash = byteArrayOf(0x03, 0x02, 0x01) + private val sigs = arrayOf(Signature(signatureBytes)) + 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, splitSha256)) + ) + private val packageMetadataMap: PackageMetadataMap = hashMapOf(packageName to packageMetadata) + private val installerName = packageMetadata.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) + } + + @Test + fun `test backup and restore with a split`(@TempDir tmpDir: Path) = runBlocking { + val apkBytes = byteArrayOf(0x04, 0x05, 0x06) + val tmpFile = File(tmpDir.toAbsolutePath().toString()) + packageInfo.applicationInfo.sourceDir = File(tmpFile, "test.apk").apply { + assertTrue(createNewFile()) + writeBytes(apkBytes) + }.absolutePath + packageInfo.splitNames = arrayOf(splitName) + packageInfo.applicationInfo.splitSourceDirs = arrayOf(File(tmpFile, "split.apk").apply { + assertTrue(createNewFile()) + writeBytes(splitBytes) + }.absolutePath) + + every { settingsManager.backupApks() } returns true + every { sigInfo.hasMultipleSigners() } returns false + every { sigInfo.signingCertificateHistory } returns sigs + every { PackageUtils.computeSha256DigestBytes(signatureBytes) } returns signatureHash + every { + metadataManager.getPackageMetadata(packageInfo.packageName) + } returns packageMetadata + 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 + + apkBackup.backupApkIfNecessary(packageInfo, PackageState.APK_AND_DATA, outputStreamGetter) + + assertArrayEquals(apkBytes, outputStream.toByteArray()) + assertArrayEquals(splitBytes, splitOutputStream.toByteArray()) + + val inputStream = ByteArrayInputStream(apkBytes) + val splitInputStream = ByteArrayInputStream(splitBytes) + val apkPath = slot() + val cacheFiles = slot>() + + every { strictContext.cacheDir } returns tmpFile + every { crypto.getNameForApk(salt, packageName, "") } returns name + coEvery { backupPlugin.getInputStream(token, name) } returns inputStream + every { pm.getPackageArchiveInfo(capture(apkPath), any()) } returns packageInfo + every { + @Suppress("UNRESOLVED_REFERENCE") + pm.loadItemIcon( + packageInfo.applicationInfo, + packageInfo.applicationInfo + ) + } 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 { backupPlugin.getInputStream(token, suffixName) } returns splitInputStream + coEvery { + apkInstaller.install(capture(cacheFiles), packageName, installerName, any()) + } returns MutableInstallResult(1).apply { + set( + packageName, ApkInstallResult( + packageName, + progress = 1, + state = ApkInstallState.SUCCEEDED + ) + ) + } + + val backup = RestorableBackup(metadata.copy(packageMetadataMap = packageMetadataMap)) + apkRestore.restore(backup).collectIndexed { i, value -> + assertFalse(value.hasFailed) + assertEquals(1, value.total) + if (i == 3) assertTrue(value.isFinished) + } + + val apkFile = File(apkPath.captured) + assertEquals(2, cacheFiles.captured.size) + assertEquals(apkFile, cacheFiles.captured[0]) + val splitFile = cacheFiles.captured[1] + assertReadEquals(apkBytes, FileInputStream(apkFile)) + assertReadEquals(splitBytes, FileInputStream(splitFile)) + } + +} 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 da50652b..6f616083 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 @@ -14,12 +14,14 @@ 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.backup.BackupPlugin import com.stevesoltys.seedvault.transport.restore.RestorePlugin import io.mockk.coEvery import io.mockk.every @@ -48,16 +50,23 @@ internal class ApkRestoreTest : TransportTest() { private val strictContext: Context = mockk().apply { every { packageManager } returns pm } + private val backupPlugin: BackupPlugin = mockk() private val restorePlugin: RestorePlugin = mockk() private val splitCompatChecker: ApkSplitCompatibilityChecker = mockk() private val apkInstaller: ApkInstaller = mockk() - private val apkRestore: ApkRestore = - ApkRestore(strictContext, restorePlugin, splitCompatChecker, apkInstaller) + private val apkRestore: ApkRestore = ApkRestore( + strictContext, + backupPlugin, + restorePlugin, + crypto, + splitCompatChecker, + apkInstaller + ) private val icon: Drawable = mockk() - private val deviceName = getRandomString() + private val deviceName = metadata.deviceName private val packageName = packageInfo.packageName private val packageMetadata = PackageMetadata( time = Random.nextLong(), @@ -71,6 +80,8 @@ internal class ApkRestoreTest : TransportTest() { private val apkInputStream = ByteArrayInputStream(apkBytes) private val appName = getRandomString() private val installerName = packageMetadata.installer + private val backup = RestorableBackup(metadata.copy(packageMetadataMap = packageMetadataMap)) + private val suffixName = getRandomString() init { // as we don't do strict signature checking, we can use a relaxed mock @@ -81,12 +92,13 @@ internal class ApkRestoreTest : TransportTest() { fun `signature mismatch causes FAILED status`(@TempDir tmpDir: Path) = runBlocking { // change SHA256 signature to random val packageMetadata = packageMetadata.copy(sha256 = getRandomString()) - val packageMetadataMap: PackageMetadataMap = hashMapOf(packageName to packageMetadata) + val backup = swapPackages(hashMapOf(packageName to packageMetadata)) every { strictContext.cacheDir } returns File(tmpDir.toString()) - coEvery { restorePlugin.getApkInputStream(token, packageName, "") } returns apkInputStream + every { crypto.getNameForApk(salt, packageName, "") } returns name + coEvery { backupPlugin.getInputStream(token, name) } returns apkInputStream - apkRestore.restore(token, deviceName, packageMetadataMap).collectIndexed { i, value -> + apkRestore.restore(backup).collectIndexed { i, value -> assertQueuedFailFinished(i, value) } } @@ -97,10 +109,11 @@ internal class ApkRestoreTest : TransportTest() { packageInfo.packageName = getRandomString() every { strictContext.cacheDir } returns File(tmpDir.toString()) - coEvery { restorePlugin.getApkInputStream(token, packageName, "") } returns apkInputStream + every { crypto.getNameForApk(salt, packageName, "") } returns name + coEvery { backupPlugin.getInputStream(token, name) } returns apkInputStream every { pm.getPackageArchiveInfo(any(), any()) } returns packageInfo - apkRestore.restore(token, deviceName, packageMetadataMap).collectIndexed { i, value -> + apkRestore.restore(backup).collectIndexed { i, value -> assertQueuedFailFinished(i, value) } } @@ -112,7 +125,7 @@ internal class ApkRestoreTest : TransportTest() { apkInstaller.install(match { it.size == 1 }, packageName, installerName, any()) } throws SecurityException() - apkRestore.restore(token, deviceName, packageMetadataMap).collectIndexed { i, value -> + apkRestore.restore(backup).collectIndexed { i, value -> assertQueuedProgressFailFinished(i, value) } } @@ -134,7 +147,43 @@ internal class ApkRestoreTest : TransportTest() { apkInstaller.install(match { it.size == 1 }, packageName, installerName, any()) } returns installResult - apkRestore.restore(token, deviceName, packageMetadataMap).collectIndexed { i, value -> + apkRestore.restore(backup).collectIndexed { i, value -> + assertQueuedProgressSuccessFinished(i, value) + } + } + + @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 installResult = MutableInstallResult(1).apply { + set( + packageName, ApkInstallResult( + packageName, + progress = 1, + state = SUCCEEDED + ) + ) + } + + every { strictContext.cacheDir } returns File(tmpDir.toString()) + @Suppress("Deprecation") + coEvery { restorePlugin.getApkInputStream(token, packageName, "") } returns apkInputStream + every { pm.getPackageArchiveInfo(any(), any()) } returns packageInfo + every { + @Suppress("UNRESOLVED_REFERENCE") + pm.loadItemIcon( + packageInfo.applicationInfo, + packageInfo.applicationInfo + ) + } returns icon + every { pm.getApplicationLabel(packageInfo.applicationInfo) } returns appName + coEvery { + apkInstaller.install(match { it.size == 1 }, packageName, installerName, any()) + } returns installResult + + apkRestore.restore(backup).collectIndexed { i, value -> assertQueuedProgressSuccessFinished(i, value) } } @@ -181,7 +230,7 @@ internal class ApkRestoreTest : TransportTest() { } } - apkRestore.restore(token, deviceName, packageMetadataMap).collectIndexed { i, value -> + apkRestore.restore(backup).collectIndexed { i, value -> when (i) { 0 -> { val result = value[packageName] @@ -231,7 +280,7 @@ internal class ApkRestoreTest : TransportTest() { splitCompatChecker.isCompatible(deviceName, listOf(split1Name, split2Name)) } returns false - apkRestore.restore(token, deviceName, packageMetadataMap).collectIndexed { i, value -> + apkRestore.restore(backup).collectIndexed { i, value -> assertQueuedProgressFailFinished(i, value) } } @@ -240,20 +289,20 @@ internal class ApkRestoreTest : TransportTest() { fun `split signature mismatch 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, sha256)) + splits = listOf(ApkSplit(splitName, getRandomBase64(23))) ) // 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 { - restorePlugin.getApkInputStream(token, packageName, "_$sha256") + backupPlugin.getInputStream(token, suffixName) } returns ByteArrayInputStream(getRandomByteArray()) - apkRestore.restore(token, deviceName, packageMetadataMap).collectIndexed { i, value -> + apkRestore.restore(backup).collectIndexed { i, value -> assertQueuedProgressFailFinished(i, value) } } @@ -272,11 +321,10 @@ internal class ApkRestoreTest : TransportTest() { cacheBaseApkAndGetInfo(tmpDir) every { splitCompatChecker.isCompatible(deviceName, listOf(splitName)) } returns true - coEvery { - restorePlugin.getApkInputStream(token, packageName, "_$sha256") - } throws IOException() + every { crypto.getNameForApk(salt, packageName, splitName) } returns suffixName + coEvery { backupPlugin.getInputStream(token, suffixName) } throws IOException() - apkRestore.restore(token, deviceName, packageMetadataMap).collectIndexed { i, value -> + apkRestore.restore(backup).collectIndexed { i, value -> assertQueuedProgressFailFinished(i, value) } } @@ -307,12 +355,12 @@ internal class ApkRestoreTest : TransportTest() { val split2Bytes = byteArrayOf(0x07, 0x08, 0x09) val split1InputStream = ByteArrayInputStream(split1Bytes) val split2InputStream = ByteArrayInputStream(split2Bytes) - coEvery { - restorePlugin.getApkInputStream(token, packageName, "_$split1sha256") - } returns split1InputStream - coEvery { - restorePlugin.getApkInputStream(token, packageName, "_$split2sha256") - } returns split2InputStream + val suffixName1 = getRandomString() + val suffixName2 = getRandomString() + every { crypto.getNameForApk(salt, packageName, split1Name) } returns suffixName1 + coEvery { backupPlugin.getInputStream(token, suffixName1) } returns split1InputStream + every { crypto.getNameForApk(salt, packageName, split2Name) } returns suffixName2 + coEvery { backupPlugin.getInputStream(token, suffixName2) } returns split2InputStream coEvery { apkInstaller.install(match { it.size == 3 }, packageName, installerName, any()) @@ -326,16 +374,23 @@ internal class ApkRestoreTest : TransportTest() { ) } - apkRestore.restore(token, deviceName, packageMetadataMap).collectIndexed { i, value -> + apkRestore.restore(backup).collectIndexed { i, value -> assertQueuedProgressSuccessFinished(i, value) } } + private fun swapPackages(packageMetadataMap: PackageMetadataMap): RestorableBackup { + val metadata = metadata.copy(packageMetadataMap = packageMetadataMap) + return backup.copy(backupMetadata = metadata) + } + private fun cacheBaseApkAndGetInfo(tmpDir: Path) { every { strictContext.cacheDir } returns File(tmpDir.toString()) - coEvery { restorePlugin.getApkInputStream(token, packageName, "") } returns apkInputStream + every { crypto.getNameForApk(salt, packageName, "") } returns name + coEvery { backupPlugin.getInputStream(token, name) } returns apkInputStream every { pm.getPackageArchiveInfo(any(), any()) } returns packageInfo every { + @Suppress("UNRESOLVED_REFERENCE") pm.loadItemIcon( packageInfo.applicationInfo, packageInfo.applicationInfo diff --git a/app/src/test/java/com/stevesoltys/seedvault/transport/backup/ApkBackupTest.kt b/app/src/test/java/com/stevesoltys/seedvault/transport/backup/ApkBackupTest.kt index 626c11f5..2137e565 100644 --- a/app/src/test/java/com/stevesoltys/seedvault/transport/backup/ApkBackupTest.kt +++ b/app/src/test/java/com/stevesoltys/seedvault/transport/backup/ApkBackupTest.kt @@ -36,9 +36,9 @@ import kotlin.random.Random internal class ApkBackupTest : BackupTest() { private val pm: PackageManager = mockk() - private val streamGetter: suspend (suffix: String) -> OutputStream = mockk() + private val streamGetter: suspend (name: String) -> OutputStream = mockk() - private val apkBackup = ApkBackup(pm, settingsManager, metadataManager) + private val apkBackup = ApkBackup(pm, crypto, settingsManager, metadataManager) private val signatureBytes = byteArrayOf(0x01, 0x02, 0x03) private val signatureHash = byteArrayOf(0x03, 0x02, 0x01) @@ -140,7 +140,9 @@ internal class ApkBackupTest : BackupTest() { ) expectChecks() - coEvery { streamGetter.invoke("") } returns apkOutputStream + 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) @@ -197,11 +199,21 @@ internal class ApkBackupTest : BackupTest() { sha256 = "eHx5jjmlvBkQNVuubQzYejay4Q_QICqD47trAF2oNHI", signatures = packageMetadata.signatures ) + val suffixName1 = getRandomString() + val suffixName2 = getRandomString() expectChecks() - coEvery { streamGetter.invoke("") } returns apkOutputStream - coEvery { streamGetter.invoke("_$split1Sha256") } returns split1OutputStream - coEvery { streamGetter.invoke("_$split2Sha256") } returns split2OutputStream + 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)