Back up app APKs in new v2 format

We still support restoring in v1 format for some time.
This commit is contained in:
Torsten Grote 2024-09-06 10:04:51 -03:00
parent e17c98857f
commit 897ae48b44
No known key found for this signature in database
GPG key ID: 3E5F77D92CF891FF
20 changed files with 1392 additions and 526 deletions

View file

@ -13,7 +13,6 @@ import kotlinx.coroutines.runBlocking
import org.calyxos.seedvault.core.backends.Backend import org.calyxos.seedvault.core.backends.Backend
import org.calyxos.seedvault.core.backends.BackendTest import org.calyxos.seedvault.core.backends.BackendTest
import org.calyxos.seedvault.core.backends.saf.SafBackend import org.calyxos.seedvault.core.backends.saf.SafBackend
import org.calyxos.seedvault.core.backends.saf.SafProperties
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
@ -25,14 +24,7 @@ class SafBackendTest : BackendTest(), KoinComponent {
private val context = InstrumentationRegistry.getInstrumentation().targetContext private val context = InstrumentationRegistry.getInstrumentation().targetContext
private val settingsManager by inject<SettingsManager>() private val settingsManager by inject<SettingsManager>()
private val safStorage = settingsManager.getSafProperties() ?: error("No SAF storage") private val safProperties = 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,
)
override val backend: Backend = SafBackend(context, safProperties, ".SeedvaultTest") override val backend: Backend = SafBackend(context, safProperties, ".SeedvaultTest")
@Test @Test

View file

@ -10,7 +10,7 @@ import com.stevesoltys.seedvault.crypto.TYPE_BACKUP_FULL
import com.stevesoltys.seedvault.crypto.TYPE_BACKUP_KV import com.stevesoltys.seedvault.crypto.TYPE_BACKUP_KV
import java.nio.ByteBuffer 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_PACKAGE_LENGTH_SIZE = 255
internal const val MAX_KEY_LENGTH_SIZE = MAX_PACKAGE_LENGTH_SIZE internal const val MAX_KEY_LENGTH_SIZE = MAX_PACKAGE_LENGTH_SIZE
internal const val MAX_VERSION_HEADER_SIZE = internal const val MAX_VERSION_HEADER_SIZE =

View file

@ -8,8 +8,12 @@ package com.stevesoltys.seedvault.metadata
import android.content.pm.ApplicationInfo.FLAG_STOPPED import android.content.pm.ApplicationInfo.FLAG_STOPPED
import android.os.Build import android.os.Build
import com.stevesoltys.seedvault.crypto.TYPE_METADATA import com.stevesoltys.seedvault.crypto.TYPE_METADATA
import com.stevesoltys.seedvault.encodeBase64
import com.stevesoltys.seedvault.header.VERSION import com.stevesoltys.seedvault.header.VERSION
import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR 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 org.calyxos.backup.storage.crypto.StreamCrypto.toByteArray
import java.nio.ByteBuffer import java.nio.ByteBuffer
@ -91,12 +95,55 @@ data class PackageMetadata(
internal val version: Long? = null, internal val version: Long? = null,
internal val installer: String? = null, internal val installer: String? = null,
internal val splits: List<ApkSplit>? = null, internal val splits: List<ApkSplit>? = null,
internal val baseApkChunkIds: List<String>? = null, // used for v2
internal val chunkIds: List<String>? = null, // used for v2
internal val sha256: String? = null, internal val sha256: String? = null,
internal val signatures: List<String>? = null, internal val signatures: List<String>? = 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,
chunkIds = app.chunkIdsList.hexFromProto(),
system = app.system,
isLaunchableSystemApp = app.launchableSystemApp,
version = app.apk.versionCode,
installer = app.apk.installer,
baseApkChunkIds = run {
val baseChunk = app.apk.splitsList.find { it.name == BASE_SPLIT }
if (baseChunk == null || baseChunk.chunkIdsCount == 0) {
null
} else {
baseChunk.chunkIdsList.hexFromProto()
}
},
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
sha256 = null,
signatures = app.apk.signaturesList.map { it.toByteArray().encodeBase64() }.takeIf {
it.isNotEmpty()
},
)
}
val isInternalSystem: Boolean = system && !isLaunchableSystemApp val isInternalSystem: Boolean = system && !isLaunchableSystemApp
fun hasApk(): Boolean { fun hasApk(): Boolean {
return version != null && sha256 != null && signatures != null return version != null && // v2 doesn't use sha256 here
(sha256 != null || baseApkChunkIds?.isNotEmpty() == true) &&
signatures != null
} }
} }
@ -104,6 +151,7 @@ data class ApkSplit(
val name: String, val name: String,
val size: Long?, val size: Long?,
val sha256: String, val sha256: String,
val chunkIds: List<String>? = null, // used for v2
// There's also a revisionCode, but it doesn't seem to be used just yet // There's also a revisionCode, but it doesn't seem to be used just yet
) )

View file

@ -11,14 +11,16 @@ import android.content.Intent
import android.content.pm.PackageManager 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.content.pm.SigningInfo
import android.util.Log import android.util.Log
import com.stevesoltys.seedvault.BackupStateManager import com.stevesoltys.seedvault.BackupStateManager
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
import com.stevesoltys.seedvault.crypto.Crypto
import com.stevesoltys.seedvault.metadata.ApkSplit
import com.stevesoltys.seedvault.metadata.PackageMetadata
import com.stevesoltys.seedvault.backend.BackendManager import com.stevesoltys.seedvault.backend.BackendManager
import com.stevesoltys.seedvault.backend.LegacyStoragePlugin 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.restore.RestorableBackup import com.stevesoltys.seedvault.restore.RestorableBackup
import com.stevesoltys.seedvault.restore.RestoreService import com.stevesoltys.seedvault.restore.RestoreService
import com.stevesoltys.seedvault.restore.install.ApkInstallState.FAILED import com.stevesoltys.seedvault.restore.install.ApkInstallState.FAILED
@ -26,9 +28,11 @@ 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.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.getBlobHandles
import com.stevesoltys.seedvault.transport.backup.isSystemApp import com.stevesoltys.seedvault.transport.backup.isSystemApp
import com.stevesoltys.seedvault.worker.copyStreamsAndGetHash import com.stevesoltys.seedvault.transport.restore.Loader
import com.stevesoltys.seedvault.worker.getSignatures import com.stevesoltys.seedvault.transport.restore.RestorableBackup
import com.stevesoltys.seedvault.worker.hashSignature
import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@ -37,6 +41,9 @@ import org.calyxos.seedvault.core.backends.Backend
import org.calyxos.seedvault.core.backends.LegacyAppBackupFile import org.calyxos.seedvault.core.backends.LegacyAppBackupFile
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.security.MessageDigest
import java.util.Locale import java.util.Locale
private val TAG = ApkRestore::class.java.simpleName private val TAG = ApkRestore::class.java.simpleName
@ -46,6 +53,7 @@ internal class ApkRestore(
private val backupManager: IBackupManager, private val backupManager: IBackupManager,
private val backupStateManager: BackupStateManager, private val backupStateManager: BackupStateManager,
private val backendManager: BackendManager, private val backendManager: BackendManager,
private val loader: Loader,
@Suppress("Deprecation") @Suppress("Deprecation")
private val legacyStoragePlugin: LegacyStoragePlugin, private val legacyStoragePlugin: LegacyStoragePlugin,
private val crypto: Crypto, private val crypto: Crypto,
@ -130,6 +138,7 @@ internal class ApkRestore(
Log.e(TAG, "Timeout while re-installing APK for $packageName.", e) Log.e(TAG, "Timeout while re-installing APK for $packageName.", e)
mInstallResult.update { it.fail(packageName) } mInstallResult.update { it.fail(packageName) }
} catch (e: Exception) { } catch (e: Exception) {
if (e::class.simpleName == "MockKException") throw e
Log.e(TAG, "Unexpected exception while re-installing APK for $packageName.", e) Log.e(TAG, "Unexpected exception while re-installing APK for $packageName.", e)
mInstallResult.update { it.fail(packageName) } mInstallResult.update { it.fail(packageName) }
} }
@ -168,10 +177,10 @@ internal class ApkRestore(
} }
// cache the APK and get its hash // 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.baseApkChunkIds)
// check APK's SHA-256 hash // check APK's SHA-256 hash for backup versions before 2
if (metadata.sha256 != sha256) throw SecurityException( if (backup.version < 2 && metadata.sha256 != sha256) throw SecurityException(
"Package $packageName has sha256 '$sha256', but '${metadata.sha256}' expected." "Package $packageName has sha256 '$sha256', but '${metadata.sha256}' expected."
) )
@ -262,10 +271,9 @@ internal class ApkRestore(
} }
splits.forEach { apkSplit -> // cache and check all splits splits.forEach { apkSplit -> // cache and check all splits
val suffix = if (backup.version == 0.toByte()) "_${apkSplit.sha256}" else apkSplit.name val suffix = if (backup.version == 0.toByte()) "_${apkSplit.sha256}" else apkSplit.name
val salt = backup.salt val (file, sha256) = cacheApk(backup, packageName, apkSplit.chunkIds, suffix)
val (file, sha256) = cacheApk(backup.version, backup.token, salt, packageName, suffix) // check APK split's SHA-256 hash for backup versions before 2
// check APK split's SHA-256 hash if (backup.version < 2 && apkSplit.sha256 != sha256) throw SecurityException(
if (apkSplit.sha256 != sha256) throw SecurityException(
"$packageName:${apkSplit.name} has sha256 '$sha256'," + "$packageName:${apkSplit.name} has sha256 '$sha256'," +
" but '${apkSplit.sha256}' expected." " but '${apkSplit.sha256}' expected."
) )
@ -282,20 +290,30 @@ internal class ApkRestore(
*/ */
@Throws(IOException::class) @Throws(IOException::class)
private suspend fun cacheApk( private suspend fun cacheApk(
version: Byte, backup: RestorableBackup,
token: Long,
salt: String,
packageName: String, packageName: String,
chunkIds: List<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 = if (version == 0.toByte()) { val inputStream = when (backup.version) {
legacyStoragePlugin.getApkInputStream(token, packageName, suffix) 0.toByte() -> {
} else { legacyStoragePlugin.getApkInputStream(backup.token, packageName, suffix)
val name = crypto.getNameForApk(salt, packageName, suffix) }
backend.load(LegacyAppBackupFile.Blob(token, name)) 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?.let {
snapshot.getBlobHandles(repoId, it)
} ?: error("No chunkIds for $packageName-$suffix")
loader.loadFiles(handles)
}
} }
val sha256 = copyStreamsAndGetHash(inputStream, cachedApk.outputStream()) val sha256 = copyStreamsAndGetHash(inputStream, cachedApk.outputStream())
return Pair(cachedApk, sha256) return Pair(cachedApk, sha256)
@ -343,3 +361,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<String> {
return if (this == null) {
emptyList()
} else if (hasMultipleSigners()) {
apkContentsSigners.map { signature ->
hashSignature(signature).encodeBase64()
}
} else {
signingCertificateHistory.map { signature ->
hashSignature(signature).encodeBase64()
}
}
}

View file

@ -14,7 +14,7 @@ val installModule = module {
factory { DeviceInfo(androidContext()) } factory { DeviceInfo(androidContext()) }
factory { ApkSplitCompatibilityChecker(get()) } factory { ApkSplitCompatibilityChecker(get()) }
factory { 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() androidContext().getSystemService(UserManager::class.java)!!.isAllowedToInstallApks()
} }
} }

View file

@ -5,12 +5,14 @@
package com.stevesoltys.seedvault.transport.backup package com.stevesoltys.seedvault.transport.backup
import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.transport.SnapshotManager import com.stevesoltys.seedvault.transport.SnapshotManager
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
internal class AppBackupManager( internal class AppBackupManager(
private val blobsCache: BlobsCache, private val blobsCache: BlobsCache,
private val settingsManager: SettingsManager,
private val snapshotManager: SnapshotManager, private val snapshotManager: SnapshotManager,
private val snapshotCreatorFactory: SnapshotCreatorFactory, private val snapshotCreatorFactory: SnapshotCreatorFactory,
) { ) {
@ -25,12 +27,15 @@ internal class AppBackupManager(
blobsCache.populateCache() blobsCache.populateCache()
} }
suspend fun afterBackupFinished() { suspend fun afterBackupFinished(success: Boolean) {
log.info { "After backup finished" } log.info { "After backup finished. Success: $success" }
blobsCache.clear() blobsCache.clear()
val snapshot = snapshotCreator?.finalizeSnapshot() ?: error("Had no snapshotCreator") if (success) {
keepTrying { val snapshot = snapshotCreator?.finalizeSnapshot() ?: error("Had no snapshotCreator")
snapshotManager.saveSnapshot(snapshot) keepTrying {
snapshotManager.saveSnapshot(snapshot)
}
settingsManager.token = snapshot.token
} }
snapshotCreator = null snapshotCreator = null
} }

View file

@ -22,6 +22,7 @@ import com.stevesoltys.seedvault.proto.Snapshot.Apk
import com.stevesoltys.seedvault.proto.Snapshot.App import com.stevesoltys.seedvault.proto.Snapshot.App
import com.stevesoltys.seedvault.proto.Snapshot.Blob import com.stevesoltys.seedvault.proto.Snapshot.Blob
import com.stevesoltys.seedvault.settings.SettingsManager import com.stevesoltys.seedvault.settings.SettingsManager
import org.calyxos.seedvault.core.backends.AppBackupFileType
import org.calyxos.seedvault.core.toHexString import org.calyxos.seedvault.core.toHexString
internal class SnapshotCreatorFactory( internal class SnapshotCreatorFactory(
@ -130,3 +131,8 @@ internal class SnapshotCreator(
fun Iterable<String>.forProto() = map { ByteString.fromHex(it) } fun Iterable<String>.forProto() = map { ByteString.fromHex(it) }
fun Iterable<ByteString>.hexFromProto() = map { it.toByteArray().toHexString() } fun Iterable<ByteString>.hexFromProto() = map { it.toByteArray().toHexString() }
fun ByteString.hexFromProto() = toByteArray().toHexString() fun ByteString.hexFromProto() = toByteArray().toHexString()
fun Snapshot.getBlobHandles(repoId: String, chunkIds: List<String>) = chunkIds.map { chunkId ->
val blobId = blobsMap[chunkId]?.id?.hexFromProto()
?: error("Blob for $chunkId missing from snapshot $token")
AppBackupFileType.Blob(repoId, blobId)
}

View file

@ -19,8 +19,10 @@ import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
import com.stevesoltys.seedvault.R import com.stevesoltys.seedvault.R
import com.stevesoltys.seedvault.metadata.MetadataManager import com.stevesoltys.seedvault.metadata.MetadataManager
import com.stevesoltys.seedvault.settings.SettingsManager import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.transport.backup.AppBackupManager
import com.stevesoltys.seedvault.transport.backup.PackageService import com.stevesoltys.seedvault.transport.backup.PackageService
import com.stevesoltys.seedvault.worker.BackupRequester import com.stevesoltys.seedvault.worker.BackupRequester
import kotlinx.coroutines.runBlocking
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.koin.core.component.inject import org.koin.core.component.inject
@ -36,6 +38,7 @@ internal class NotificationBackupObserver(
private val metadataManager: MetadataManager by inject() private val metadataManager: MetadataManager by inject()
private val packageService: PackageService by inject() private val packageService: PackageService by inject()
private val settingsManager: SettingsManager by inject() private val settingsManager: SettingsManager by inject()
private val appBackupManager: AppBackupManager by inject()
private var currentPackage: String? = null private var currentPackage: String? = null
private var numPackages: Int = 0 private var numPackages: Int = 0
private var numPackagesToReport: Int = 0 private var numPackagesToReport: Int = 0
@ -141,6 +144,12 @@ internal class NotificationBackupObserver(
Log.e(TAG, "Error getting number of all user packages: ", e) Log.e(TAG, "Error getting number of all user packages: ", e)
requestedPackages requestedPackages
} }
// TODO handle exceptions
runBlocking {
// TODO check if UI thread
Log.d("TAG", "Finalizing backup...")
appBackupManager.afterBackupFinished(success)
}
nm.onBackupFinished(success, numPackagesToReport, total, size) nm.onBackupFinished(success, numPackagesToReport, total, size)
} }
} }

View file

@ -5,7 +5,6 @@
package com.stevesoltys.seedvault.worker package com.stevesoltys.seedvault.worker
import android.annotation.SuppressLint
import android.content.pm.PackageInfo import android.content.pm.PackageInfo
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.content.pm.Signature import android.content.pm.Signature
@ -13,94 +12,91 @@ 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.metadata.ApkSplit
import com.stevesoltys.seedvault.metadata.MetadataManager
import com.stevesoltys.seedvault.metadata.PackageMetadata import com.stevesoltys.seedvault.metadata.PackageMetadata
import com.stevesoltys.seedvault.proto.Snapshot
import com.stevesoltys.seedvault.settings.SettingsManager 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.isNotUpdatedSystemApp
import com.stevesoltys.seedvault.transport.backup.isTestOnly import com.stevesoltys.seedvault.transport.backup.isTestOnly
import org.calyxos.seedvault.core.toHexString
import java.io.File import java.io.File
import java.io.FileInputStream import java.io.FileInputStream
import java.io.IOException import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.security.MessageDigest
private val TAG = ApkBackup::class.java.simpleName private val TAG = ApkBackup::class.java.simpleName
internal const val BASE_SPLIT = "org.calyxos.seedvault.BASE_SPLIT"
internal class ApkBackup( internal class ApkBackup(
private val pm: PackageManager, 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 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, * Checks if a new APK needs to get backed up,
* because the version code or the signatures have changed. * because the version code or the signatures have changed.
* Only if an APK needs a backup, an [OutputStream] is obtained from the given streamGetter * Only if APKs need backup, they get chunked and uploaded.
* and the APK binary written to it.
* *
* @return new [PackageMetadata] if an APK backup was made or null if no backup was made. * @return new [PackageMetadata] if an APK backup was made or null if no backup was made.
*/ */
@Throws(IOException::class) @Throws(IOException::class)
@SuppressLint("NewApi") // can be removed when minSdk is set to 30 suspend fun backupApkIfNecessary(packageInfo: PackageInfo) {
suspend fun backupApkIfNecessary(
packageInfo: PackageInfo,
streamGetter: suspend (name: String) -> OutputStream,
): PackageMetadata? {
// do not back up @pm@ // do not back up @pm@
val packageName = packageInfo.packageName 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 // 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 // do not back up if package is blacklisted
if (!settingsManager.isBackupEnabled(packageName)) { if (!settingsManager.isBackupEnabled(packageName)) {
Log.d(TAG, "Package $packageName is blacklisted. Not backing it up.") 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 // 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 // see: https://commonsware.com/blog/2017/10/31/android-studio-3p0-flag-test-only.html
if (packageInfo.isTestOnly()) { if (packageInfo.isTestOnly()) {
Log.d(TAG, "Package $packageName is test-only app. Not backing it up.") 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 // do not back up system apps that haven't been updated
if (packageInfo.isNotUpdatedSystemApp()) { if (packageInfo.isNotUpdatedSystemApp()) {
Log.d(TAG, "Package $packageName is vanilla system app. Not backing it up.") 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 // TODO remove when adding support for packages with multiple signers
val signingInfo = packageInfo.signingInfo ?: return null val signingInfo = packageInfo.signingInfo ?: return
if (signingInfo.hasMultipleSigners()) { if (signingInfo.hasMultipleSigners()) {
Log.e(TAG, "Package $packageName has multiple signers. Not backing it up.") Log.e(TAG, "Package $packageName has multiple signers. Not backing it up.")
return null return
} }
// get signatures // get signatures
val signatures = signingInfo.getSignatures() val signatures = signingInfo.getSignaturesHex()
if (signatures.isEmpty()) { if (signatures.isEmpty()) {
Log.e(TAG, "Package $packageName has no signatures. Not backing it up.") Log.e(TAG, "Package $packageName has no signatures. Not backing it up.")
return null return
} }
// get cached metadata about package // get info from latest snapshot
val packageMetadata = metadataManager.getPackageMetadata(packageName)
?: PackageMetadata()
// get version codes
val version = packageInfo.longVersionCode 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 // 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( Log.d(
TAG, "Package $packageName with version $version" + TAG, "Package $packageName with version $version" +
" already has a backup ($backedUpVersion)" + " 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, // 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. // 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 // get an InputStream for the APK
val sourceDir = packageInfo.applicationInfo?.sourceDir ?: return null val sourceDir = packageInfo.applicationInfo?.sourceDir ?: return
val inputStream = getApkInputStream(sourceDir) // upload the APK to the backend
// copy the APK to the storage's output and calculate SHA-256 hash while at it getApkInputStream(sourceDir).use { inputStream ->
val name = crypto.getNameForApk(metadataManager.salt, packageName) backupReceiver.readFromStream(inputStream)
val sha256 = copyStreamsAndGetHash(inputStream, streamGetter(name)) }
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 // back up splits if they exist
val splits = val splits = if (packageInfo.splitNames == null) {
if (packageInfo.splitNames == null) null else backupSplitApks(packageInfo, streamGetter) 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}.") 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( private fun signaturesChanged(
packageMetadata: PackageMetadata, apk: Snapshot.Apk?,
signatures: List<String>, signatures: List<String>,
): Boolean { ): Boolean {
// no signatures in package metadata counts as them not having changed // no signatures counts as them not having changed
if (packageMetadata.signatures == null) return false if (apk == null || apk.signaturesList.isNullOrEmpty()) return false
val sigHex = apk.signaturesList.hexFromProto()
// TODO to support multiple signers check if lists differ // TODO to support multiple signers check if lists differ
return packageMetadata.signatures.intersect(signatures).isEmpty() return sigHex.intersect(signatures.toSet()).isEmpty()
} }
@Throws(IOException::class) @Throws(IOException::class)
@ -159,8 +167,8 @@ internal class ApkBackup(
@Throws(IOException::class) @Throws(IOException::class)
private suspend fun backupSplitApks( private suspend fun backupSplitApks(
packageInfo: PackageInfo, packageInfo: PackageInfo,
streamGetter: suspend (name: String) -> OutputStream, chunkMap: MutableMap<String, Snapshot.Blob>,
): List<ApkSplit> { ): List<Snapshot.Split> {
check(packageInfo.splitNames != null) check(packageInfo.splitNames != null)
// attention: though not documented, splitSourceDirs can be null // attention: though not documented, splitSourceDirs can be null
val splitSourceDirs = packageInfo.applicationInfo?.splitSourceDirs ?: emptyArray() val splitSourceDirs = packageInfo.applicationInfo?.splitSourceDirs ?: emptyArray()
@ -169,97 +177,42 @@ internal class ApkBackup(
"splitNames is ${packageInfo.splitNames.toList()}, " + "splitNames is ${packageInfo.splitNames.toList()}, " +
"but splitSourceDirs is ${splitSourceDirs.toList()}" "but splitSourceDirs is ${splitSourceDirs.toList()}"
} }
val splits = ArrayList<ApkSplit>(packageInfo.splitNames.size) val splits = ArrayList<Snapshot.Split>(packageInfo.splitNames.size)
for (i in packageInfo.splitNames.indices) { for (i in packageInfo.splitNames.indices) {
val split = backupSplitApk( // copy the split APK to the storage stream
packageName = packageInfo.packageName, getApkInputStream(splitSourceDirs[i]).use { inputStream ->
splitName = packageInfo.splitNames[i], backupReceiver.readFromStream(inputStream)
sourceDir = splitSourceDirs[i], }
streamGetter = streamGetter val backupData = backupReceiver.finalize()
) val split = Snapshot.Split.newBuilder()
.setName(packageInfo.splitNames[i])
.addAllChunkIds(backupData.chunks.forProto())
.build()
splits.add(split) splits.add(split)
chunkMap.putAll(backupData.chunkMap)
} }
return splits 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] * Returns a list of lowercase hex encoded SHA-256 signature hashes.
* 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 SigningInfo?.getSignaturesHex(): List<String> {
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<String> {
return if (this == null) { return if (this == null) {
emptyList() emptyList()
} else if (hasMultipleSigners()) { } else if (hasMultipleSigners()) {
apkContentsSigners.map { signature -> apkContentsSigners.map { signature ->
hashSignature(signature).encodeBase64() hashSignature(signature).toHexString()
} }
} else { } else {
signingCertificateHistory.map { signature -> 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() return computeSha256DigestBytes(signature.toByteArray()) ?: throw AssertionError()
} }

View file

@ -8,19 +8,15 @@ package com.stevesoltys.seedvault.worker
import android.content.Context import android.content.Context
import android.content.pm.PackageInfo import android.content.pm.PackageInfo
import android.util.Log import android.util.Log
import com.stevesoltys.seedvault.backend.isOutOfSpace
import com.stevesoltys.seedvault.metadata.MetadataManager import com.stevesoltys.seedvault.metadata.MetadataManager
import com.stevesoltys.seedvault.metadata.PackageState.NOT_ALLOWED import com.stevesoltys.seedvault.metadata.PackageState.NOT_ALLOWED
import com.stevesoltys.seedvault.metadata.PackageState.WAS_STOPPED 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.settings.SettingsManager
import com.stevesoltys.seedvault.transport.backup.PackageService import com.stevesoltys.seedvault.transport.backup.PackageService
import com.stevesoltys.seedvault.transport.backup.isStopped import com.stevesoltys.seedvault.transport.backup.isStopped
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
import com.stevesoltys.seedvault.ui.notification.getAppName import com.stevesoltys.seedvault.ui.notification.getAppName
import kotlinx.coroutines.delay
import org.calyxos.seedvault.core.backends.LegacyAppBackupFile
import java.io.IOException import java.io.IOException
internal class ApkBackupManager( internal class ApkBackupManager(
@ -30,7 +26,6 @@ internal class ApkBackupManager(
private val packageService: PackageService, private val packageService: PackageService,
private val iconManager: IconManager, private val iconManager: IconManager,
private val apkBackup: ApkBackup, private val apkBackup: ApkBackup,
private val backendManager: BackendManager,
private val nm: BackupNotificationManager, private val nm: BackupNotificationManager,
) { ) {
@ -51,14 +46,6 @@ internal class ApkBackupManager(
backUpApks() backUpApks()
} }
} finally { } 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() nm.onApkBackupDone()
} }
} }
@ -107,37 +94,15 @@ internal class ApkBackupManager(
} }
/** /**
* Backs up an APK for the given [PackageInfo]. * Backs up one (or more split) APK(s) for the given [PackageInfo], if needed.
*
* @return true if a backup was performed and false if no backup was needed or it failed.
*/ */
private suspend fun backUpApk(packageInfo: PackageInfo): Boolean { private suspend fun backUpApk(packageInfo: PackageInfo) {
val packageName = packageInfo.packageName val packageName = packageInfo.packageName
return try { try {
apkBackup.backupApkIfNecessary(packageInfo) { name -> apkBackup.backupApkIfNecessary(packageInfo)
val token = settingsManager.getToken() ?: throw IOException("no current token")
backendManager.backend.save(LegacyAppBackupFile.Blob(token, name))
}?.let { packageMetadata ->
metadataManager.onApkBackedUp(packageInfo, packageMetadata)
true
} ?: false
} catch (e: IOException) { } catch (e: IOException) {
Log.e(TAG, "Error while writing APK for $packageName", e) Log.e(TAG, "Error while writing APK for $packageName", e)
if (e.isOutOfSpace()) nm.onInsufficientSpaceError() 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)
}
} }
} }
} }

View file

@ -24,6 +24,7 @@ import androidx.work.WorkManager
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
import com.stevesoltys.seedvault.backend.BackendManager import com.stevesoltys.seedvault.backend.BackendManager
import com.stevesoltys.seedvault.settings.SettingsManager 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.BackupNotificationManager
import com.stevesoltys.seedvault.ui.notification.NOTIFICATION_ID_OBSERVER import com.stevesoltys.seedvault.ui.notification.NOTIFICATION_ID_OBSERVER
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
@ -101,6 +102,7 @@ class AppBackupWorker(
private val backupRequester: BackupRequester by inject() private val backupRequester: BackupRequester by inject()
private val settingsManager: SettingsManager by inject() private val settingsManager: SettingsManager by inject()
private val apkBackupManager: ApkBackupManager by inject() private val apkBackupManager: ApkBackupManager by inject()
private val appBackupManager: AppBackupManager by inject()
private val backendManager: BackendManager by inject() private val backendManager: BackendManager by inject()
private val nm: BackupNotificationManager by inject() private val nm: BackupNotificationManager by inject()
@ -137,6 +139,15 @@ class AppBackupWorker(
private suspend fun doBackup(): Result { private suspend fun doBackup(): Result {
var result: Result = Result.success() 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 { try {
Log.i(TAG, "Starting APK backup... (stopped: $isStopped)") Log.i(TAG, "Starting APK backup... (stopped: $isStopped)")
if (!isStopped) apkBackupManager.backup() if (!isStopped) apkBackupManager.backup()

View file

@ -27,13 +27,14 @@ val workerModule = module {
appBackupManager = get(), appBackupManager = get(),
) )
} }
single { AppBackupManager(get(), get(), get()) } single { AppBackupManager(get(), get(), get(), get()) }
single { single {
ApkBackup( ApkBackup(
pm = androidContext().packageManager, pm = androidContext().packageManager,
crypto = get(), backupReceiver = get(),
appBackupManager = get(),
snapshotManager = get(),
settingsManager = get(), settingsManager = get(),
metadataManager = get()
) )
} }
single { single {
@ -44,7 +45,6 @@ val workerModule = module {
packageService = get(), packageService = get(),
apkBackup = get(), apkBackup = get(),
iconManager = get(), iconManager = get(),
backendManager = get(),
nm = get() nm = get()
) )
} }

View file

@ -416,10 +416,7 @@ internal class AppSelectionManagerTest : TransportTest() {
} }
private fun getRestorableBackup(map: Map<String, PackageMetadata>) = RestorableBackup( private fun getRestorableBackup(map: Map<String, PackageMetadata>) = RestorableBackup(
backupMetadata = backupMetadata.copy( backupMetadata = backupMetadata.copy(packageMetadataMap = map as PackageMetadataMap),
version = 2,
packageMetadataMap = map as PackageMetadataMap,
),
repoId = repoId, repoId = repoId,
snapshot = snapshot, snapshot = snapshot,
) )

View file

@ -14,29 +14,43 @@ import android.content.pm.Signature
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import android.util.PackageUtils import android.util.PackageUtils
import app.cash.turbine.test 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.BackupStateManager
import com.stevesoltys.seedvault.assertReadEquals 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.getRandomString
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.backend.LegacyStoragePlugin import com.stevesoltys.seedvault.proto.Snapshot
import com.stevesoltys.seedvault.backend.BackendManager
import com.stevesoltys.seedvault.restore.RestorableBackup
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.SnapshotManager
import com.stevesoltys.seedvault.transport.TransportTest 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.transport.restore.RestorableBackup
import com.stevesoltys.seedvault.worker.ApkBackup import com.stevesoltys.seedvault.worker.ApkBackup
import com.stevesoltys.seedvault.worker.BASE_SPLIT
import io.mockk.Runs
import io.mockk.coEvery import io.mockk.coEvery
import io.mockk.every import io.mockk.every
import io.mockk.just
import io.mockk.mockk import io.mockk.mockk
import io.mockk.mockkStatic import io.mockk.mockkStatic
import io.mockk.slot import io.mockk.slot
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.calyxos.seedvault.core.backends.AppBackupFileType
import org.calyxos.seedvault.core.backends.Backend import org.calyxos.seedvault.core.backends.Backend
import org.calyxos.seedvault.core.backends.LegacyAppBackupFile import org.calyxos.seedvault.core.toHexString
import org.junit.jupiter.api.Assertions.assertArrayEquals import org.junit.jupiter.api.Assertions.assertArrayEquals
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertFalse
@ -48,7 +62,7 @@ import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.io.File import java.io.File
import java.io.FileInputStream import java.io.FileInputStream
import java.io.OutputStream import java.io.InputStream
import java.nio.file.Path import java.nio.file.Path
import kotlin.random.Random import kotlin.random.Random
@ -63,6 +77,11 @@ internal class ApkBackupRestoreTest : TransportTest() {
private val backendManager: BackendManager = mockk() private val backendManager: BackendManager = mockk()
private val backupManager: IBackupManager = mockk() private val backupManager: IBackupManager = mockk()
private val backupStateManager: BackupStateManager = 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") @Suppress("Deprecation")
private val legacyStoragePlugin: LegacyStoragePlugin = mockk() private val legacyStoragePlugin: LegacyStoragePlugin = mockk()
@ -71,12 +90,14 @@ internal class ApkBackupRestoreTest : TransportTest() {
private val apkInstaller: ApkInstaller = mockk() private val apkInstaller: ApkInstaller = mockk()
private val installRestriction: InstallRestriction = 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( private val apkRestore: ApkRestore = ApkRestore(
context = strictContext, context = strictContext,
backupManager = backupManager, backupManager = backupManager,
backupStateManager = backupStateManager, backupStateManager = backupStateManager,
backendManager = backendManager, backendManager = backendManager,
loader = loader,
legacyStoragePlugin = legacyStoragePlugin, legacyStoragePlugin = legacyStoragePlugin,
crypto = crypto, crypto = crypto,
splitCompatChecker = splitCompatChecker, splitCompatChecker = splitCompatChecker,
@ -90,29 +111,46 @@ internal class ApkBackupRestoreTest : TransportTest() {
private val packageName: String = packageInfo.packageName private val packageName: String = packageInfo.packageName
private val splitName = getRandomString() private val splitName = getRandomString()
private val splitBytes = byteArrayOf(0x07, 0x08, 0x09) private val splitBytes = byteArrayOf(0x07, 0x08, 0x09)
private val splitSha256 = "ZqZ1cVH47lXbEncWx-Pc4L6AdLZOIO2lQuXB5GypxB4" private val apkChunkId = Random.nextBytes(32).toHexString()
private val packageMetadata = PackageMetadata( private val splitChunkId = Random.nextBytes(32).toHexString()
time = Random.nextLong(), private val apkBlob =
version = packageInfo.longVersionCode - 1, Snapshot.Blob.newBuilder().setId(ByteString.copyFrom(Random.nextBytes(32))).build()
installer = getRandomString(), private val splitBlob =
sha256 = "eHx5jjmlvBkQNVuubQzYejay4Q_QICqD47trAF2oNHI", Snapshot.Blob.newBuilder().setId(ByteString.copyFrom(Random.nextBytes(32))).build()
signatures = listOf("AwIB"), private val apkBackupData = BackupData(listOf(apkChunkId), mapOf(apkChunkId to apkBlob))
splits = listOf(ApkSplit(splitName, Random.nextLong(), splitSha256)) private val splitBackupData = BackupData(listOf(splitChunkId), mapOf(splitChunkId to splitBlob))
) private val chunkMap = apkBackupData.chunkMap + splitBackupData.chunkMap
private val packageMetadataMap: PackageMetadataMap = hashMapOf(packageName to packageMetadata) private val baseSplit = Snapshot.Split.newBuilder().setName(BASE_SPLIT)
private val installerName = packageMetadata.installer .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 icon: Drawable = mockk()
private val appName = getRandomString() private val appName = getRandomString()
private val suffixName = getRandomString()
private val outputStream = ByteArrayOutputStream() private val outputStream = ByteArrayOutputStream()
private val splitOutputStream = ByteArrayOutputStream() private val splitOutputStream = ByteArrayOutputStream()
private val outputStreamGetter: suspend (name: String) -> OutputStream = { name ->
if (name == this.name) outputStream else splitOutputStream
}
init { init {
mockkStatic(PackageUtils::class) mockkStatic(PackageUtils::class)
every { backendManager.backend } returns backend every { backendManager.backend } returns backend
every { appBackupManager.snapshotCreator } returns snapshotCreator
} }
@Test @Test
@ -128,6 +166,7 @@ internal class ApkBackupRestoreTest : TransportTest() {
assertTrue(createNewFile()) assertTrue(createNewFile())
writeBytes(splitBytes) writeBytes(splitBytes)
}.absolutePath) }.absolutePath)
val capturedApkStream = slot<InputStream>()
// related to starting/stopping service // related to starting/stopping service
every { strictContext.packageName } returns "org.foo.bar" every { strictContext.packageName } returns "org.foo.bar"
@ -141,16 +180,19 @@ internal class ApkBackupRestoreTest : TransportTest() {
every { sigInfo.hasMultipleSigners() } returns false every { sigInfo.hasMultipleSigners() } returns false
every { sigInfo.signingCertificateHistory } returns sigs every { sigInfo.signingCertificateHistory } returns sigs
every { PackageUtils.computeSha256DigestBytes(signatureBytes) } returns signatureHash every { PackageUtils.computeSha256DigestBytes(signatureBytes) } returns signatureHash
every { every { snapshotManager.latestSnapshot } returns snapshot
metadataManager.getPackageMetadata(packageInfo.packageName)
} returns packageMetadata
every { pm.getInstallSourceInfo(packageInfo.packageName) } returns mockk(relaxed = true) every { pm.getInstallSourceInfo(packageInfo.packageName) } returns mockk(relaxed = true)
every { metadataManager.salt } returns salt coEvery { backupReceiver.readFromStream(capture(capturedApkStream)) } answers {
every { crypto.getNameForApk(salt, packageName) } returns name capturedApkStream.captured.copyTo(outputStream)
every { crypto.getNameForApk(salt, packageName, splitName) } returns suffixName } andThenAnswer {
every { backend.providerPackageName } returns storageProviderPackageName capturedApkStream.captured.copyTo(splitOutputStream)
}
coEvery { backupReceiver.finalize() } returns apkBackupData andThen splitBackupData
every {
snapshotCreator.onApkBackedUp(packageInfo, any<Snapshot.Apk>(), chunkMap)
} just Runs
apkBackup.backupApkIfNecessary(packageInfo, outputStreamGetter) apkBackup.backupApkIfNecessary(packageInfo)
assertArrayEquals(apkBytes, outputStream.toByteArray()) assertArrayEquals(apkBytes, outputStream.toByteArray())
assertArrayEquals(splitBytes, splitOutputStream.toByteArray()) assertArrayEquals(splitBytes, splitOutputStream.toByteArray())
@ -159,23 +201,23 @@ internal class ApkBackupRestoreTest : TransportTest() {
val splitInputStream = ByteArrayInputStream(splitBytes) val splitInputStream = ByteArrayInputStream(splitBytes)
val apkPath = slot<String>() val apkPath = slot<String>()
val cacheFiles = slot<List<File>>() val cacheFiles = slot<List<File>>()
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 { installRestriction.isAllowedToInstallApks() } returns true
every { backupStateManager.isAutoRestoreEnabled } returns false every { backupStateManager.isAutoRestoreEnabled } returns false
every { pm.getPackageInfo(packageName, any<Int>()) } throws NameNotFoundException() every { pm.getPackageInfo(packageName, any<Int>()) } throws NameNotFoundException()
every { strictContext.cacheDir } returns tmpFile every { strictContext.cacheDir } returns tmpFile
every { crypto.getNameForApk(salt, packageName, "") } returns name coEvery { loader.loadFiles(listOf(apkHandle)) } returns inputStream
coEvery { backend.load(LegacyAppBackupFile.Blob(token, name)) } returns inputStream
every { pm.getPackageArchiveInfo(capture(apkPath), any<Int>()) } returns packageInfo every { pm.getPackageArchiveInfo(capture(apkPath), any<Int>()) } returns packageInfo
every { applicationInfo.loadIcon(pm) } returns icon every { applicationInfo.loadIcon(pm) } returns icon
every { pm.getApplicationLabel(packageInfo.applicationInfo!!) } returns appName every { pm.getApplicationLabel(packageInfo.applicationInfo!!) } returns appName
every { every {
splitCompatChecker.isCompatible(metadata.deviceName, listOf(splitName)) splitCompatChecker.isCompatible(metadata.deviceName, listOf(splitName))
} returns true } returns true
every { crypto.getNameForApk(salt, packageName, splitName) } returns suffixName coEvery { loader.loadFiles(listOf(splitHandle)) } returns splitInputStream
coEvery {
backend.load(LegacyAppBackupFile.Blob(token, suffixName))
} returns splitInputStream
val resultMap = mapOf( val resultMap = mapOf(
packageName to ApkInstallResult( packageName to ApkInstallResult(
packageName, packageName,
@ -187,7 +229,11 @@ internal class ApkBackupRestoreTest : TransportTest() {
apkInstaller.install(capture(cacheFiles), packageName, installerName, any()) apkInstaller.install(capture(cacheFiles), packageName, installerName, any())
} returns InstallResult(resultMap) } 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 { apkRestore.installResult.test {
awaitItem() // initial empty state awaitItem() // initial empty state
apkRestore.restore(backup) apkRestore.restore(backup)

View file

@ -17,15 +17,19 @@ import android.content.pm.PackageManager.NameNotFoundException
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import app.cash.turbine.TurbineTestContext import app.cash.turbine.TurbineTestContext
import app.cash.turbine.test 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.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.getRandomBase64
import com.stevesoltys.seedvault.getRandomByteArray
import com.stevesoltys.seedvault.getRandomString 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.backend.LegacyStoragePlugin import com.stevesoltys.seedvault.proto.Snapshot
import com.stevesoltys.seedvault.backend.BackendManager
import com.stevesoltys.seedvault.restore.RestorableBackup 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
@ -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.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.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.Runs
import io.mockk.coEvery import io.mockk.coEvery
import io.mockk.every import io.mockk.every
@ -43,8 +50,9 @@ import io.mockk.mockkStatic
import io.mockk.verifyOrder import io.mockk.verifyOrder
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.calyxos.seedvault.core.backends.AppBackupFileType
import org.calyxos.seedvault.core.backends.Backend import org.calyxos.seedvault.core.backends.Backend
import org.calyxos.seedvault.core.backends.LegacyAppBackupFile import org.calyxos.seedvault.core.toHexString
import org.junit.jupiter.api.Assertions import org.junit.jupiter.api.Assertions
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertFalse
@ -68,6 +76,7 @@ internal class ApkRestoreTest : TransportTest() {
private val backupManager: IBackupManager = mockk() private val backupManager: IBackupManager = mockk()
private val backupStateManager: BackupStateManager = mockk() private val backupStateManager: BackupStateManager = mockk()
private val backendManager: BackendManager = mockk() private val backendManager: BackendManager = mockk()
private val loader: Loader = mockk()
private val backend: Backend = mockk() private val backend: Backend = mockk()
private val legacyStoragePlugin: LegacyStoragePlugin = mockk() private val legacyStoragePlugin: LegacyStoragePlugin = mockk()
private val splitCompatChecker: ApkSplitCompatibilityChecker = mockk() private val splitCompatChecker: ApkSplitCompatibilityChecker = mockk()
@ -79,6 +88,7 @@ internal class ApkRestoreTest : TransportTest() {
backupManager = backupManager, backupManager = backupManager,
backupStateManager = backupStateManager, backupStateManager = backupStateManager,
backendManager = backendManager, backendManager = backendManager,
loader = loader,
legacyStoragePlugin = legacyStoragePlugin, legacyStoragePlugin = legacyStoragePlugin,
crypto = crypto, crypto = crypto,
splitCompatChecker = splitCompatChecker, splitCompatChecker = splitCompatChecker,
@ -90,20 +100,39 @@ internal class ApkRestoreTest : TransportTest() {
private val deviceName = metadata.deviceName private val deviceName = metadata.deviceName
private val packageName = packageInfo.packageName 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 apkBytes = byteArrayOf(0x04, 0x05, 0x06)
private val apkInputStream = ByteArrayInputStream(apkBytes) private val apkInputStream = ByteArrayInputStream(apkBytes)
private val appName = getRandomString() 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(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 installerName = packageMetadata.installer
private val backup = RestorableBackup(metadata.copy(packageMetadataMap = packageMetadataMap)) private val backup = RestorableBackup(
private val suffixName = getRandomString() repoId = repoId,
snapshot = snapshot,
backupMetadata = metadata.copy(packageMetadataMap = packageMetadataMap),
)
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
@ -119,26 +148,6 @@ internal class ApkRestoreTest : TransportTest() {
every { strictContext.stopService(any()) } returns true 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 @Test
fun `test app without APK does not attempt install`(@TempDir tmpDir: Path) = runBlocking { fun `test app without APK does not attempt install`(@TempDir tmpDir: Path) = runBlocking {
// remove all APK info // remove all APK info
@ -201,9 +210,9 @@ internal class ApkRestoreTest : TransportTest() {
every { installRestriction.isAllowedToInstallApks() } returns true every { installRestriction.isAllowedToInstallApks() } returns true
every { backupStateManager.isAutoRestoreEnabled } returns false every { backupStateManager.isAutoRestoreEnabled } returns false
every { pm.getPackageInfo(packageName, any<Int>()) } throws NameNotFoundException()
every { strictContext.cacheDir } returns File(tmpDir.toString()) every { strictContext.cacheDir } returns File(tmpDir.toString())
every { crypto.getNameForApk(salt, packageName, "") } returns name coEvery { loader.loadFiles(listOf(apkBlobHandle)) } returns apkInputStream
coEvery { backend.load(handle) } returns apkInputStream
every { pm.getPackageArchiveInfo(any(), any<Int>()) } returns packageInfo every { pm.getPackageArchiveInfo(any(), any<Int>()) } returns packageInfo
every { backend.providerPackageName } returns storageProviderPackageName 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<Int>()) } throws NameNotFoundException()
every { strictContext.cacheDir } returns File(tmpDir.toString())
coEvery {
legacyStoragePlugin.getApkInputStream(token, packageName, "")
} returns apkInputStream
every { pm.getPackageArchiveInfo(any(), any<Int>()) } 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 @Test
fun `test app only installed not already installed`(@TempDir tmpDir: Path) = runBlocking { fun `test app only installed not already installed`(@TempDir tmpDir: Path) = runBlocking {
val packageInfo: PackageInfo = mockk() val packageInfo: PackageInfo = mockk()
mockkStatic("com.stevesoltys.seedvault.worker.ApkBackupKt") mockkStatic("com.stevesoltys.seedvault.restore.install.ApkRestoreKt")
every { installRestriction.isAllowedToInstallApks() } returns true every { installRestriction.isAllowedToInstallApks() } returns true
every { backupStateManager.isAutoRestoreEnabled } returns false every { backupStateManager.isAutoRestoreEnabled } returns false
every { backend.providerPackageName } returns storageProviderPackageName 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) = fun `test app still installed if older version is installed`(@TempDir tmpDir: Path) =
runBlocking { runBlocking {
val packageInfo: PackageInfo = mockk() val packageInfo: PackageInfo = mockk()
mockkStatic("com.stevesoltys.seedvault.worker.ApkBackupKt") mockkStatic("com.stevesoltys.seedvault.restore.install.ApkRestoreKt")
every { installRestriction.isAllowedToInstallApks() } returns true every { installRestriction.isAllowedToInstallApks() } returns true
every { backupStateManager.isAutoRestoreEnabled } returns false every { backupStateManager.isAutoRestoreEnabled } returns false
every { backend.providerPackageName } returns storageProviderPackageName every { backend.providerPackageName } returns storageProviderPackageName
@ -367,7 +340,7 @@ internal class ApkRestoreTest : TransportTest() {
@Test @Test
fun `test app fails if installed with different signer`(@TempDir tmpDir: Path) = runBlocking { fun `test app fails if installed with different signer`(@TempDir tmpDir: Path) = runBlocking {
val packageInfo: PackageInfo = mockk() val packageInfo: PackageInfo = mockk()
mockkStatic("com.stevesoltys.seedvault.worker.ApkBackupKt") mockkStatic("com.stevesoltys.seedvault.restore.install.ApkRestoreKt")
every { installRestriction.isAllowedToInstallApks() } returns true every { installRestriction.isAllowedToInstallApks() } returns true
every { backupStateManager.isAutoRestoreEnabled } returns false every { backupStateManager.isAutoRestoreEnabled } returns false
every { backend.providerPackageName } returns storageProviderPackageName 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<Int>()) } 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 @Test
fun `exception while getting split data causes FAILED state`(@TempDir tmpDir: Path) = fun `exception while getting split data causes FAILED state`(@TempDir tmpDir: Path) =
runBlocking { runBlocking {
// add one APK split to metadata // add one APK split to metadata
val splitName = getRandomString() val splitName = getRandomString()
val sha256 = getRandomBase64(23) 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( 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 { installRestriction.isAllowedToInstallApks() } returns true
every { backupStateManager.isAutoRestoreEnabled } returns false every { backupStateManager.isAutoRestoreEnabled } returns false
every { backend.providerPackageName } returns storageProviderPackageName
every { pm.getPackageInfo(packageName, any<Int>()) } throws NameNotFoundException() every { pm.getPackageInfo(packageName, any<Int>()) } throws NameNotFoundException()
// 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 { loader.loadFiles(listOf(blobHandle)) } throws IOException()
coEvery {
backend.load(LegacyAppBackupFile.Blob(token, suffixName))
} throws IOException()
every { backend.providerPackageName } returns storageProviderPackageName
apkRestore.installResult.test { apkRestore.installResult.test {
awaitItem() // initial empty state awaitItem() // initial empty state
@ -549,17 +498,31 @@ internal class ApkRestoreTest : TransportTest() {
// add one APK split to metadata // add one APK split to metadata
val split1Name = getRandomString() val split1Name = getRandomString()
val split2Name = getRandomString() val split2Name = getRandomString()
val split1sha256 = "A5BYxvLAy0ksUzsKTRTvd8wPeKvMztUofYShogEc-4E" val splitChunkId1 = Random.nextBytes(32).toHexString()
val split2sha256 = "ZqZ1cVH47lXbEncWx-Pc4L6AdLZOIO2lQuXB5GypxB4" val splitChunkId2 = Random.nextBytes(32).toHexString()
packageMetadataMap[packageName] = packageMetadataMap[packageName]!!.copy( val apkSplit1 = Snapshot.Split.newBuilder().setName(split1Name)
splits = listOf( .addAllChunkIds(listOf(fromHex(splitChunkId1))).build()
ApkSplit(split1Name, Random.nextLong(), split1sha256), val apkSplit2 = Snapshot.Split.newBuilder().setName(split2Name)
ApkSplit(split2Name, Random.nextLong(), split2sha256) .addAllChunkIds(listOf(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 { installRestriction.isAllowedToInstallApks() } returns true
every { backupStateManager.isAutoRestoreEnabled } returns false every { backupStateManager.isAutoRestoreEnabled } returns false
every { backend.providerPackageName } returns storageProviderPackageName
every { pm.getPackageInfo(packageName, any<Int>()) } throws NameNotFoundException() every { pm.getPackageInfo(packageName, any<Int>()) } throws NameNotFoundException()
// cache APK and get icon as well as app name // cache APK and get icon as well as app name
cacheBaseApkAndGetInfo(tmpDir) cacheBaseApkAndGetInfo(tmpDir)
@ -573,17 +536,10 @@ 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)
val suffixName1 = getRandomString() val splitHandle1 = AppBackupFileType.Blob(repoId, splitBlob1.id.hexFromProto())
val suffixName2 = getRandomString() val splitHandle2 = AppBackupFileType.Blob(repoId, splitBlob2.id.hexFromProto())
every { crypto.getNameForApk(salt, packageName, split1Name) } returns suffixName1 coEvery { loader.loadFiles(listOf(splitHandle1)) } returns split1InputStream
coEvery { coEvery { loader.loadFiles(listOf(splitHandle2)) } returns split2InputStream
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( val resultMap = mapOf(
packageName to ApkInstallResult( packageName to ApkInstallResult(
@ -709,8 +665,7 @@ internal class ApkRestoreTest : TransportTest() {
private fun cacheBaseApkAndGetInfo(tmpDir: Path) { private fun cacheBaseApkAndGetInfo(tmpDir: Path) {
every { strictContext.cacheDir } returns File(tmpDir.toString()) every { strictContext.cacheDir } returns File(tmpDir.toString())
every { crypto.getNameForApk(salt, packageName, "") } returns name coEvery { loader.loadFiles(listOf(apkBlobHandle)) } returns apkInputStream
coEvery { backend.load(handle) } returns apkInputStream
every { pm.getPackageArchiveInfo(any(), any<Int>()) } returns packageInfo every { pm.getPackageArchiveInfo(any(), any<Int>()) } returns packageInfo
every { applicationInfo.loadIcon(pm) } returns icon every { applicationInfo.loadIcon(pm) } returns icon
every { pm.getApplicationLabel(packageInfo.applicationInfo!!) } returns appName every { pm.getApplicationLabel(packageInfo.applicationInfo!!) } returns appName
@ -718,6 +673,14 @@ internal class ApkRestoreTest : TransportTest() {
private suspend fun TurbineTestContext<InstallResult>.assertQueuedFailFinished() { private suspend fun TurbineTestContext<InstallResult>.assertQueuedFailFinished() {
awaitQueuedItem() 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 -> awaitItem().also { failedItem ->
val result = failedItem[packageName] val result = failedItem[packageName]
assertEquals(FAILED, result.state) 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") return this.installResults[packageName] ?: Assertions.fail("$packageName not found")
} }

View file

@ -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.transport.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<Context>().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<Int>()) } 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<Int>()
)
} 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<Int>()) } 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<Int>()) } 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<Int>()) } 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<Int>()
)
} 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<Int>()
)
} 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<Int>()
)
} throws NameNotFoundException()
every { strictContext.cacheDir } returns File(tmpDir.toString())
coEvery {
legacyStoragePlugin.getApkInputStream(token, packageName, "")
} returns apkInputStream
every { pm.getPackageArchiveInfo(any(), any<Int>()) } 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<Int>()) } 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<Int>()) } 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<Int>()) } 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<Int>()
)
} 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<Int>()
)
} 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<Int>()
)
} 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<Int>()
)
} 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<Int>()
)
} 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<Int>()
)
} 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<Int>()) } returns packageInfo
every { applicationInfo.loadIcon(pm) } returns icon
every { pm.getApplicationLabel(packageInfo.applicationInfo!!) } returns appName
}
private suspend fun TurbineTestContext<InstallResult>.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<InstallResult>.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<InstallResult>.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<InstallResult>.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<InstallResult>.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
}
}

View file

@ -157,9 +157,7 @@ internal class CoordinatorIntegrationTest : TransportTest() {
appData2.copyInto(value2.captured) // write the app data into the passed ByteArray appData2.copyInto(value2.captured) // write the app data into the passed ByteArray
appData2.size appData2.size
} }
coEvery { coEvery { apkBackup.backupApkIfNecessary(packageInfo) } just Runs
apkBackup.backupApkIfNecessary(packageInfo, any())
} returns packageMetadata
coEvery { coEvery {
backend.save(LegacyAppBackupFile.Metadata(token)) backend.save(LegacyAppBackupFile.Metadata(token))
} returns metadataOutputStream } returns metadataOutputStream
@ -238,7 +236,7 @@ internal class CoordinatorIntegrationTest : TransportTest() {
appData.copyInto(value.captured) // write the app data into the passed ByteArray appData.copyInto(value.captured) // write the app data into the passed ByteArray
appData.size appData.size
} }
coEvery { apkBackup.backupApkIfNecessary(packageInfo, any()) } returns null coEvery { apkBackup.backupApkIfNecessary(packageInfo) } just Runs
every { settingsManager.getToken() } returns token every { settingsManager.getToken() } returns token
coEvery { coEvery {
backend.save(LegacyAppBackupFile.Metadata(token)) backend.save(LegacyAppBackupFile.Metadata(token))
@ -307,7 +305,7 @@ internal class CoordinatorIntegrationTest : TransportTest() {
} returns bOutputStream } returns bOutputStream
every { inputFactory.getInputStream(fileDescriptor) } returns bInputStream every { inputFactory.getInputStream(fileDescriptor) } returns bInputStream
every { settingsManager.isQuotaUnlimited() } returns false every { settingsManager.isQuotaUnlimited() } returns false
coEvery { apkBackup.backupApkIfNecessary(packageInfo, any()) } returns packageMetadata coEvery { apkBackup.backupApkIfNecessary(packageInfo) } just Runs
every { settingsManager.getToken() } returns token every { settingsManager.getToken() } returns token
every { metadataManager.salt } returns salt every { metadataManager.salt } returns salt
coEvery { coEvery {

View file

@ -14,13 +14,13 @@ import android.content.pm.PackageInfo
import android.net.Uri import android.net.Uri
import android.os.ParcelFileDescriptor import android.os.ParcelFileDescriptor
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
import com.stevesoltys.seedvault.backend.BackendManager
import com.stevesoltys.seedvault.coAssertThrows import com.stevesoltys.seedvault.coAssertThrows
import com.stevesoltys.seedvault.getRandomString import com.stevesoltys.seedvault.getRandomString
import com.stevesoltys.seedvault.metadata.BackupType import com.stevesoltys.seedvault.metadata.BackupType
import com.stevesoltys.seedvault.metadata.PackageMetadata import com.stevesoltys.seedvault.metadata.PackageMetadata
import com.stevesoltys.seedvault.metadata.PackageState.NO_DATA import com.stevesoltys.seedvault.metadata.PackageState.NO_DATA
import com.stevesoltys.seedvault.metadata.PackageState.QUOTA_EXCEEDED 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.ui.notification.BackupNotificationManager
import com.stevesoltys.seedvault.worker.ApkBackup import com.stevesoltys.seedvault.worker.ApkBackup
import io.mockk.Runs import io.mockk.Runs
@ -273,7 +273,7 @@ internal class BackupCoordinatorTest : BackupTest() {
coEvery { coEvery {
full.performFullBackup(packageInfo, fileDescriptor, 0, token, salt) full.performFullBackup(packageInfo, fileDescriptor, 0, token, salt)
} returns TRANSPORT_OK } returns TRANSPORT_OK
coEvery { apkBackup.backupApkIfNecessary(packageInfo, any()) } returns null coEvery { apkBackup.backupApkIfNecessary(packageInfo) } just Runs
assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, fileDescriptor, 0)) assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, fileDescriptor, 0))
} }
@ -382,7 +382,7 @@ internal class BackupCoordinatorTest : BackupTest() {
} }
private fun expectApkBackupAndMetadataWrite() { private fun expectApkBackupAndMetadataWrite() {
coEvery { apkBackup.backupApkIfNecessary(any(), any()) } returns packageMetadata coEvery { apkBackup.backupApkIfNecessary(packageInfo) } just Runs
every { settingsManager.getToken() } returns token every { settingsManager.getToken() } returns token
coEvery { backend.save(LegacyAppBackupFile.Metadata(token)) } returns metadataOutputStream coEvery { backend.save(LegacyAppBackupFile.Metadata(token)) } returns metadataOutputStream
every { metadataManager.onApkBackedUp(any(), packageMetadata) } just Runs every { metadataManager.onApkBackedUp(any(), packageMetadata) } just Runs

View file

@ -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_INSTALLED
import android.content.pm.ApplicationInfo.FLAG_STOPPED import android.content.pm.ApplicationInfo.FLAG_STOPPED
import android.content.pm.PackageInfo import android.content.pm.PackageInfo
import com.stevesoltys.seedvault.backend.BackendManager
import com.stevesoltys.seedvault.metadata.PackageMetadata import com.stevesoltys.seedvault.metadata.PackageMetadata
import com.stevesoltys.seedvault.metadata.PackageState.NOT_ALLOWED import com.stevesoltys.seedvault.metadata.PackageState.NOT_ALLOWED
import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR
import com.stevesoltys.seedvault.metadata.PackageState.WAS_STOPPED 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.TransportTest
import com.stevesoltys.seedvault.transport.backup.PackageService import com.stevesoltys.seedvault.transport.backup.PackageService
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
import io.mockk.Runs import io.mockk.Runs
import io.mockk.andThenJust
import io.mockk.coEvery import io.mockk.coEvery
import io.mockk.coVerify import io.mockk.coVerify
import io.mockk.every import io.mockk.every
@ -29,11 +28,7 @@ import io.mockk.verify
import io.mockk.verifyAll import io.mockk.verifyAll
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.calyxos.seedvault.core.backends.Backend import org.calyxos.seedvault.core.backends.Backend
import org.calyxos.seedvault.core.backends.LegacyAppBackupFile
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import java.io.ByteArrayOutputStream
import java.io.IOException
import java.io.OutputStream
internal class ApkBackupManagerTest : TransportTest() { internal class ApkBackupManagerTest : TransportTest() {
@ -49,13 +44,11 @@ internal class ApkBackupManagerTest : TransportTest() {
settingsManager = settingsManager, settingsManager = settingsManager,
metadataManager = metadataManager, metadataManager = metadataManager,
packageService = packageService, packageService = packageService,
apkBackup = apkBackup,
iconManager = iconManager, iconManager = iconManager,
backendManager = backendManager, apkBackup = apkBackup,
nm = nm, nm = nm,
) )
private val metadataOutputStream = mockk<OutputStream>()
private val packageMetadata: PackageMetadata = mockk() private val packageMetadata: PackageMetadata = mockk()
init { init {
@ -77,14 +70,12 @@ internal class ApkBackupManagerTest : TransportTest() {
every { metadataManager.onPackageDoesNotGetBackedUp(packageInfo, NOT_ALLOWED) } just Runs every { metadataManager.onPackageDoesNotGetBackedUp(packageInfo, NOT_ALLOWED) } just Runs
every { settingsManager.backupApks() } returns false every { settingsManager.backupApks() } returns false
expectFinalUpload()
every { nm.onApkBackupDone() } just Runs every { nm.onApkBackupDone() } just Runs
apkBackupManager.backup() apkBackupManager.backup()
verify { verify {
metadataManager.onPackageDoesNotGetBackedUp(packageInfo, NOT_ALLOWED) metadataManager.onPackageDoesNotGetBackedUp(packageInfo, NOT_ALLOWED)
metadataOutputStream.close()
} }
} }
@ -102,14 +93,12 @@ internal class ApkBackupManagerTest : TransportTest() {
every { metadataManager.onPackageDoesNotGetBackedUp(packageInfo, NOT_ALLOWED) } just Runs every { metadataManager.onPackageDoesNotGetBackedUp(packageInfo, NOT_ALLOWED) } just Runs
every { settingsManager.backupApks() } returns false every { settingsManager.backupApks() } returns false
expectFinalUpload()
every { nm.onApkBackupDone() } just Runs every { nm.onApkBackupDone() } just Runs
apkBackupManager.backup() apkBackupManager.backup()
verify { verify {
metadataManager.onPackageDoesNotGetBackedUp(packageInfo, NOT_ALLOWED) metadataManager.onPackageDoesNotGetBackedUp(packageInfo, NOT_ALLOWED)
metadataOutputStream.close()
} }
} }
@ -135,14 +124,12 @@ internal class ApkBackupManagerTest : TransportTest() {
every { metadataManager.onPackageDoesNotGetBackedUp(packageInfo, WAS_STOPPED) } just Runs every { metadataManager.onPackageDoesNotGetBackedUp(packageInfo, WAS_STOPPED) } just Runs
every { settingsManager.backupApks() } returns false every { settingsManager.backupApks() } returns false
expectFinalUpload()
every { nm.onApkBackupDone() } just Runs every { nm.onApkBackupDone() } just Runs
apkBackupManager.backup() apkBackupManager.backup()
verify { verify {
metadataManager.onPackageDoesNotGetBackedUp(packageInfo, WAS_STOPPED) metadataManager.onPackageDoesNotGetBackedUp(packageInfo, WAS_STOPPED)
metadataOutputStream.close()
} }
} }
@ -160,7 +147,6 @@ internal class ApkBackupManagerTest : TransportTest() {
every { packageMetadata.state } returns NOT_ALLOWED every { packageMetadata.state } returns NOT_ALLOWED
every { settingsManager.backupApks() } returns false every { settingsManager.backupApks() } returns false
expectFinalUpload()
every { nm.onApkBackupDone() } just Runs every { nm.onApkBackupDone() } just Runs
apkBackupManager.backup() apkBackupManager.backup()
@ -179,7 +165,6 @@ internal class ApkBackupManagerTest : TransportTest() {
expectUploadIcons() expectUploadIcons()
every { settingsManager.backupApks() } returns false every { settingsManager.backupApks() } returns false
expectFinalUpload()
every { nm.onApkBackupDone() } just Runs every { nm.onApkBackupDone() } just Runs
apkBackupManager.backup() apkBackupManager.backup()
@ -211,32 +196,22 @@ internal class ApkBackupManagerTest : TransportTest() {
nm.onApkBackup(notAllowedPackages[0].packageName, any(), 0, notAllowedPackages.size) nm.onApkBackup(notAllowedPackages[0].packageName, any(), 0, notAllowedPackages.size)
} just Runs } just Runs
// no backup needed // no backup needed
coEvery { coEvery { apkBackup.backupApkIfNecessary(notAllowedPackages[0]) } just Runs
apkBackup.backupApkIfNecessary(notAllowedPackages[0], any())
} returns null
// update notification for second package // update notification for second package
every { every {
nm.onApkBackup(notAllowedPackages[1].packageName, any(), 1, notAllowedPackages.size) nm.onApkBackup(notAllowedPackages[1].packageName, any(), 1, notAllowedPackages.size)
} just Runs } just Runs
// was backed up, get new packageMetadata // was backed up, get new packageMetadata
coEvery { coEvery { apkBackup.backupApkIfNecessary(notAllowedPackages[1]) } just Runs
apkBackup.backupApkIfNecessary(notAllowedPackages[1], any())
} returns packageMetadata
every { metadataManager.onApkBackedUp(notAllowedPackages[1], packageMetadata) } just Runs every { metadataManager.onApkBackedUp(notAllowedPackages[1], packageMetadata) } just Runs
expectFinalUpload()
every { nm.onApkBackupDone() } just Runs every { nm.onApkBackupDone() } just Runs
apkBackupManager.backup() apkBackupManager.backup()
coVerify { coVerify {
apkBackup.backupApkIfNecessary(notAllowedPackages[0], any()) apkBackup.backupApkIfNecessary(notAllowedPackages[0])
apkBackup.backupApkIfNecessary(notAllowedPackages[1], any()) apkBackup.backupApkIfNecessary(notAllowedPackages[1])
metadataOutputStream.close()
}
// metadata should only get uploaded once
verify(exactly = 1) {
metadataManager.uploadMetadata(metadataOutputStream)
} }
} }
@ -256,29 +231,17 @@ internal class ApkBackupManagerTest : TransportTest() {
every { settingsManager.backupApks() } returns false 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 every { nm.onApkBackupDone() } just Runs
apkBackupManager.backup() apkBackupManager.backup()
verify { verify {
metadataManager.onPackageDoesNotGetBackedUp(packageInfo, NOT_ALLOWED) metadataManager.onPackageDoesNotGetBackedUp(packageInfo, NOT_ALLOWED)
metadataOutputStream.close()
} }
} }
private suspend fun expectUploadIcons() { private suspend fun expectUploadIcons() {
every { settingsManager.getToken() } returns token coEvery { iconManager.uploadIcons() } just Runs
val stream = ByteArrayOutputStream()
coEvery { backend.save(LegacyAppBackupFile.IconsFile(token)) } returns stream
every { iconManager.uploadIcons(token, stream) } just Runs
} }
private fun expectAllAppsWillGetBackedUp() { private fun expectAllAppsWillGetBackedUp() {
@ -286,11 +249,4 @@ internal class ApkBackupManagerTest : TransportTest() {
every { packageService.notBackedUpPackages } returns emptyList() 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
}
} }

