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

View file

@ -10,7 +10,7 @@ import com.stevesoltys.seedvault.crypto.TYPE_BACKUP_FULL
import com.stevesoltys.seedvault.crypto.TYPE_BACKUP_KV
import java.nio.ByteBuffer
internal const val VERSION: Byte = 1
internal const val VERSION: Byte = 2
internal const val MAX_PACKAGE_LENGTH_SIZE = 255
internal const val MAX_KEY_LENGTH_SIZE = MAX_PACKAGE_LENGTH_SIZE
internal const val MAX_VERSION_HEADER_SIZE =

View file

@ -8,8 +8,12 @@ package com.stevesoltys.seedvault.metadata
import android.content.pm.ApplicationInfo.FLAG_STOPPED
import android.os.Build
import com.stevesoltys.seedvault.crypto.TYPE_METADATA
import com.stevesoltys.seedvault.encodeBase64
import com.stevesoltys.seedvault.header.VERSION
import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR
import com.stevesoltys.seedvault.proto.Snapshot
import com.stevesoltys.seedvault.transport.backup.hexFromProto
import com.stevesoltys.seedvault.worker.BASE_SPLIT
import org.calyxos.backup.storage.crypto.StreamCrypto.toByteArray
import java.nio.ByteBuffer
@ -91,12 +95,55 @@ data class PackageMetadata(
internal val version: Long? = null,
internal val installer: String? = 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 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
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 size: Long?,
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
)

View file

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

View file

