Back up app APKs in new v2 format
We still support restoring in v1 format for some time.
This commit is contained in:
parent
e17c98857f
commit
897ae48b44
20 changed files with 1392 additions and 526 deletions
|
@ -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
|
||||
|
|
|
@ -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 =
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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()
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue