Let ApkBackup and ApkRestore use the new storage plugin API

This commit is contained in:
Torsten Grote 2021-09-23 12:37:28 +02:00 committed by Chirayu Desai
parent 183e34afd2
commit 50066f0317
12 changed files with 335 additions and 61 deletions

View file

@ -44,6 +44,10 @@ internal interface Crypto {
fun getNameForPackage(salt: String, packageName: String): String 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 fun getNameForApk(salt: String, packageName: String, suffix: String = ""): String
/** /**

View file

@ -8,9 +8,15 @@ data class RestorableBackup(val backupMetadata: BackupMetadata) {
val name: String val name: String
get() = backupMetadata.deviceName get() = backupMetadata.deviceName
val version: Byte
get() = backupMetadata.version
val token: Long val token: Long
get() = backupMetadata.token get() = backupMetadata.token
val salt: String
get() = backupMetadata.salt
val time: Long val time: Long
get() = backupMetadata.time get() = backupMetadata.time

View file

@ -122,6 +122,7 @@ internal class RestoreViewModel(
@Throws(RemoteException::class) @Throws(RemoteException::class)
private fun getOrStartSession(): IRestoreSession { private fun getOrStartSession(): IRestoreSession {
@Suppress("UNRESOLVED_REFERENCE")
val session = this.session val session = this.session
?: backupManager.beginRestoreSessionForUser(UserHandle.myUserId(), null, TRANSPORT_ID) ?: backupManager.beginRestoreSessionForUser(UserHandle.myUserId(), null, TRANSPORT_ID)
?: throw RemoteException("beginRestoreSessionForUser returned null") ?: throw RemoteException("beginRestoreSessionForUser returned null")
@ -155,7 +156,7 @@ internal class RestoreViewModel(
private fun getInstallResult(backup: RestorableBackup): LiveData<InstallResult> { private fun getInstallResult(backup: RestorableBackup): LiveData<InstallResult> {
@Suppress("EXPERIMENTAL_API_USAGE") @Suppress("EXPERIMENTAL_API_USAGE")
return apkRestore.restore(backup.token, backup.deviceName, backup.packageMetadataMap) return apkRestore.restore(backup)
.onStart { .onStart {
Log.d(TAG, "Start InstallResult Flow") Log.d(TAG, "Start InstallResult Flow")
}.catch { e -> }.catch { e ->

View file

@ -5,13 +5,15 @@ import android.content.pm.PackageManager
import android.content.pm.PackageManager.GET_SIGNATURES import android.content.pm.PackageManager.GET_SIGNATURES
import android.content.pm.PackageManager.GET_SIGNING_CERTIFICATES import android.content.pm.PackageManager.GET_SIGNING_CERTIFICATES
import android.util.Log import android.util.Log
import com.stevesoltys.seedvault.crypto.Crypto
import com.stevesoltys.seedvault.metadata.ApkSplit import com.stevesoltys.seedvault.metadata.ApkSplit
import com.stevesoltys.seedvault.metadata.PackageMetadata 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.FAILED_SYSTEM_APP
import com.stevesoltys.seedvault.restore.install.ApkInstallState.IN_PROGRESS import com.stevesoltys.seedvault.restore.install.ApkInstallState.IN_PROGRESS
import com.stevesoltys.seedvault.restore.install.ApkInstallState.QUEUED import com.stevesoltys.seedvault.restore.install.ApkInstallState.QUEUED
import com.stevesoltys.seedvault.restore.install.ApkInstallState.SUCCEEDED 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.copyStreamsAndGetHash
import com.stevesoltys.seedvault.transport.backup.getSignatures import com.stevesoltys.seedvault.transport.backup.getSignatures
import com.stevesoltys.seedvault.transport.backup.isSystemApp import com.stevesoltys.seedvault.transport.backup.isSystemApp
@ -26,16 +28,19 @@ private val TAG = ApkRestore::class.java.simpleName
internal class ApkRestore( internal class ApkRestore(
private val context: Context, private val context: Context,
private val backupPlugin: BackupPlugin,
private val restorePlugin: RestorePlugin, private val restorePlugin: RestorePlugin,
private val crypto: Crypto,
private val splitCompatChecker: ApkSplitCompatibilityChecker, private val splitCompatChecker: ApkSplitCompatibilityChecker,
private val apkInstaller: ApkInstaller private val apkInstaller: ApkInstaller
) { ) {
private val pm = context.packageManager 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 // 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 val total = packages.size
var progress = 0 var progress = 0
@ -55,7 +60,7 @@ internal class ApkRestore(
// re-install individual packages and emit updates // re-install individual packages and emit updates
for ((packageName, metadata) in packages) { for ((packageName, metadata) in packages) {
try { try {
restore(this, token, deviceName, packageName, metadata, installResult) restore(this, backup, packageName, metadata, installResult)
} catch (e: IOException) { } catch (e: IOException) {
Log.e(TAG, "Error re-installing APK for $packageName.", e) Log.e(TAG, "Error re-installing APK for $packageName.", e)
emit(installResult.fail(packageName)) emit(installResult.fail(packageName))
@ -75,14 +80,13 @@ internal class ApkRestore(
@Throws(IOException::class, SecurityException::class) @Throws(IOException::class, SecurityException::class)
private suspend fun restore( private suspend fun restore(
collector: FlowCollector<InstallResult>, collector: FlowCollector<InstallResult>,
token: Long, backup: RestorableBackup,
deviceName: String,
packageName: String, packageName: String,
metadata: PackageMetadata, metadata: PackageMetadata,
installResult: MutableInstallResult installResult: MutableInstallResult
) { ) {
// cache the APK and get its hash // 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 // check APK's SHA-256 hash
if (metadata.sha256 != sha256) throw SecurityException( if (metadata.sha256 != sha256) throw SecurityException(
@ -139,7 +143,7 @@ internal class ApkRestore(
// process further APK splits, if available // process further APK splits, if available
val cachedApks = val cachedApks =
cacheSplitsIfNeeded(token, deviceName, packageName, cachedApk, metadata.splits) cacheSplitsIfNeeded(backup, packageName, cachedApk, metadata.splits)
if (cachedApks == null) { if (cachedApks == null) {
Log.w(TAG, "Not installing $packageName because of incompatible splits.") Log.w(TAG, "Not installing $packageName because of incompatible splits.")
collector.emit(installResult.fail(packageName)) collector.emit(installResult.fail(packageName))
@ -161,8 +165,7 @@ internal class ApkRestore(
*/ */
@Throws(IOException::class, SecurityException::class) @Throws(IOException::class, SecurityException::class)
private suspend fun cacheSplitsIfNeeded( private suspend fun cacheSplitsIfNeeded(
token: Long, backup: RestorableBackup,
deviceName: String,
packageName: String, packageName: String,
cachedApk: File, cachedApk: File,
splits: List<ApkSplit>? splits: List<ApkSplit>?
@ -171,15 +174,16 @@ internal class ApkRestore(
val splitNames = splits?.map { it.name } ?: return listOf(cachedApk) val splitNames = splits?.map { it.name } ?: return listOf(cachedApk)
// return null when splits are incompatible // 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 // store coming splits in a list
val cachedApks = ArrayList<File>(splits.size + 1).apply { val cachedApks = ArrayList<File>(splits.size + 1).apply {
add(cachedApk) // don't forget the base APK add(cachedApk) // don't forget the base APK
} }
splits.forEach { apkSplit -> // cache and check all splits splits.forEach { apkSplit -> // cache and check all splits
val suffix = "_${apkSplit.sha256}" val suffix = if (backup.version == 0.toByte()) "_${apkSplit.sha256}" else apkSplit.name
val (file, sha256) = cacheApk(token, packageName, suffix) val salt = backup.salt
val (file, sha256) = cacheApk(backup.version, backup.token, salt, packageName, suffix)
// check APK split's SHA-256 hash // check APK split's SHA-256 hash
if (apkSplit.sha256 != sha256) throw SecurityException( if (apkSplit.sha256 != sha256) throw SecurityException(
"$packageName:${apkSplit.name} has sha256 '$sha256'," + "$packageName:${apkSplit.name} has sha256 '$sha256'," +
@ -199,14 +203,22 @@ internal class ApkRestore(
@Throws(IOException::class) @Throws(IOException::class)
@Suppress("BlockingMethodInNonBlockingContext") // flows on Dispatcher.IO @Suppress("BlockingMethodInNonBlockingContext") // flows on Dispatcher.IO
private suspend fun cacheApk( private suspend fun cacheApk(
version: Byte,
token: Long, token: Long,
salt: String,
packageName: String, packageName: String,
suffix: String = "" suffix: String = ""
): Pair<File, String> { ): Pair<File, String> {
// create a cache file to write the APK into // create a cache file to write the APK into
val cachedApk = File.createTempFile(packageName + suffix, ".apk", context.cacheDir) val cachedApk = File.createTempFile(packageName + suffix, ".apk", context.cacheDir)
// copy APK to cache file and calculate SHA-256 hash while we are at it // 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()) val sha256 = copyStreamsAndGetHash(inputStream, cachedApk.outputStream())
return Pair(cachedApk, sha256) return Pair(cachedApk, sha256)
} }

View file

@ -7,5 +7,5 @@ val installModule = module {
factory { ApkInstaller(androidContext()) } factory { ApkInstaller(androidContext()) }
factory { DeviceInfo(androidContext()) } factory { DeviceInfo(androidContext()) }
factory { ApkSplitCompatibilityChecker(get()) } factory { ApkSplitCompatibilityChecker(get()) }
factory { ApkRestore(androidContext(), get(), get(), get()) } factory { ApkRestore(androidContext(), get(), get(), get(), get(), get()) }
} }

View file

@ -8,6 +8,7 @@ import android.content.pm.SigningInfo
import android.util.Log import android.util.Log
import android.util.PackageUtils.computeSha256DigestBytes import android.util.PackageUtils.computeSha256DigestBytes
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
import com.stevesoltys.seedvault.crypto.Crypto
import com.stevesoltys.seedvault.encodeBase64 import com.stevesoltys.seedvault.encodeBase64
import com.stevesoltys.seedvault.metadata.ApkSplit import com.stevesoltys.seedvault.metadata.ApkSplit
import com.stevesoltys.seedvault.metadata.MetadataManager import com.stevesoltys.seedvault.metadata.MetadataManager
@ -27,6 +28,7 @@ private val TAG = ApkBackup::class.java.simpleName
@Suppress("BlockingMethodInNonBlockingContext") @Suppress("BlockingMethodInNonBlockingContext")
internal class ApkBackup( internal class ApkBackup(
private val pm: PackageManager, private val pm: PackageManager,
private val crypto: Crypto,
private val settingsManager: SettingsManager, private val settingsManager: SettingsManager,
private val metadataManager: MetadataManager private val metadataManager: MetadataManager
) { ) {
@ -44,7 +46,7 @@ internal class ApkBackup(
suspend fun backupApkIfNecessary( suspend fun backupApkIfNecessary(
packageInfo: PackageInfo, packageInfo: PackageInfo,
packageState: PackageState, packageState: PackageState,
streamGetter: suspend (suffix: String) -> OutputStream streamGetter: suspend (name: String) -> OutputStream
): PackageMetadata? { ): PackageMetadata? {
// do not back up @pm@ // do not back up @pm@
val packageName = packageInfo.packageName val packageName = packageInfo.packageName
@ -102,7 +104,8 @@ internal class ApkBackup(
// get an InputStream for the APK // get an InputStream for the APK
val inputStream = getApkInputStream(packageInfo.applicationInfo.sourceDir) val inputStream = getApkInputStream(packageInfo.applicationInfo.sourceDir)
// copy the APK to the storage's output and calculate SHA-256 hash while at it // 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 // back up splits if they exist
val splits = val splits =
@ -148,7 +151,7 @@ internal class ApkBackup(
@Throws(IOException::class) @Throws(IOException::class)
private suspend fun backupSplitApks( private suspend fun backupSplitApks(
packageInfo: PackageInfo, packageInfo: PackageInfo,
streamGetter: suspend (suffix: String) -> OutputStream streamGetter: suspend (name: String) -> OutputStream
): List<ApkSplit> { ): List<ApkSplit> {
check(packageInfo.splitNames != null) check(packageInfo.splitNames != null)
val splitSourceDirs = packageInfo.applicationInfo.splitSourceDirs val splitSourceDirs = packageInfo.applicationInfo.splitSourceDirs
@ -159,7 +162,12 @@ internal class ApkBackup(
} }
val splits = ArrayList<ApkSplit>(packageInfo.splitNames.size) val splits = ArrayList<ApkSplit>(packageInfo.splitNames.size)
for (i in packageInfo.splitNames.indices) { 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) splits.add(split)
} }
return splits return splits
@ -167,9 +175,10 @@ internal class ApkBackup(
@Throws(IOException::class) @Throws(IOException::class)
private suspend fun backupSplitApk( private suspend fun backupSplitApk(
name: String, packageName: String,
splitName: String,
sourceDir: String, sourceDir: String,
streamGetter: suspend (suffix: String) -> OutputStream streamGetter: suspend (name: String) -> OutputStream
): ApkSplit { ): ApkSplit {
// Calculate sha256 hash first to determine file name suffix. // 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 // 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 sha256 = messageDigest.digest().encodeBase64()
val suffix = "_$sha256" val name = crypto.getNameForApk(metadataManager.salt, packageName, splitName)
// copy the split APK to the storage stream // copy the split APK to the storage stream
getApkInputStream(sourceDir).use { inputStream -> getApkInputStream(sourceDir).use { inputStream ->
streamGetter(suffix).use { outputStream -> streamGetter(name).use { outputStream ->
inputStream.copyTo(outputStream) inputStream.copyTo(outputStream)
} }
} }
return ApkSplit(name, sha256) return ApkSplit(splitName, sha256)
} }
} }

View file

@ -453,9 +453,8 @@ internal class BackupCoordinator(
): Boolean { ): Boolean {
val packageName = packageInfo.packageName val packageName = packageInfo.packageName
return try { return try {
apkBackup.backupApkIfNecessary(packageInfo, packageState) { suffix -> apkBackup.backupApkIfNecessary(packageInfo, packageState) { name ->
val token = settingsManager.getToken() ?: throw IOException("no current token") val token = settingsManager.getToken() ?: throw IOException("no current token")
val name = "${packageInfo.packageName}$suffix.apk"
plugin.getOutputStream(token, name) plugin.getOutputStream(token, name)
}?.let { packageMetadata -> }?.let { packageMetadata ->
plugin.getMetadataOutputStream().use { plugin.getMetadataOutputStream().use {

View file

@ -14,6 +14,7 @@ val backupModule = module {
single { single {
ApkBackup( ApkBackup(
pm = androidContext().packageManager, pm = androidContext().packageManager,
crypto = get(),
settingsManager = get(), settingsManager = get(),
metadataManager = get() metadataManager = get()
) )

View file

@ -13,6 +13,7 @@ interface RestorePlugin {
* Returns an [InputStream] for the given token, for reading an APK that is to be restored. * Returns an [InputStream] for the given token, for reading an APK that is to be restored.
*/ */
@Throws(IOException::class) @Throws(IOException::class)
@Deprecated("Use only for v0 restores")
suspend fun getApkInputStream(token: Long, packageName: String, suffix: String): InputStream suspend fun getApkInputStream(token: Long, packageName: String, suffix: String): InputStream
} }

View file

@ -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<Context>().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<String>()
val cacheFiles = slot<List<File>>()
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))
}
}

View file

@ -14,12 +14,14 @@ import com.stevesoltys.seedvault.getRandomString
import com.stevesoltys.seedvault.metadata.ApkSplit import com.stevesoltys.seedvault.metadata.ApkSplit
import com.stevesoltys.seedvault.metadata.PackageMetadata import com.stevesoltys.seedvault.metadata.PackageMetadata
import com.stevesoltys.seedvault.metadata.PackageMetadataMap import com.stevesoltys.seedvault.metadata.PackageMetadataMap
import com.stevesoltys.seedvault.restore.RestorableBackup
import com.stevesoltys.seedvault.restore.install.ApkInstallState.FAILED 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.FAILED_SYSTEM_APP
import com.stevesoltys.seedvault.restore.install.ApkInstallState.IN_PROGRESS import com.stevesoltys.seedvault.restore.install.ApkInstallState.IN_PROGRESS
import com.stevesoltys.seedvault.restore.install.ApkInstallState.QUEUED import com.stevesoltys.seedvault.restore.install.ApkInstallState.QUEUED
import com.stevesoltys.seedvault.restore.install.ApkInstallState.SUCCEEDED import com.stevesoltys.seedvault.restore.install.ApkInstallState.SUCCEEDED
import com.stevesoltys.seedvault.transport.TransportTest import com.stevesoltys.seedvault.transport.TransportTest
import com.stevesoltys.seedvault.transport.backup.BackupPlugin
import com.stevesoltys.seedvault.transport.restore.RestorePlugin import com.stevesoltys.seedvault.transport.restore.RestorePlugin
import io.mockk.coEvery import io.mockk.coEvery
import io.mockk.every import io.mockk.every
@ -48,16 +50,23 @@ internal class ApkRestoreTest : TransportTest() {
private val strictContext: Context = mockk<Context>().apply { private val strictContext: Context = mockk<Context>().apply {
every { packageManager } returns pm every { packageManager } returns pm
} }
private val backupPlugin: BackupPlugin = mockk()
private val restorePlugin: RestorePlugin = mockk() private val restorePlugin: RestorePlugin = mockk()
private val splitCompatChecker: ApkSplitCompatibilityChecker = mockk() private val splitCompatChecker: ApkSplitCompatibilityChecker = mockk()
private val apkInstaller: ApkInstaller = mockk() private val apkInstaller: ApkInstaller = mockk()
private val apkRestore: ApkRestore = private val apkRestore: ApkRestore = ApkRestore(
ApkRestore(strictContext, restorePlugin, splitCompatChecker, apkInstaller) strictContext,
backupPlugin,
restorePlugin,
crypto,
splitCompatChecker,
apkInstaller
)
private val icon: Drawable = mockk() private val icon: Drawable = mockk()
private val deviceName = getRandomString() private val deviceName = metadata.deviceName
private val packageName = packageInfo.packageName private val packageName = packageInfo.packageName
private val packageMetadata = PackageMetadata( private val packageMetadata = PackageMetadata(
time = Random.nextLong(), time = Random.nextLong(),
@ -71,6 +80,8 @@ internal class ApkRestoreTest : TransportTest() {
private val apkInputStream = ByteArrayInputStream(apkBytes) private val apkInputStream = ByteArrayInputStream(apkBytes)
private val appName = getRandomString() private val appName = getRandomString()
private val installerName = packageMetadata.installer private val installerName = packageMetadata.installer
private val backup = RestorableBackup(metadata.copy(packageMetadataMap = packageMetadataMap))
private val suffixName = getRandomString()
init { init {
// as we don't do strict signature checking, we can use a relaxed mock // 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 { fun `signature mismatch causes FAILED status`(@TempDir tmpDir: Path) = runBlocking {
// change SHA256 signature to random // change SHA256 signature to random
val packageMetadata = packageMetadata.copy(sha256 = getRandomString()) 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()) 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) assertQueuedFailFinished(i, value)
} }
} }
@ -97,10 +109,11 @@ internal class ApkRestoreTest : TransportTest() {
packageInfo.packageName = getRandomString() packageInfo.packageName = getRandomString()
every { strictContext.cacheDir } returns File(tmpDir.toString()) 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 { pm.getPackageArchiveInfo(any(), any()) } returns packageInfo
apkRestore.restore(token, deviceName, packageMetadataMap).collectIndexed { i, value -> apkRestore.restore(backup).collectIndexed { i, value ->
assertQueuedFailFinished(i, value) assertQueuedFailFinished(i, value)
} }
} }
@ -112,7 +125,7 @@ internal class ApkRestoreTest : TransportTest() {
apkInstaller.install(match { it.size == 1 }, packageName, installerName, any()) apkInstaller.install(match { it.size == 1 }, packageName, installerName, any())
} throws SecurityException() } throws SecurityException()
apkRestore.restore(token, deviceName, packageMetadataMap).collectIndexed { i, value -> apkRestore.restore(backup).collectIndexed { i, value ->
assertQueuedProgressFailFinished(i, value) assertQueuedProgressFailFinished(i, value)
} }
} }
@ -134,7 +147,43 @@ internal class ApkRestoreTest : TransportTest() {
apkInstaller.install(match { it.size == 1 }, packageName, installerName, any()) apkInstaller.install(match { it.size == 1 }, packageName, installerName, any())
} returns installResult } 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) 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) { when (i) {
0 -> { 0 -> {
val result = value[packageName] val result = value[packageName]
@ -231,7 +280,7 @@ internal class ApkRestoreTest : TransportTest() {
splitCompatChecker.isCompatible(deviceName, listOf(split1Name, split2Name)) splitCompatChecker.isCompatible(deviceName, listOf(split1Name, split2Name))
} returns false } returns false
apkRestore.restore(token, deviceName, packageMetadataMap).collectIndexed { i, value -> apkRestore.restore(backup).collectIndexed { i, value ->
assertQueuedProgressFailFinished(i, value) assertQueuedProgressFailFinished(i, value)
} }
} }
@ -240,20 +289,20 @@ internal class ApkRestoreTest : TransportTest() {
fun `split signature mismatch causes FAILED state`(@TempDir tmpDir: Path) = runBlocking { fun `split signature mismatch causes FAILED state`(@TempDir tmpDir: Path) = runBlocking {
// add one APK split to metadata // add one APK split to metadata
val splitName = getRandomString() val splitName = getRandomString()
val sha256 = getRandomBase64(23)
packageMetadataMap[packageName] = packageMetadataMap[packageName]!!.copy( 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 // cache APK and get icon as well as app name
cacheBaseApkAndGetInfo(tmpDir) cacheBaseApkAndGetInfo(tmpDir)
every { splitCompatChecker.isCompatible(deviceName, listOf(splitName)) } returns true every { splitCompatChecker.isCompatible(deviceName, listOf(splitName)) } returns true
every { crypto.getNameForApk(salt, packageName, splitName) } returns suffixName
coEvery { coEvery {
restorePlugin.getApkInputStream(token, packageName, "_$sha256") backupPlugin.getInputStream(token, suffixName)
} returns ByteArrayInputStream(getRandomByteArray()) } returns ByteArrayInputStream(getRandomByteArray())
apkRestore.restore(token, deviceName, packageMetadataMap).collectIndexed { i, value -> apkRestore.restore(backup).collectIndexed { i, value ->
assertQueuedProgressFailFinished(i, value) assertQueuedProgressFailFinished(i, value)
} }
} }
@ -272,11 +321,10 @@ internal class ApkRestoreTest : TransportTest() {
cacheBaseApkAndGetInfo(tmpDir) cacheBaseApkAndGetInfo(tmpDir)
every { splitCompatChecker.isCompatible(deviceName, listOf(splitName)) } returns true every { splitCompatChecker.isCompatible(deviceName, listOf(splitName)) } returns true
coEvery { every { crypto.getNameForApk(salt, packageName, splitName) } returns suffixName
restorePlugin.getApkInputStream(token, packageName, "_$sha256") coEvery { backupPlugin.getInputStream(token, suffixName) } throws IOException()
} throws IOException()
apkRestore.restore(token, deviceName, packageMetadataMap).collectIndexed { i, value -> apkRestore.restore(backup).collectIndexed { i, value ->
assertQueuedProgressFailFinished(i, value) assertQueuedProgressFailFinished(i, value)
} }
} }
@ -307,12 +355,12 @@ internal class ApkRestoreTest : TransportTest() {
val split2Bytes = byteArrayOf(0x07, 0x08, 0x09) val split2Bytes = byteArrayOf(0x07, 0x08, 0x09)
val split1InputStream = ByteArrayInputStream(split1Bytes) val split1InputStream = ByteArrayInputStream(split1Bytes)
val split2InputStream = ByteArrayInputStream(split2Bytes) val split2InputStream = ByteArrayInputStream(split2Bytes)
coEvery { val suffixName1 = getRandomString()
restorePlugin.getApkInputStream(token, packageName, "_$split1sha256") val suffixName2 = getRandomString()
} returns split1InputStream every { crypto.getNameForApk(salt, packageName, split1Name) } returns suffixName1
coEvery { coEvery { backupPlugin.getInputStream(token, suffixName1) } returns split1InputStream
restorePlugin.getApkInputStream(token, packageName, "_$split2sha256") every { crypto.getNameForApk(salt, packageName, split2Name) } returns suffixName2
} returns split2InputStream coEvery { backupPlugin.getInputStream(token, suffixName2) } returns split2InputStream
coEvery { coEvery {
apkInstaller.install(match { it.size == 3 }, packageName, installerName, any()) 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) 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) { private fun cacheBaseApkAndGetInfo(tmpDir: Path) {
every { strictContext.cacheDir } returns File(tmpDir.toString()) 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 { pm.getPackageArchiveInfo(any(), any()) } returns packageInfo
every { every {
@Suppress("UNRESOLVED_REFERENCE")
pm.loadItemIcon( pm.loadItemIcon(
packageInfo.applicationInfo, packageInfo.applicationInfo,
packageInfo.applicationInfo packageInfo.applicationInfo

View file

@ -36,9 +36,9 @@ import kotlin.random.Random
internal class ApkBackupTest : BackupTest() { internal class ApkBackupTest : BackupTest() {
private val pm: PackageManager = mockk() 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 signatureBytes = byteArrayOf(0x01, 0x02, 0x03)
private val signatureHash = byteArrayOf(0x03, 0x02, 0x01) private val signatureHash = byteArrayOf(0x03, 0x02, 0x01)
@ -140,7 +140,9 @@ internal class ApkBackupTest : BackupTest() {
) )
expectChecks() 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 { every {
pm.getInstallSourceInfo(packageInfo.packageName) pm.getInstallSourceInfo(packageInfo.packageName)
} returns InstallSourceInfo(null, null, null, updatedMetadata.installer) } returns InstallSourceInfo(null, null, null, updatedMetadata.installer)
@ -197,11 +199,21 @@ internal class ApkBackupTest : BackupTest() {
sha256 = "eHx5jjmlvBkQNVuubQzYejay4Q_QICqD47trAF2oNHI", sha256 = "eHx5jjmlvBkQNVuubQzYejay4Q_QICqD47trAF2oNHI",
signatures = packageMetadata.signatures signatures = packageMetadata.signatures
) )
val suffixName1 = getRandomString()
val suffixName2 = getRandomString()
expectChecks() expectChecks()
coEvery { streamGetter.invoke("") } returns apkOutputStream every { metadataManager.salt } returns salt
coEvery { streamGetter.invoke("_$split1Sha256") } returns split1OutputStream every { crypto.getNameForApk(salt, packageInfo.packageName) } returns name
coEvery { streamGetter.invoke("_$split2Sha256") } returns split2OutputStream 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 { every {
pm.getInstallSourceInfo(packageInfo.packageName) pm.getInstallSourceInfo(packageInfo.packageName)