@ -5,12 +5,14 @@
package com.stevesoltys.seedvault.transport.backup
import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.transport.SnapshotManager
import io.github.oshai.kotlinlogging.KotlinLogging
import kotlinx.coroutines.delay
internal class AppBackupManager(
private val blobsCache: BlobsCache,
private val settingsManager: SettingsManager,
private val snapshotManager: SnapshotManager,
private val snapshotCreatorFactory: SnapshotCreatorFactory,
) {
@ -25,12 +27,15 @@ internal class AppBackupManager(
blobsCache.populateCache()
}
suspend fun afterBackupFinished() {
log.info { "After backup finished" }
suspend fun afterBackupFinished(success: Boolean) {
log.info { "After backup finished. Success: $success" }
blobsCache.clear()
val snapshot = snapshotCreator?.finalizeSnapshot() ?: error("Had no snapshotCreator")
keepTrying {
snapshotManager.saveSnapshot(snapshot)
if (success) {
val snapshot = snapshotCreator?.finalizeSnapshot() ?: error("Had no snapshotCreator")
keepTrying {
snapshotManager.saveSnapshot(snapshot)
}
settingsManager.token = snapshot.token
}
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.Blob
import com.stevesoltys.seedvault.settings.SettingsManager
import org.calyxos.seedvault.core.backends.AppBackupFileType
import org.calyxos.seedvault.core.toHexString
internal class SnapshotCreatorFactory(
@ -130,3 +131,8 @@ internal class SnapshotCreator(
fun Iterable<String>.forProto() = map { ByteString.fromHex(it) }
fun Iterable<ByteString>.hexFromProto() = map { it.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.metadata.MetadataManager
import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.transport.backup.AppBackupManager
import com.stevesoltys.seedvault.transport.backup.PackageService
import com.stevesoltys.seedvault.worker.BackupRequester
import kotlinx.coroutines.runBlocking
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
@ -36,6 +38,7 @@ internal class NotificationBackupObserver(
private val metadataManager: MetadataManager by inject()
private val packageService: PackageService by inject()
private val settingsManager: SettingsManager by inject()
private val appBackupManager: AppBackupManager by inject()
private var currentPackage: String? = null
private var numPackages: Int = 0
private var numPackagesToReport: Int = 0
@ -141,6 +144,12 @@ internal class NotificationBackupObserver(
Log.e(TAG, "Error getting number of all user packages: ", e)
requestedPackages
}
// TODO handle exceptions
runBlocking {
// TODO check if UI thread
Log.d("TAG", "Finalizing backup...")
appBackupManager.afterBackupFinished(success)
}
nm.onBackupFinished(success, numPackagesToReport, total, size)
}
}

View file

@ -5,7 +5,6 @@
package com.stevesoltys.seedvault.worker
import android.annotation.SuppressLint
import android.content.pm.PackageInfo
import android.content.pm.PackageManager
import android.content.pm.Signature
@ -13,94 +12,91 @@ import android.content.pm.SigningInfo
import android.util.Log
import android.util.PackageUtils.computeSha256DigestBytes
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
import com.stevesoltys.seedvault.crypto.Crypto
import com.stevesoltys.seedvault.encodeBase64
import com.stevesoltys.seedvault.metadata.ApkSplit
import com.stevesoltys.seedvault.metadata.MetadataManager
import com.stevesoltys.seedvault.metadata.PackageMetadata
import com.stevesoltys.seedvault.proto.Snapshot
import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.transport.SnapshotManager
import com.stevesoltys.seedvault.transport.backup.AppBackupManager
import com.stevesoltys.seedvault.transport.backup.BackupReceiver
import com.stevesoltys.seedvault.transport.backup.forProto
import com.stevesoltys.seedvault.transport.backup.hexFromProto
import com.stevesoltys.seedvault.transport.backup.isNotUpdatedSystemApp
import com.stevesoltys.seedvault.transport.backup.isTestOnly
import org.calyxos.seedvault.core.toHexString
import java.io.File
import java.io.FileInputStream
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.security.MessageDigest
private val TAG = ApkBackup::class.java.simpleName
internal const val BASE_SPLIT = "org.calyxos.seedvault.BASE_SPLIT"
internal class ApkBackup(
private val pm: PackageManager,
private val crypto: Crypto,
private val backupReceiver: BackupReceiver,
private val appBackupManager: AppBackupManager,
private val snapshotManager: SnapshotManager,
private val settingsManager: SettingsManager,
private val metadataManager: MetadataManager,
) {
private val snapshotCreator
get() = appBackupManager.snapshotCreator ?: error("No SnapshotCreator")
/**
* Checks if a new APK needs to get backed up,
* because the version code or the signatures have changed.
* Only if an APK needs a backup, an [OutputStream] is obtained from the given streamGetter
* and the APK binary written to it.
* Only if APKs need backup, they get chunked and uploaded.
*
* @return new [PackageMetadata] if an APK backup was made or null if no backup was made.
*/
@Throws(IOException::class)
@SuppressLint("NewApi") // can be removed when minSdk is set to 30
suspend fun backupApkIfNecessary(
packageInfo: PackageInfo,
streamGetter: suspend (name: String) -> OutputStream,
): PackageMetadata? {
suspend fun backupApkIfNecessary(packageInfo: PackageInfo) {
// do not back up @pm@
val packageName = packageInfo.packageName
if (packageName == MAGIC_PACKAGE_MANAGER) return null
if (packageName == MAGIC_PACKAGE_MANAGER) return
// do not back up when setting is not enabled
if (!settingsManager.backupApks()) return null
if (!settingsManager.backupApks()) return
// do not back up if package is blacklisted
if (!settingsManager.isBackupEnabled(packageName)) {
Log.d(TAG, "Package $packageName is blacklisted. Not backing it up.")
return null
return
}
// do not back up test-only apps as we can't re-install them anyway
// see: https://commonsware.com/blog/2017/10/31/android-studio-3p0-flag-test-only.html
if (packageInfo.isTestOnly()) {
Log.d(TAG, "Package $packageName is test-only app. Not backing it up.")
return null
return
}
// do not back up system apps that haven't been updated
if (packageInfo.isNotUpdatedSystemApp()) {
Log.d(TAG, "Package $packageName is vanilla system app. Not backing it up.")
return null
return
}
// TODO remove when adding support for packages with multiple signers
val signingInfo = packageInfo.signingInfo ?: return null
val signingInfo = packageInfo.signingInfo ?: return
if (signingInfo.hasMultipleSigners()) {
Log.e(TAG, "Package $packageName has multiple signers. Not backing it up.")
return null
return
}
// get signatures
val signatures = signingInfo.getSignatures()
val signatures = signingInfo.getSignaturesHex()
if (signatures.isEmpty()) {
Log.e(TAG, "Package $packageName has no signatures. Not backing it up.")
return null
return
}
// get cached metadata about package
val packageMetadata = metadataManager.getPackageMetadata(packageName)
?: PackageMetadata()
// get version codes
// get info from latest snapshot
val version = packageInfo.longVersionCode
val backedUpVersion = packageMetadata.version ?: 0L // no version will cause backup
val oldApk = snapshotManager.latestSnapshot?.appsMap?.get(packageName)?.apk
val backedUpVersion = oldApk?.versionCode ?: 0L // no version will cause backup
// do not backup if we have the version already and signatures did not change
if (version <= backedUpVersion && !signaturesChanged(packageMetadata, signatures)) {
if (version <= backedUpVersion && !signaturesChanged(oldApk, signatures)) {
Log.d(
TAG, "Package $packageName with version $version" +
" already has a backup ($backedUpVersion)" +
@ -108,40 +104,52 @@ internal class ApkBackup(
)
// We could also check if there are new feature module splits to back up,
// but we rely on the app themselves to re-download those, if needed after restore.
return null
return
}
// builder for Apk object
val apkBuilder = Snapshot.Apk.newBuilder()
.setVersionCode(version)
.setInstaller(pm.getInstallSourceInfo(packageName).installingPackageName)
.addAllSignatures(signatures.forProto())
// get an InputStream for the APK
val sourceDir = packageInfo.applicationInfo?.sourceDir ?: return null
val inputStream = getApkInputStream(sourceDir)
// copy the APK to the storage's output and calculate SHA-256 hash while at it
val name = crypto.getNameForApk(metadataManager.salt, packageName)
val sha256 = copyStreamsAndGetHash(inputStream, streamGetter(name))
val sourceDir = packageInfo.applicationInfo?.sourceDir ?: return
// upload the APK to the backend
getApkInputStream(sourceDir).use { inputStream ->
backupReceiver.readFromStream(inputStream)
}
val backupData = backupReceiver.finalize()
// store base split in builder
val baseSplit = Snapshot.Split.newBuilder()
.setName(BASE_SPLIT)
.addAllChunkIds(backupData.chunks.forProto())
apkBuilder
.addSplits(baseSplit)
val chunkMap = backupData.chunkMap.toMutableMap()
// back up splits if they exist
val splits =
if (packageInfo.splitNames == null) null else backupSplitApks(packageInfo, streamGetter)
val splits = if (packageInfo.splitNames == null) {
emptyList()
} else {
backupSplitApks(packageInfo, chunkMap)
}
apkBuilder.addAllSplits(splits)
val apk = apkBuilder.build()
snapshotCreator.onApkBackedUp(packageName, apk, chunkMap)
Log.d(TAG, "Backed up new APK of $packageName with version ${packageInfo.versionName}.")
// return updated metadata
return packageMetadata.copy(
version = version,
installer = pm.getInstallSourceInfo(packageName).installingPackageName,
splits = splits,
sha256 = sha256,
signatures = signatures
)
}
private fun signaturesChanged(
packageMetadata: PackageMetadata,
apk: Snapshot.Apk?,
signatures: List<String>,
): Boolean {
// no signatures in package metadata counts as them not having changed
if (packageMetadata.signatures == null) return false
// no signatures counts as them not having changed
if (apk == null || apk.signaturesList.isNullOrEmpty()) return false
val sigHex = apk.signaturesList.hexFromProto()
// TODO to support multiple signers check if lists differ
return packageMetadata.signatures.intersect(signatures).isEmpty()
return sigHex.intersect(signatures.toSet()).isEmpty()
}
@Throws(IOException::class)
@ -159,8 +167,8 @@ internal class ApkBackup(
@Throws(IOException::class)
private suspend fun backupSplitApks(
packageInfo: PackageInfo,
streamGetter: suspend (name: String) -> OutputStream,
): List<ApkSplit> {
chunkMap: MutableMap<String, Snapshot.Blob>,
): List<Snapshot.Split> {
check(packageInfo.splitNames != null)
// attention: though not documented, splitSourceDirs can be null
val splitSourceDirs = packageInfo.applicationInfo?.splitSourceDirs ?: emptyArray()
@ -169,97 +177,42 @@ internal class ApkBackup(
"splitNames is ${packageInfo.splitNames.toList()}, " +
"but splitSourceDirs is ${splitSourceDirs.toList()}"
}
val splits = ArrayList<ApkSplit>(packageInfo.splitNames.size)
val splits = ArrayList<Snapshot.Split>(packageInfo.splitNames.size)
for (i in packageInfo.splitNames.indices) {
val split = backupSplitApk(
packageName = packageInfo.packageName,
splitName = packageInfo.splitNames[i],
sourceDir = splitSourceDirs[i],
streamGetter = streamGetter
)
// copy the split APK to the storage stream
getApkInputStream(splitSourceDirs[i]).use { inputStream ->
backupReceiver.readFromStream(inputStream)
}
val backupData = backupReceiver.finalize()
val split = Snapshot.Split.newBuilder()
.setName(packageInfo.splitNames[i])
.addAllChunkIds(backupData.chunks.forProto())
.build()
splits.add(split)
chunkMap.putAll(backupData.chunkMap)
}
return splits
}
@Throws(IOException::class)
private suspend fun backupSplitApk(
packageName: String,
splitName: String,
sourceDir: String,
streamGetter: suspend (name: String) -> OutputStream,
): ApkSplit {
// Calculate sha256 hash first to determine file name suffix.
// We could also just use the split name as a suffix, but there is a theoretical risk
// that we exceed the maximum file name length, so we use the hash instead.
// The downside is that we need to read the file two times.
val messageDigest = MessageDigest.getInstance("SHA-256")
val size = getApkInputStream(sourceDir).use { inputStream ->
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
var readCount = 0
var bytes = inputStream.read(buffer)
while (bytes >= 0) {
readCount += bytes
messageDigest.update(buffer, 0, bytes)
bytes = inputStream.read(buffer)
}
readCount
}
val sha256 = messageDigest.digest().encodeBase64()
val name = crypto.getNameForApk(metadataManager.salt, packageName, splitName)
// copy the split APK to the storage stream
getApkInputStream(sourceDir).use { inputStream ->
streamGetter(name).use { outputStream ->
inputStream.copyTo(outputStream)
}
}
return ApkSplit(splitName, size.toLong(), sha256)
}
}
/**
* Copy the APK from the given [InputStream] to the given [OutputStream]
* and calculate the SHA-256 hash while at it.
*
* Both streams will be closed when the method returns.
*
* @return the APK's SHA-256 hash in Base64 format.
* Returns a list of lowercase hex encoded SHA-256 signature hashes.
*/
@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> {
fun SigningInfo?.getSignaturesHex(): List<String> {
return if (this == null) {
emptyList()
} else if (hasMultipleSigners()) {
apkContentsSigners.map { signature ->
hashSignature(signature).encodeBase64()
hashSignature(signature).toHexString()
}
} else {
signingCertificateHistory.map { signature ->
hashSignature(signature).encodeBase64()
hashSignature(signature).toHexString()
}
}
}
private fun hashSignature(signature: Signature): ByteArray {
internal fun hashSignature(signature: Signature): ByteArray {
return computeSha256DigestBytes(signature.toByteArray()) ?: throw AssertionError()
}

View file

@ -8,19 +8,15 @@ package com.stevesoltys.seedvault.worker
import android.content.Context
import android.content.pm.PackageInfo
import android.util.Log
import com.stevesoltys.seedvault.backend.isOutOfSpace
import com.stevesoltys.seedvault.metadata.MetadataManager
import com.stevesoltys.seedvault.metadata.PackageState.NOT_ALLOWED
import com.stevesoltys.seedvault.metadata.PackageState.WAS_STOPPED
import com.stevesoltys.seedvault.backend.BackendManager
import com.stevesoltys.seedvault.backend.getMetadataOutputStream
import com.stevesoltys.seedvault.backend.isOutOfSpace
import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.transport.backup.PackageService
import com.stevesoltys.seedvault.transport.backup.isStopped
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
import com.stevesoltys.seedvault.ui.notification.getAppName
import kotlinx.coroutines.delay
import org.calyxos.seedvault.core.backends.LegacyAppBackupFile
import java.io.IOException
internal class ApkBackupManager(
@ -30,7 +26,6 @@ internal class ApkBackupManager(
private val packageService: PackageService,
private val iconManager: IconManager,
private val apkBackup: ApkBackup,
private val backendManager: BackendManager,
private val nm: BackupNotificationManager,
) {
@ -51,14 +46,6 @@ internal class ApkBackupManager(
backUpApks()
}
} finally {
keepTrying {
// upload all local changes only at the end,
// so we don't have to re-upload the metadata
val token = settingsManager.getToken() ?: error("no token")
backendManager.backend.getMetadataOutputStream(token).use { outputStream ->
metadataManager.uploadMetadata(outputStream)
}
}
nm.onApkBackupDone()
}
}
@ -107,37 +94,15 @@ internal class ApkBackupManager(
}
/**
* Backs up an APK for the given [PackageInfo].
*
* @return true if a backup was performed and false if no backup was needed or it failed.
* Backs up one (or more split) APK(s) for the given [PackageInfo], if needed.
*/
private suspend fun backUpApk(packageInfo: PackageInfo): Boolean {
private suspend fun backUpApk(packageInfo: PackageInfo) {
val packageName = packageInfo.packageName
return try {
apkBackup.backupApkIfNecessary(packageInfo) { name ->
val token = settingsManager.getToken() ?: throw IOException("no current token")
backendManager.backend.save(LegacyAppBackupFile.Blob(token, name))
}?.let { packageMetadata ->
metadataManager.onApkBackedUp(packageInfo, packageMetadata)
true
} ?: false
try {
apkBackup.backupApkIfNecessary(packageInfo)
} catch (e: IOException) {
Log.e(TAG, "Error while writing APK for $packageName", e)
if (e.isOutOfSpace()) nm.onInsufficientSpaceError()
false
}
}
private suspend fun keepTrying(n: Int = 3, block: suspend () -> Unit) {
for (i in 1..n) {
try {
block()
return
} catch (e: Exception) {
if (i == n) throw e
Log.e(TAG, "Error (#$i), we'll keep trying", e)
delay(1000)
}
}
}
}

View file

@ -24,6 +24,7 @@ import androidx.work.WorkManager
import androidx.work.WorkerParameters
import com.stevesoltys.seedvault.backend.BackendManager
import com.stevesoltys.seedvault.settings.SettingsManager
import com.stevesoltys.seedvault.transport.backup.AppBackupManager
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
import com.stevesoltys.seedvault.ui.notification.NOTIFICATION_ID_OBSERVER
import org.koin.core.component.KoinComponent
@ -101,6 +102,7 @@ class AppBackupWorker(
private val backupRequester: BackupRequester by inject()
private val settingsManager: SettingsManager by inject()
private val apkBackupManager: ApkBackupManager by inject()
private val appBackupManager: AppBackupManager by inject()
private val backendManager: BackendManager by inject()
private val nm: BackupNotificationManager by inject()
@ -137,6 +139,15 @@ class AppBackupWorker(
private suspend fun doBackup(): Result {
var result: Result = Result.success()
if (!isStopped) {
Log.i(TAG, "Initializing backup info...")
try {
appBackupManager.beforeBackup()
} catch (e: Exception) {
Log.e(TAG, "Error populating blobs cache: ", e)
return Result.retry()
}
}
try {
Log.i(TAG, "Starting APK backup... (stopped: $isStopped)")
if (!isStopped) apkBackupManager.backup()

View file

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

View file

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

View file

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

View file

@ -17,15 +17,19 @@ import android.content.pm.PackageManager.NameNotFoundException
import android.graphics.drawable.Drawable
import app.cash.turbine.TurbineTestContext
import app.cash.turbine.test
import com.google.protobuf.ByteString
import com.google.protobuf.ByteString.copyFromUtf8
import com.google.protobuf.ByteString.fromHex
import com.stevesoltys.seedvault.BackupStateManager
import com.stevesoltys.seedvault.backend.BackendManager
import com.stevesoltys.seedvault.backend.LegacyStoragePlugin
import com.stevesoltys.seedvault.decodeBase64
import com.stevesoltys.seedvault.getRandomBase64
import com.stevesoltys.seedvault.getRandomByteArray
import com.stevesoltys.seedvault.getRandomString
import com.stevesoltys.seedvault.metadata.ApkSplit
import com.stevesoltys.seedvault.metadata.PackageMetadata
import com.stevesoltys.seedvault.metadata.PackageMetadataMap
import com.stevesoltys.seedvault.backend.LegacyStoragePlugin
import com.stevesoltys.seedvault.backend.BackendManager
import com.stevesoltys.seedvault.proto.Snapshot
import com.stevesoltys.seedvault.restore.RestorableBackup
import com.stevesoltys.seedvault.restore.install.ApkInstallState.FAILED
import com.stevesoltys.seedvault.restore.install.ApkInstallState.FAILED_SYSTEM_APP
@ -33,7 +37,10 @@ import com.stevesoltys.seedvault.restore.install.ApkInstallState.IN_PROGRESS
import com.stevesoltys.seedvault.restore.install.ApkInstallState.QUEUED
import com.stevesoltys.seedvault.restore.install.ApkInstallState.SUCCEEDED
import com.stevesoltys.seedvault.transport.TransportTest
import com.stevesoltys.seedvault.worker.getSignatures
import com.stevesoltys.seedvault.transport.backup.BackupData
import com.stevesoltys.seedvault.transport.backup.hexFromProto
import com.stevesoltys.seedvault.transport.restore.Loader
import com.stevesoltys.seedvault.worker.BASE_SPLIT
import io.mockk.Runs
import io.mockk.coEvery
import io.mockk.every
@ -43,8 +50,9 @@ import io.mockk.mockkStatic
import io.mockk.verifyOrder
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.runBlocking
import org.calyxos.seedvault.core.backends.AppBackupFileType
import org.calyxos.seedvault.core.backends.Backend
import org.calyxos.seedvault.core.backends.LegacyAppBackupFile
import org.calyxos.seedvault.core.toHexString
import org.junit.jupiter.api.Assertions
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
@ -68,6 +76,7 @@ internal class ApkRestoreTest : TransportTest() {
private val backupManager: IBackupManager = mockk()
private val backupStateManager: BackupStateManager = mockk()
private val backendManager: BackendManager = mockk()
private val loader: Loader = mockk()
private val backend: Backend = mockk()
private val legacyStoragePlugin: LegacyStoragePlugin = mockk()
private val splitCompatChecker: ApkSplitCompatibilityChecker = mockk()
@ -79,6 +88,7 @@ internal class ApkRestoreTest : TransportTest() {
backupManager = backupManager,
backupStateManager = backupStateManager,
backendManager = backendManager,
loader = loader,
legacyStoragePlugin = legacyStoragePlugin,
crypto = crypto,
splitCompatChecker = splitCompatChecker,
@ -90,20 +100,39 @@ internal class ApkRestoreTest : TransportTest() {
private val deviceName = metadata.deviceName
private val packageName = packageInfo.packageName
private val packageMetadata = PackageMetadata(
time = Random.nextLong(),
version = packageInfo.longVersionCode - 1,
installer = getRandomString(),
sha256 = "eHx5jjmlvBkQNVuubQzYejay4Q_QICqD47trAF2oNHI",
signatures = listOf("AwIB")
)
private val packageMetadataMap: PackageMetadataMap = hashMapOf(packageName to packageMetadata)
private val apkBytes = byteArrayOf(0x04, 0x05, 0x06)
private val apkInputStream = ByteArrayInputStream(apkBytes)
private val appName = getRandomString()
private val repoId = Random.nextBytes(32).toHexString()
private val apkChunkId = Random.nextBytes(32).toHexString()
private val apkBlob =
Snapshot.Blob.newBuilder().setId(ByteString.copyFrom(Random.nextBytes(32))).build()
private val apkBlobHandle = AppBackupFileType.Blob(repoId, apkBlob.id.hexFromProto())
private val apkBackupData = BackupData(listOf(apkChunkId), mapOf(apkChunkId to apkBlob))
private val baseSplit = Snapshot.Split.newBuilder().setName(BASE_SPLIT)
.addAllChunkIds(listOf(fromHex(apkChunkId))).build()
private val apk = Snapshot.Apk.newBuilder()
.setVersionCode(packageInfo.longVersionCode - 1)
.setInstaller(getRandomString())
.addAllSignatures(mutableListOf(copyFromUtf8("AwIB".decodeBase64())))
.addSplits(baseSplit)
.build()
private val app = Snapshot.App.newBuilder()
.setApk(apk)
.build()
private val snapshot = Snapshot.newBuilder()
.setToken(token)
.putApps(packageName, app)
.putAllBlobs(apkBackupData.chunkMap)
.build()
private val packageMetadata = PackageMetadata.fromSnapshot(app)
private val packageMetadataMap: PackageMetadataMap = hashMapOf(packageName to packageMetadata)
private val installerName = packageMetadata.installer
private val backup = RestorableBackup(metadata.copy(packageMetadataMap = packageMetadataMap))
private val suffixName = getRandomString()
private val backup = RestorableBackup(
repoId = repoId,
snapshot = snapshot,
backupMetadata = metadata.copy(packageMetadataMap = packageMetadataMap),
)
init {
// as we don't do strict signature checking, we can use a relaxed mock
@ -119,26 +148,6 @@ internal class ApkRestoreTest : TransportTest() {
every { strictContext.stopService(any()) } returns true
}
@Test
fun `signature mismatch causes FAILED status`(@TempDir tmpDir: Path) = runBlocking {
// change SHA256 signature to random
val packageMetadata = packageMetadata.copy(sha256 = getRandomString())
val backup = swapPackages(hashMapOf(packageName to packageMetadata))
every { installRestriction.isAllowedToInstallApks() } returns true
every { backupStateManager.isAutoRestoreEnabled } returns false
every { strictContext.cacheDir } returns File(tmpDir.toString())
every { crypto.getNameForApk(salt, packageName, "") } returns name
coEvery { backend.load(handle) } returns apkInputStream
every { backend.providerPackageName } returns storageProviderPackageName
apkRestore.installResult.test {
awaitItem() // initial empty state
apkRestore.restore(backup)
assertQueuedFailFinished()
}
}
@Test
fun `test app without APK does not attempt install`(@TempDir tmpDir: Path) = runBlocking {
// remove all APK info
@ -201,9 +210,9 @@ internal class ApkRestoreTest : TransportTest() {
every { installRestriction.isAllowedToInstallApks() } returns true
every { backupStateManager.isAutoRestoreEnabled } returns false
every { pm.getPackageInfo(packageName, any<Int>()) } throws NameNotFoundException()
every { strictContext.cacheDir } returns File(tmpDir.toString())
every { crypto.getNameForApk(salt, packageName, "") } returns name
coEvery { backend.load(handle) } returns apkInputStream
coEvery { loader.loadFiles(listOf(apkBlobHandle)) } returns apkInputStream
every { pm.getPackageArchiveInfo(any(), any<Int>()) } returns packageInfo
every { backend.providerPackageName } returns storageProviderPackageName
@ -259,46 +268,10 @@ internal class ApkRestoreTest : TransportTest() {
}
}
@Test
fun `v0 test successful run`(@TempDir tmpDir: Path) = runBlocking {
// This is a legacy backup with version 0
val backup = backup.copy(backupMetadata = backup.backupMetadata.copy(version = 0))
// Install will be successful
val packagesMap = mapOf(
packageName to ApkInstallResult(
packageName,
state = SUCCEEDED,
metadata = PackageMetadata(),
)
)
val installResult = InstallResult(packagesMap)
every { installRestriction.isAllowedToInstallApks() } returns true
every { backupStateManager.isAutoRestoreEnabled } returns false
every { pm.getPackageInfo(packageName, any<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.worker.ApkBackupKt")
mockkStatic("com.stevesoltys.seedvault.restore.install.ApkRestoreKt")
every { installRestriction.isAllowedToInstallApks() } returns true
every { backupStateManager.isAutoRestoreEnabled } returns false
every { backend.providerPackageName } returns storageProviderPackageName
@ -327,7 +300,7 @@ internal class ApkRestoreTest : TransportTest() {
fun `test app still installed if older version is installed`(@TempDir tmpDir: Path) =
runBlocking {
val packageInfo: PackageInfo = mockk()
mockkStatic("com.stevesoltys.seedvault.worker.ApkBackupKt")
mockkStatic("com.stevesoltys.seedvault.restore.install.ApkRestoreKt")
every { installRestriction.isAllowedToInstallApks() } returns true
every { backupStateManager.isAutoRestoreEnabled } returns false
every { backend.providerPackageName } returns storageProviderPackageName
@ -367,7 +340,7 @@ internal class ApkRestoreTest : TransportTest() {
@Test
fun `test app fails if installed with different signer`(@TempDir tmpDir: Path) = runBlocking {
val packageInfo: PackageInfo = mockk()
mockkStatic("com.stevesoltys.seedvault.worker.ApkBackupKt")
mockkStatic("com.stevesoltys.seedvault.restore.install.ApkRestoreKt")
every { installRestriction.isAllowedToInstallApks() } returns true
every { backupStateManager.isAutoRestoreEnabled } returns false
every { backend.providerPackageName } returns storageProviderPackageName
@ -486,56 +459,32 @@ internal class ApkRestoreTest : TransportTest() {
}
}
@Test
fun `split signature mismatch causes FAILED state`(@TempDir tmpDir: Path) = runBlocking {
// add one APK split to metadata
val splitName = getRandomString()
packageMetadataMap[packageName] = packageMetadataMap[packageName]!!.copy(
splits = listOf(ApkSplit(splitName, Random.nextLong(), getRandomBase64(23)))
)
every { installRestriction.isAllowedToInstallApks() } returns true
every { backupStateManager.isAutoRestoreEnabled } returns false
every { pm.getPackageInfo(packageName, any<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)
val splitChunkId = Random.nextBytes(32).toHexString()
val splitBlobId = Random.nextBytes(32).toHexString()
val split = ApkSplit(splitName, Random.nextLong(), sha256, listOf(splitChunkId))
packageMetadataMap[packageName] = packageMetadataMap[packageName]!!.copy(
splits = listOf(ApkSplit(splitName, Random.nextLong(), sha256))
splits = listOf(split)
)
val blobHandle = AppBackupFileType.Blob(repoId, splitBlobId)
val splitBlob = Snapshot.Blob.newBuilder().setId(fromHex(splitBlobId)).build()
val snapshot = snapshot.toBuilder().putBlobs(splitChunkId, splitBlob).build()
val backup = backup.copy(snapshot = snapshot)
every { installRestriction.isAllowedToInstallApks() } returns true
every { backupStateManager.isAutoRestoreEnabled } returns false
every { backend.providerPackageName } returns storageProviderPackageName
every { pm.getPackageInfo(packageName, any<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
coEvery { loader.loadFiles(listOf(blobHandle)) } throws IOException()
apkRestore.installResult.test {
awaitItem() // initial empty state
@ -549,17 +498,31 @@ internal class ApkRestoreTest : TransportTest() {
// add one APK split to metadata
val split1Name = getRandomString()
val split2Name = getRandomString()
val split1sha256 = "A5BYxvLAy0ksUzsKTRTvd8wPeKvMztUofYShogEc-4E"
val split2sha256 = "ZqZ1cVH47lXbEncWx-Pc4L6AdLZOIO2lQuXB5GypxB4"
packageMetadataMap[packageName] = packageMetadataMap[packageName]!!.copy(
splits = listOf(
ApkSplit(split1Name, Random.nextLong(), split1sha256),
ApkSplit(split2Name, Random.nextLong(), split2sha256)
)
)
val splitChunkId1 = Random.nextBytes(32).toHexString()
val splitChunkId2 = Random.nextBytes(32).toHexString()
val apkSplit1 = Snapshot.Split.newBuilder().setName(split1Name)
.addAllChunkIds(listOf(fromHex(splitChunkId1))).build()
val apkSplit2 = Snapshot.Split.newBuilder().setName(split2Name)
.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 { backupStateManager.isAutoRestoreEnabled } returns false
every { backend.providerPackageName } returns storageProviderPackageName
every { pm.getPackageInfo(packageName, any<Int>()) } throws NameNotFoundException()
// cache APK and get icon as well as app name
cacheBaseApkAndGetInfo(tmpDir)
@ -573,17 +536,10 @@ internal class ApkRestoreTest : TransportTest() {
val split2Bytes = byteArrayOf(0x07, 0x08, 0x09)
val split1InputStream = ByteArrayInputStream(split1Bytes)
val split2InputStream = ByteArrayInputStream(split2Bytes)
val suffixName1 = getRandomString()
val suffixName2 = getRandomString()
every { crypto.getNameForApk(salt, packageName, split1Name) } returns suffixName1
coEvery {
backend.load(LegacyAppBackupFile.Blob(token, suffixName1))
} returns split1InputStream
every { crypto.getNameForApk(salt, packageName, split2Name) } returns suffixName2
coEvery {
backend.load(LegacyAppBackupFile.Blob(token, suffixName2))
} returns split2InputStream
every { backend.providerPackageName } returns storageProviderPackageName
val splitHandle1 = AppBackupFileType.Blob(repoId, splitBlob1.id.hexFromProto())
val splitHandle2 = AppBackupFileType.Blob(repoId, splitBlob2.id.hexFromProto())
coEvery { loader.loadFiles(listOf(splitHandle1)) } returns split1InputStream
coEvery { loader.loadFiles(listOf(splitHandle2)) } returns split2InputStream
val resultMap = mapOf(
packageName to ApkInstallResult(
@ -709,8 +665,7 @@ internal class ApkRestoreTest : TransportTest() {
private fun cacheBaseApkAndGetInfo(tmpDir: Path) {
every { strictContext.cacheDir } returns File(tmpDir.toString())
every { crypto.getNameForApk(salt, packageName, "") } returns name
coEvery { backend.load(handle) } returns apkInputStream
coEvery { loader.loadFiles(listOf(apkBlobHandle)) } returns apkInputStream
every { pm.getPackageArchiveInfo(any(), any<Int>()) } returns packageInfo
every { applicationInfo.loadIcon(pm) } returns icon
every { pm.getApplicationLabel(packageInfo.applicationInfo!!) } returns appName
@ -718,6 +673,14 @@ internal class ApkRestoreTest : TransportTest() {
private suspend fun TurbineTestContext<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)
@ -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")
}

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

View file

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

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

View file

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