View file

@ -13,20 +13,25 @@ import android.content.pm.PackageInfo
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.content.pm.Signature import android.content.pm.Signature
import android.util.PackageUtils import android.util.PackageUtils
import com.google.protobuf.ByteString
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
import com.stevesoltys.seedvault.getRandomString import com.stevesoltys.seedvault.getRandomString
import com.stevesoltys.seedvault.metadata.ApkSplit import com.stevesoltys.seedvault.proto.Snapshot
import com.stevesoltys.seedvault.metadata.PackageMetadata import com.stevesoltys.seedvault.transport.SnapshotManager
import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR 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.BackupTest
import com.stevesoltys.seedvault.transport.backup.SnapshotCreator
import io.mockk.Runs
import io.mockk.coEvery import io.mockk.coEvery
import io.mockk.every import io.mockk.every
import io.mockk.just
import io.mockk.mockk import io.mockk.mockk
import io.mockk.mockkStatic import io.mockk.mockkStatic
import io.mockk.slot
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions.assertArrayEquals 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.assertThrows
import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
@ -34,34 +39,41 @@ import org.junit.jupiter.api.io.TempDir
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
import java.io.OutputStream import java.io.InputStream
import java.nio.file.Path import java.nio.file.Path
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 (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 signatureBytes = byteArrayOf(0x01, 0x02, 0x03)
private val signatureHash = byteArrayOf(0x03, 0x02, 0x01) private val signatureHash = byteArrayOf(0x03, 0x02, 0x01)
private val sigs = arrayOf(Signature(signatureBytes)) private val sigs = arrayOf(Signature(signatureBytes))
private val packageMetadata = PackageMetadata( private val apk = Snapshot.Apk.newBuilder()
time = Random.nextLong(), .setVersionCode(packageInfo.longVersionCode - 1)
version = packageInfo.longVersionCode - 1, .addSignatures(ByteString.copyFrom(signatureHash))
signatures = listOf("AwIB") .build()
) private val snapshot = Snapshot.newBuilder()
.setToken(token)
.putApps(packageInfo.packageName, Snapshot.App.newBuilder().setApk(apk).build())
.build()
init { init {
mockkStatic(PackageUtils::class) mockkStatic(PackageUtils::class)
every { appBackupManager.snapshotCreator } returns snapshotCreator
} }
@Test @Test
fun `does not back up @pm@`() = runBlocking { fun `does not back up @pm@`() = runBlocking {
val packageInfo = PackageInfo().apply { packageName = MAGIC_PACKAGE_MANAGER } val packageInfo = PackageInfo().apply { packageName = MAGIC_PACKAGE_MANAGER }
assertNull(apkBackup.backupApkIfNecessary(packageInfo, streamGetter)) apkBackup.backupApkIfNecessary(packageInfo)
} }
@Test @Test
@ -69,7 +81,7 @@ internal class ApkBackupTest : BackupTest() {
every { settingsManager.backupApks() } returns false every { settingsManager.backupApks() } returns false
every { settingsManager.isBackupEnabled(any()) } returns true every { settingsManager.isBackupEnabled(any()) } returns true
assertNull(apkBackup.backupApkIfNecessary(packageInfo, streamGetter)) apkBackup.backupApkIfNecessary(packageInfo)
} }
@Test @Test
@ -77,7 +89,7 @@ internal class ApkBackupTest : BackupTest() {
every { settingsManager.backupApks() } returns true every { settingsManager.backupApks() } returns true
every { settingsManager.isBackupEnabled(any()) } returns false every { settingsManager.isBackupEnabled(any()) } returns false
assertNull(apkBackup.backupApkIfNecessary(packageInfo, streamGetter)) apkBackup.backupApkIfNecessary(packageInfo)
} }
@Test @Test
@ -86,7 +98,7 @@ internal class ApkBackupTest : BackupTest() {
every { settingsManager.isBackupEnabled(any()) } returns true every { settingsManager.isBackupEnabled(any()) } returns true
every { settingsManager.backupApks() } returns true every { settingsManager.backupApks() } returns true
assertNull(apkBackup.backupApkIfNecessary(packageInfo, streamGetter)) apkBackup.backupApkIfNecessary(packageInfo)
} }
@Test @Test
@ -95,45 +107,50 @@ internal class ApkBackupTest : BackupTest() {
every { settingsManager.isBackupEnabled(any()) } returns true every { settingsManager.isBackupEnabled(any()) } returns true
every { settingsManager.backupApks() } returns true every { settingsManager.backupApks() } returns true
assertNull(apkBackup.backupApkIfNecessary(packageInfo, streamGetter)) apkBackup.backupApkIfNecessary(packageInfo)
} }
@Test @Test
fun `does not back up the same version`() = runBlocking { fun `does not back up the same version`() = runBlocking {
packageInfo.applicationInfo!!.flags = FLAG_UPDATED_SYSTEM_APP packageInfo.applicationInfo!!.flags = FLAG_UPDATED_SYSTEM_APP
val packageMetadata = packageMetadata.copy( val apk = apk.toBuilder().setVersionCode(packageInfo.longVersionCode).build()
version = packageInfo.longVersionCode val app = Snapshot.App.newBuilder().setApk(apk).build()
) expectChecks(snapshot.toBuilder().putApps(packageInfo.packageName, app).build())
expectChecks(packageMetadata) apkBackup.backupApkIfNecessary(packageInfo)
assertNull(apkBackup.backupApkIfNecessary(packageInfo, streamGetter))
} }
@Test @Test
fun `does back up the same version when signatures changes`() { fun `does back up the same version when signatures changes`() {
packageInfo.applicationInfo!!.sourceDir = "/tmp/doesNotExist" packageInfo.applicationInfo!!.sourceDir = "/tmp/doesNotExist"
val apk = apk.toBuilder()
expectChecks() .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) { assertThrows(IOException::class.java) {
runBlocking { runBlocking {
assertNull(apkBackup.backupApkIfNecessary(packageInfo, streamGetter)) apkBackup.backupApkIfNecessary(packageInfo)
} }
} }
Unit
} }
@Test @Test
fun `do not accept empty signature`() = runBlocking { fun `do not accept empty signature`() = runBlocking {
every { settingsManager.backupApks() } returns true every { settingsManager.backupApks() } returns true
every { settingsManager.isBackupEnabled(any()) } returns true every { settingsManager.isBackupEnabled(any()) } returns true
every { every { snapshotManager.latestSnapshot } returns snapshot
metadataManager.getPackageMetadata(packageInfo.packageName)
} returns packageMetadata
every { sigInfo.hasMultipleSigners() } returns false every { sigInfo.hasMultipleSigners() } returns false
every { sigInfo.signingCertificateHistory } returns emptyArray() every { sigInfo.signingCertificateHistory } returns emptyArray()
assertNull(apkBackup.backupApkIfNecessary(packageInfo, streamGetter)) apkBackup.backupApkIfNecessary(packageInfo)
} }
@Test @Test
@ -145,27 +162,24 @@ internal class ApkBackupTest : BackupTest() {
writeBytes(apkBytes) writeBytes(apkBytes)
}.absolutePath }.absolutePath
val apkOutputStream = ByteArrayOutputStream() val apkOutputStream = ByteArrayOutputStream()
val updatedMetadata = PackageMetadata( val installer = getRandomString()
time = packageMetadata.time, val capturedStream = slot<InputStream>()
state = UNKNOWN_ERROR,
version = packageInfo.longVersionCode,
installer = getRandomString(),
sha256 = "eHx5jjmlvBkQNVuubQzYejay4Q_QICqD47trAF2oNHI",
signatures = packageMetadata.signatures
)
expectChecks() expectChecks()
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, installer)
coEvery { backupReceiver.readFromStream(capture(capturedStream)) } answers {
capturedStream.captured.copyTo(apkOutputStream)
}
coEvery { backupReceiver.finalize() } returns BackupData(emptyList(), emptyMap())
every {
snapshotCreator.onApkBackedUp(packageInfo, match<Snapshot.Apk> {
it.installer == installer
}, emptyMap())
} just Runs
assertEquals( apkBackup.backupApkIfNecessary(packageInfo)
updatedMetadata,
apkBackup.backupApkIfNecessary(packageInfo, streamGetter)
)
assertArrayEquals(apkBytes, apkOutputStream.toByteArray()) assertArrayEquals(apkBytes, apkOutputStream.toByteArray())
} }
@ -184,9 +198,7 @@ internal class ApkBackupTest : BackupTest() {
packageInfo.splitNames = arrayOf(split1Name, split2Name) packageInfo.splitNames = arrayOf(split1Name, split2Name)
// create two split APKs // create two split APKs
val split1Bytes = byteArrayOf(0x07, 0x08, 0x09) val split1Bytes = byteArrayOf(0x07, 0x08, 0x09)
val split1Sha256 = "ZqZ1cVH47lXbEncWx-Pc4L6AdLZOIO2lQuXB5GypxB4"
val split2Bytes = byteArrayOf(0x01, 0x02, 0x03) val split2Bytes = byteArrayOf(0x01, 0x02, 0x03)
val split2Sha256 = "A5BYxvLAy0ksUzsKTRTvd8wPeKvMztUofYShogEc-4E"
packageInfo.applicationInfo!!.splitSourceDirs = arrayOf( packageInfo.applicationInfo!!.splitSourceDirs = arrayOf(
File(tmpFile, "test-$split1Name.apk").apply { File(tmpFile, "test-$split1Name.apk").apply {
assertTrue(createNewFile()) assertTrue(createNewFile())
@ -201,54 +213,39 @@ internal class ApkBackupTest : BackupTest() {
val apkOutputStream = ByteArrayOutputStream() val apkOutputStream = ByteArrayOutputStream()
val split1OutputStream = ByteArrayOutputStream() val split1OutputStream = ByteArrayOutputStream()
val split2OutputStream = ByteArrayOutputStream() val split2OutputStream = ByteArrayOutputStream()
// expected new metadata for package val capturedStream = slot<InputStream>()
val updatedMetadata = PackageMetadata( val installer = getRandomString()
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()
expectChecks() 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 { every {
pm.getInstallSourceInfo(packageInfo.packageName) 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)
} andThenAnswer {
capturedStream.captured.copyTo(split1OutputStream)
} andThenAnswer {
capturedStream.captured.copyTo(split2OutputStream)
}
coEvery { backupReceiver.finalize() } returns BackupData(emptyList(), emptyMap())
every {
snapshotCreator.onApkBackedUp(packageInfo, match<Snapshot.Apk> {
it.installer == installer &&
it.getSplits(1).name == split1Name &&
it.getSplits(2).name == split2Name
}, emptyMap())
} just Runs
assertEquals( apkBackup.backupApkIfNecessary(packageInfo)
updatedMetadata,
apkBackup.backupApkIfNecessary(packageInfo, streamGetter)
)
assertArrayEquals(apkBytes, apkOutputStream.toByteArray()) assertArrayEquals(apkBytes, apkOutputStream.toByteArray())
assertArrayEquals(split1Bytes, split1OutputStream.toByteArray()) assertArrayEquals(split1Bytes, split1OutputStream.toByteArray())
assertArrayEquals(split2Bytes, split2OutputStream.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.isBackupEnabled(any()) } returns true
every { settingsManager.backupApks() } returns true every { settingsManager.backupApks() } returns true
every { every { snapshotManager.latestSnapshot } returns snapshot
metadataManager.getPackageMetadata(packageInfo.packageName)
} returns packageMetadata
every { PackageUtils.computeSha256DigestBytes(signatureBytes) } returns signatureHash every { PackageUtils.computeSha256DigestBytes(signatureBytes) } returns signatureHash
every { sigInfo.hasMultipleSigners() } returns false every { sigInfo.hasMultipleSigners() } returns false
every { sigInfo.signingCertificateHistory } returns sigs every { sigInfo.signingCertificateHistory } returns sigs