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.Backend
|
||||||
import org.calyxos.seedvault.core.backends.BackendTest
|
import org.calyxos.seedvault.core.backends.BackendTest
|
||||||
import org.calyxos.seedvault.core.backends.saf.SafBackend
|
import org.calyxos.seedvault.core.backends.saf.SafBackend
|
||||||
import org.calyxos.seedvault.core.backends.saf.SafProperties
|
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
import org.koin.core.component.KoinComponent
|
import org.koin.core.component.KoinComponent
|
||||||
|
@ -25,14 +24,7 @@ class SafBackendTest : BackendTest(), KoinComponent {
|
||||||
|
|
||||||
private val context = InstrumentationRegistry.getInstrumentation().targetContext
|
private val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||||
private val settingsManager by inject<SettingsManager>()
|
private val settingsManager by inject<SettingsManager>()
|
||||||
private val safStorage = settingsManager.getSafProperties() ?: error("No SAF storage")
|
private val safProperties = settingsManager.getSafProperties() ?: error("No SAF storage")
|
||||||
private val safProperties = SafProperties(
|
|
||||||
config = safStorage.config,
|
|
||||||
name = safStorage.name,
|
|
||||||
isUsb = safStorage.isUsb,
|
|
||||||
requiresNetwork = safStorage.requiresNetwork,
|
|
||||||
rootId = safStorage.rootId,
|
|
||||||
)
|
|
||||||
override val backend: Backend = SafBackend(context, safProperties, ".SeedvaultTest")
|
override val backend: Backend = SafBackend(context, safProperties, ".SeedvaultTest")
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
|
@ -10,7 +10,7 @@ import com.stevesoltys.seedvault.crypto.TYPE_BACKUP_FULL
|
||||||
import com.stevesoltys.seedvault.crypto.TYPE_BACKUP_KV
|
import com.stevesoltys.seedvault.crypto.TYPE_BACKUP_KV
|
||||||
import java.nio.ByteBuffer
|
import java.nio.ByteBuffer
|
||||||
|
|
||||||
internal const val VERSION: Byte = 1
|
internal const val VERSION: Byte = 2
|
||||||
internal const val MAX_PACKAGE_LENGTH_SIZE = 255
|
internal const val MAX_PACKAGE_LENGTH_SIZE = 255
|
||||||
internal const val MAX_KEY_LENGTH_SIZE = MAX_PACKAGE_LENGTH_SIZE
|
internal const val MAX_KEY_LENGTH_SIZE = MAX_PACKAGE_LENGTH_SIZE
|
||||||
internal const val MAX_VERSION_HEADER_SIZE =
|
internal const val MAX_VERSION_HEADER_SIZE =
|
||||||
|
|
|
@ -8,8 +8,12 @@ package com.stevesoltys.seedvault.metadata
|
||||||
import android.content.pm.ApplicationInfo.FLAG_STOPPED
|
import android.content.pm.ApplicationInfo.FLAG_STOPPED
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import com.stevesoltys.seedvault.crypto.TYPE_METADATA
|
import com.stevesoltys.seedvault.crypto.TYPE_METADATA
|
||||||
|
import com.stevesoltys.seedvault.encodeBase64
|
||||||
import com.stevesoltys.seedvault.header.VERSION
|
import com.stevesoltys.seedvault.header.VERSION
|
||||||
import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR
|
import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR
|
||||||
|
import com.stevesoltys.seedvault.proto.Snapshot
|
||||||
|
import com.stevesoltys.seedvault.transport.backup.hexFromProto
|
||||||
|
import com.stevesoltys.seedvault.worker.BASE_SPLIT
|
||||||
import org.calyxos.backup.storage.crypto.StreamCrypto.toByteArray
|
import org.calyxos.backup.storage.crypto.StreamCrypto.toByteArray
|
||||||
import java.nio.ByteBuffer
|
import java.nio.ByteBuffer
|
||||||
|
|
||||||
|
@ -91,12 +95,55 @@ data class PackageMetadata(
|
||||||
internal val version: Long? = null,
|
internal val version: Long? = null,
|
||||||
internal val installer: String? = null,
|
internal val installer: String? = null,
|
||||||
internal val splits: List<ApkSplit>? = null,
|
internal val splits: List<ApkSplit>? = null,
|
||||||
|
internal val baseApkChunkIds: List<String>? = null, // used for v2
|
||||||
|
internal val chunkIds: List<String>? = null, // used for v2
|
||||||
internal val sha256: String? = null,
|
internal val sha256: String? = null,
|
||||||
internal val signatures: List<String>? = null,
|
internal val signatures: List<String>? = null,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromSnapshot(app: Snapshot.App) = PackageMetadata(
|
||||||
|
time = app.time,
|
||||||
|
state = if (app.state.isBlank()) UNKNOWN_ERROR else PackageState.valueOf(app.state),
|
||||||
|
backupType = when (app.type) {
|
||||||
|
Snapshot.BackupType.FULL -> BackupType.FULL
|
||||||
|
Snapshot.BackupType.KV -> BackupType.KV
|
||||||
|
else -> null
|
||||||
|
},
|
||||||
|
name = app.name,
|
||||||
|
chunkIds = app.chunkIdsList.hexFromProto(),
|
||||||
|
system = app.system,
|
||||||
|
isLaunchableSystemApp = app.launchableSystemApp,
|
||||||
|
version = app.apk.versionCode,
|
||||||
|
installer = app.apk.installer,
|
||||||
|
baseApkChunkIds = run {
|
||||||
|
val baseChunk = app.apk.splitsList.find { it.name == BASE_SPLIT }
|
||||||
|
if (baseChunk == null || baseChunk.chunkIdsCount == 0) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
baseChunk.chunkIdsList.hexFromProto()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
splits = app.apk.splitsList.filter { it.name != BASE_SPLIT }.map {
|
||||||
|
ApkSplit(
|
||||||
|
name = it.name,
|
||||||
|
size = null,
|
||||||
|
sha256 = "",
|
||||||
|
chunkIds = if (it.chunkIdsCount == 0) null else it.chunkIdsList.hexFromProto()
|
||||||
|
)
|
||||||
|
}.takeIf { it.isNotEmpty() }, // expected null if there are no splits
|
||||||
|
sha256 = null,
|
||||||
|
signatures = app.apk.signaturesList.map { it.toByteArray().encodeBase64() }.takeIf {
|
||||||
|
it.isNotEmpty()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
val isInternalSystem: Boolean = system && !isLaunchableSystemApp
|
val isInternalSystem: Boolean = system && !isLaunchableSystemApp
|
||||||
fun hasApk(): Boolean {
|
fun hasApk(): Boolean {
|
||||||
return version != null && sha256 != null && signatures != null
|
return version != null && // v2 doesn't use sha256 here
|
||||||
|
(sha256 != null || baseApkChunkIds?.isNotEmpty() == true) &&
|
||||||
|
signatures != null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -104,6 +151,7 @@ data class ApkSplit(
|
||||||
val name: String,
|
val name: String,
|
||||||
val size: Long?,
|
val size: Long?,
|
||||||
val sha256: String,
|
val sha256: String,
|
||||||
|
val chunkIds: List<String>? = null, // used for v2
|
||||||
// There's also a revisionCode, but it doesn't seem to be used just yet
|
// There's also a revisionCode, but it doesn't seem to be used just yet
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -11,14 +11,16 @@ import android.content.Intent
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.content.pm.PackageManager.GET_SIGNATURES
|
import android.content.pm.PackageManager.GET_SIGNATURES
|
||||||
import android.content.pm.PackageManager.GET_SIGNING_CERTIFICATES
|
import android.content.pm.PackageManager.GET_SIGNING_CERTIFICATES
|
||||||
|
import android.content.pm.SigningInfo
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.stevesoltys.seedvault.BackupStateManager
|
import com.stevesoltys.seedvault.BackupStateManager
|
||||||
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
|
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
|
||||||
import com.stevesoltys.seedvault.crypto.Crypto
|
|
||||||
import com.stevesoltys.seedvault.metadata.ApkSplit
|
|
||||||
import com.stevesoltys.seedvault.metadata.PackageMetadata
|
|
||||||
import com.stevesoltys.seedvault.backend.BackendManager
|
import com.stevesoltys.seedvault.backend.BackendManager
|
||||||
import com.stevesoltys.seedvault.backend.LegacyStoragePlugin
|
import com.stevesoltys.seedvault.backend.LegacyStoragePlugin
|
||||||
|
import com.stevesoltys.seedvault.crypto.Crypto
|
||||||
|
import com.stevesoltys.seedvault.encodeBase64
|
||||||
|
import com.stevesoltys.seedvault.metadata.ApkSplit
|
||||||
|
import com.stevesoltys.seedvault.metadata.PackageMetadata
|
||||||
import com.stevesoltys.seedvault.restore.RestorableBackup
|
import com.stevesoltys.seedvault.restore.RestorableBackup
|
||||||
import com.stevesoltys.seedvault.restore.RestoreService
|
import com.stevesoltys.seedvault.restore.RestoreService
|
||||||
import com.stevesoltys.seedvault.restore.install.ApkInstallState.FAILED
|
import com.stevesoltys.seedvault.restore.install.ApkInstallState.FAILED
|
||||||
|
@ -26,9 +28,11 @@ import com.stevesoltys.seedvault.restore.install.ApkInstallState.FAILED_SYSTEM_A
|
||||||
import com.stevesoltys.seedvault.restore.install.ApkInstallState.IN_PROGRESS
|
import com.stevesoltys.seedvault.restore.install.ApkInstallState.IN_PROGRESS
|
||||||
import com.stevesoltys.seedvault.restore.install.ApkInstallState.QUEUED
|
import com.stevesoltys.seedvault.restore.install.ApkInstallState.QUEUED
|
||||||
import com.stevesoltys.seedvault.restore.install.ApkInstallState.SUCCEEDED
|
import com.stevesoltys.seedvault.restore.install.ApkInstallState.SUCCEEDED
|
||||||
|
import com.stevesoltys.seedvault.transport.backup.getBlobHandles
|
||||||
import com.stevesoltys.seedvault.transport.backup.isSystemApp
|
import com.stevesoltys.seedvault.transport.backup.isSystemApp
|
||||||
import com.stevesoltys.seedvault.worker.copyStreamsAndGetHash
|
import com.stevesoltys.seedvault.transport.restore.Loader
|
||||||
import com.stevesoltys.seedvault.worker.getSignatures
|
import com.stevesoltys.seedvault.transport.restore.RestorableBackup
|
||||||
|
import com.stevesoltys.seedvault.worker.hashSignature
|
||||||
import kotlinx.coroutines.TimeoutCancellationException
|
import kotlinx.coroutines.TimeoutCancellationException
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
@ -37,6 +41,9 @@ import org.calyxos.seedvault.core.backends.Backend
|
||||||
import org.calyxos.seedvault.core.backends.LegacyAppBackupFile
|
import org.calyxos.seedvault.core.backends.LegacyAppBackupFile
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.io.OutputStream
|
||||||
|
import java.security.MessageDigest
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
private val TAG = ApkRestore::class.java.simpleName
|
private val TAG = ApkRestore::class.java.simpleName
|
||||||
|
@ -46,6 +53,7 @@ internal class ApkRestore(
|
||||||
private val backupManager: IBackupManager,
|
private val backupManager: IBackupManager,
|
||||||
private val backupStateManager: BackupStateManager,
|
private val backupStateManager: BackupStateManager,
|
||||||
private val backendManager: BackendManager,
|
private val backendManager: BackendManager,
|
||||||
|
private val loader: Loader,
|
||||||
@Suppress("Deprecation")
|
@Suppress("Deprecation")
|
||||||
private val legacyStoragePlugin: LegacyStoragePlugin,
|
private val legacyStoragePlugin: LegacyStoragePlugin,
|
||||||
private val crypto: Crypto,
|
private val crypto: Crypto,
|
||||||
|
@ -130,6 +138,7 @@ internal class ApkRestore(
|
||||||
Log.e(TAG, "Timeout while re-installing APK for $packageName.", e)
|
Log.e(TAG, "Timeout while re-installing APK for $packageName.", e)
|
||||||
mInstallResult.update { it.fail(packageName) }
|
mInstallResult.update { it.fail(packageName) }
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
if (e::class.simpleName == "MockKException") throw e
|
||||||
Log.e(TAG, "Unexpected exception while re-installing APK for $packageName.", e)
|
Log.e(TAG, "Unexpected exception while re-installing APK for $packageName.", e)
|
||||||
mInstallResult.update { it.fail(packageName) }
|
mInstallResult.update { it.fail(packageName) }
|
||||||
}
|
}
|
||||||
|
@ -168,10 +177,10 @@ internal class ApkRestore(
|
||||||
}
|
}
|
||||||
|
|
||||||
// cache the APK and get its hash
|
// cache the APK and get its hash
|
||||||
val (cachedApk, sha256) = cacheApk(backup.version, backup.token, backup.salt, packageName)
|
val (cachedApk, sha256) = cacheApk(backup, packageName, metadata.baseApkChunkIds)
|
||||||
|
|
||||||
// check APK's SHA-256 hash
|
// check APK's SHA-256 hash for backup versions before 2
|
||||||
if (metadata.sha256 != sha256) throw SecurityException(
|
if (backup.version < 2 && metadata.sha256 != sha256) throw SecurityException(
|
||||||
"Package $packageName has sha256 '$sha256', but '${metadata.sha256}' expected."
|
"Package $packageName has sha256 '$sha256', but '${metadata.sha256}' expected."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -262,10 +271,9 @@ internal class ApkRestore(
|
||||||
}
|
}
|
||||||
splits.forEach { apkSplit -> // cache and check all splits
|
splits.forEach { apkSplit -> // cache and check all splits
|
||||||
val suffix = if (backup.version == 0.toByte()) "_${apkSplit.sha256}" else apkSplit.name
|
val suffix = if (backup.version == 0.toByte()) "_${apkSplit.sha256}" else apkSplit.name
|
||||||
val salt = backup.salt
|
val (file, sha256) = cacheApk(backup, packageName, apkSplit.chunkIds, suffix)
|
||||||
val (file, sha256) = cacheApk(backup.version, backup.token, salt, packageName, suffix)
|
// check APK split's SHA-256 hash for backup versions before 2
|
||||||
// check APK split's SHA-256 hash
|
if (backup.version < 2 && apkSplit.sha256 != sha256) throw SecurityException(
|
||||||
if (apkSplit.sha256 != sha256) throw SecurityException(
|
|
||||||
"$packageName:${apkSplit.name} has sha256 '$sha256'," +
|
"$packageName:${apkSplit.name} has sha256 '$sha256'," +
|
||||||
" but '${apkSplit.sha256}' expected."
|
" but '${apkSplit.sha256}' expected."
|
||||||
)
|
)
|
||||||
|
@ -282,20 +290,30 @@ internal class ApkRestore(
|
||||||
*/
|
*/
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
private suspend fun cacheApk(
|
private suspend fun cacheApk(
|
||||||
version: Byte,
|
backup: RestorableBackup,
|
||||||
token: Long,
|
|
||||||
salt: String,
|
|
||||||
packageName: String,
|
packageName: String,
|
||||||
|
chunkIds: List<String>?,
|
||||||
suffix: String = "",
|
suffix: String = "",
|
||||||
): Pair<File, String> {
|
): Pair<File, String> {
|
||||||
// create a cache file to write the APK into
|
// create a cache file to write the APK into
|
||||||
val cachedApk = File.createTempFile(packageName + suffix, ".apk", context.cacheDir)
|
val cachedApk = File.createTempFile(packageName + suffix, ".apk", context.cacheDir)
|
||||||
// copy APK to cache file and calculate SHA-256 hash while we are at it
|
// copy APK to cache file and calculate SHA-256 hash while we are at it
|
||||||
val inputStream = if (version == 0.toByte()) {
|
val inputStream = when (backup.version) {
|
||||||
legacyStoragePlugin.getApkInputStream(token, packageName, suffix)
|
0.toByte() -> {
|
||||||
} else {
|
legacyStoragePlugin.getApkInputStream(backup.token, packageName, suffix)
|
||||||
val name = crypto.getNameForApk(salt, packageName, suffix)
|
}
|
||||||
backend.load(LegacyAppBackupFile.Blob(token, name))
|
1.toByte() -> {
|
||||||
|
val name = crypto.getNameForApk(backup.salt, packageName, suffix)
|
||||||
|
backend.load(LegacyAppBackupFile.Blob(backup.token, name))
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
val repoId = backup.repoId ?: error("No repoId for v2 backup")
|
||||||
|
val snapshot = backup.snapshot ?: error("No snapshot for v2 backup")
|
||||||
|
val handles = chunkIds?.let {
|
||||||
|
snapshot.getBlobHandles(repoId, it)
|
||||||
|
} ?: error("No chunkIds for $packageName-$suffix")
|
||||||
|
loader.loadFiles(handles)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
val sha256 = copyStreamsAndGetHash(inputStream, cachedApk.outputStream())
|
val sha256 = copyStreamsAndGetHash(inputStream, cachedApk.outputStream())
|
||||||
return Pair(cachedApk, sha256)
|
return Pair(cachedApk, sha256)
|
||||||
|
@ -343,3 +361,45 @@ internal class ApkRestore(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy the APK from the given [InputStream] to the given [OutputStream]
|
||||||
|
* and calculate the SHA-256 hash while at it.
|
||||||
|
*
|
||||||
|
* Both streams will be closed when the method returns.
|
||||||
|
*
|
||||||
|
* @return the APK's SHA-256 hash in Base64 format.
|
||||||
|
*/
|
||||||
|
@Throws(IOException::class)
|
||||||
|
fun copyStreamsAndGetHash(inputStream: InputStream, outputStream: OutputStream): String {
|
||||||
|
val messageDigest = MessageDigest.getInstance("SHA-256")
|
||||||
|
outputStream.use { oStream ->
|
||||||
|
inputStream.use { inputStream ->
|
||||||
|
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||||
|
var bytes = inputStream.read(buffer)
|
||||||
|
while (bytes >= 0) {
|
||||||
|
oStream.write(buffer, 0, bytes)
|
||||||
|
messageDigest.update(buffer, 0, bytes)
|
||||||
|
bytes = inputStream.read(buffer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return messageDigest.digest().encodeBase64()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a list of Base64 encoded SHA-256 signature hashes.
|
||||||
|
*/
|
||||||
|
fun SigningInfo?.getSignatures(): List<String> {
|
||||||
|
return if (this == null) {
|
||||||
|
emptyList()
|
||||||
|
} else if (hasMultipleSigners()) {
|
||||||
|
apkContentsSigners.map { signature ->
|
||||||
|
hashSignature(signature).encodeBase64()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
signingCertificateHistory.map { signature ->
|
||||||
|
hashSignature(signature).encodeBase64()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -14,7 +14,7 @@ val installModule = module {
|
||||||
factory { DeviceInfo(androidContext()) }
|
factory { DeviceInfo(androidContext()) }
|
||||||
factory { ApkSplitCompatibilityChecker(get()) }
|
factory { ApkSplitCompatibilityChecker(get()) }
|
||||||
factory {
|
factory {
|
||||||
ApkRestore(androidContext(), get(), get(), get(), get(), get(), get(), get()) {
|
ApkRestore(androidContext(), get(), get(), get(), get(), get(), get(), get(), get()) {
|
||||||
androidContext().getSystemService(UserManager::class.java)!!.isAllowedToInstallApks()
|
androidContext().getSystemService(UserManager::class.java)!!.isAllowedToInstallApks()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,12 +5,14 @@
|
||||||
|
|
||||||
package com.stevesoltys.seedvault.transport.backup
|
package com.stevesoltys.seedvault.transport.backup
|
||||||
|
|
||||||
|
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||||
import com.stevesoltys.seedvault.transport.SnapshotManager
|
import com.stevesoltys.seedvault.transport.SnapshotManager
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
internal class AppBackupManager(
|
internal class AppBackupManager(
|
||||||
private val blobsCache: BlobsCache,
|
private val blobsCache: BlobsCache,
|
||||||
|
private val settingsManager: SettingsManager,
|
||||||
private val snapshotManager: SnapshotManager,
|
private val snapshotManager: SnapshotManager,
|
||||||
private val snapshotCreatorFactory: SnapshotCreatorFactory,
|
private val snapshotCreatorFactory: SnapshotCreatorFactory,
|
||||||
) {
|
) {
|
||||||
|
@ -25,12 +27,15 @@ internal class AppBackupManager(
|
||||||
blobsCache.populateCache()
|
blobsCache.populateCache()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun afterBackupFinished() {
|
suspend fun afterBackupFinished(success: Boolean) {
|
||||||
log.info { "After backup finished" }
|
log.info { "After backup finished. Success: $success" }
|
||||||
blobsCache.clear()
|
blobsCache.clear()
|
||||||
val snapshot = snapshotCreator?.finalizeSnapshot() ?: error("Had no snapshotCreator")
|
if (success) {
|
||||||
keepTrying {
|
val snapshot = snapshotCreator?.finalizeSnapshot() ?: error("Had no snapshotCreator")
|
||||||
snapshotManager.saveSnapshot(snapshot)
|
keepTrying {
|
||||||
|
snapshotManager.saveSnapshot(snapshot)
|
||||||
|
}
|
||||||
|
settingsManager.token = snapshot.token
|
||||||
}
|
}
|
||||||
snapshotCreator = null
|
snapshotCreator = null
|
||||||
}
|
}
|
||||||
|
|
|
@ -22,6 +22,7 @@ import com.stevesoltys.seedvault.proto.Snapshot.Apk
|
||||||
import com.stevesoltys.seedvault.proto.Snapshot.App
|
import com.stevesoltys.seedvault.proto.Snapshot.App
|
||||||
import com.stevesoltys.seedvault.proto.Snapshot.Blob
|
import com.stevesoltys.seedvault.proto.Snapshot.Blob
|
||||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||||
|
import org.calyxos.seedvault.core.backends.AppBackupFileType
|
||||||
import org.calyxos.seedvault.core.toHexString
|
import org.calyxos.seedvault.core.toHexString
|
||||||
|
|
||||||
internal class SnapshotCreatorFactory(
|
internal class SnapshotCreatorFactory(
|
||||||
|
@ -130,3 +131,8 @@ internal class SnapshotCreator(
|
||||||
fun Iterable<String>.forProto() = map { ByteString.fromHex(it) }
|
fun Iterable<String>.forProto() = map { ByteString.fromHex(it) }
|
||||||
fun Iterable<ByteString>.hexFromProto() = map { it.toByteArray().toHexString() }
|
fun Iterable<ByteString>.hexFromProto() = map { it.toByteArray().toHexString() }
|
||||||
fun ByteString.hexFromProto() = toByteArray().toHexString()
|
fun ByteString.hexFromProto() = toByteArray().toHexString()
|
||||||
|
fun Snapshot.getBlobHandles(repoId: String, chunkIds: List<String>) = chunkIds.map { chunkId ->
|
||||||
|
val blobId = blobsMap[chunkId]?.id?.hexFromProto()
|
||||||
|
?: error("Blob for $chunkId missing from snapshot $token")
|
||||||
|
AppBackupFileType.Blob(repoId, blobId)
|
||||||
|
}
|
||||||
|
|
|
@ -19,8 +19,10 @@ import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
|
||||||
import com.stevesoltys.seedvault.R
|
import com.stevesoltys.seedvault.R
|
||||||
import com.stevesoltys.seedvault.metadata.MetadataManager
|
import com.stevesoltys.seedvault.metadata.MetadataManager
|
||||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||||
|
import com.stevesoltys.seedvault.transport.backup.AppBackupManager
|
||||||
import com.stevesoltys.seedvault.transport.backup.PackageService
|
import com.stevesoltys.seedvault.transport.backup.PackageService
|
||||||
import com.stevesoltys.seedvault.worker.BackupRequester
|
import com.stevesoltys.seedvault.worker.BackupRequester
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
import org.koin.core.component.KoinComponent
|
import org.koin.core.component.KoinComponent
|
||||||
import org.koin.core.component.inject
|
import org.koin.core.component.inject
|
||||||
|
|
||||||
|
@ -36,6 +38,7 @@ internal class NotificationBackupObserver(
|
||||||
private val metadataManager: MetadataManager by inject()
|
private val metadataManager: MetadataManager by inject()
|
||||||
private val packageService: PackageService by inject()
|
private val packageService: PackageService by inject()
|
||||||
private val settingsManager: SettingsManager by inject()
|
private val settingsManager: SettingsManager by inject()
|
||||||
|
private val appBackupManager: AppBackupManager by inject()
|
||||||
private var currentPackage: String? = null
|
private var currentPackage: String? = null
|
||||||
private var numPackages: Int = 0
|
private var numPackages: Int = 0
|
||||||
private var numPackagesToReport: Int = 0
|
private var numPackagesToReport: Int = 0
|
||||||
|
@ -141,6 +144,12 @@ internal class NotificationBackupObserver(
|
||||||
Log.e(TAG, "Error getting number of all user packages: ", e)
|
Log.e(TAG, "Error getting number of all user packages: ", e)
|
||||||
requestedPackages
|
requestedPackages
|
||||||
}
|
}
|
||||||
|
// TODO handle exceptions
|
||||||
|
runBlocking {
|
||||||
|
// TODO check if UI thread
|
||||||
|
Log.d("TAG", "Finalizing backup...")
|
||||||
|
appBackupManager.afterBackupFinished(success)
|
||||||
|
}
|
||||||
nm.onBackupFinished(success, numPackagesToReport, total, size)
|
nm.onBackupFinished(success, numPackagesToReport, total, size)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,7 +5,6 @@
|
||||||
|
|
||||||
package com.stevesoltys.seedvault.worker
|
package com.stevesoltys.seedvault.worker
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.content.pm.PackageInfo
|
import android.content.pm.PackageInfo
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.content.pm.Signature
|
import android.content.pm.Signature
|
||||||
|
@ -13,94 +12,91 @@ import android.content.pm.SigningInfo
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.util.PackageUtils.computeSha256DigestBytes
|
import android.util.PackageUtils.computeSha256DigestBytes
|
||||||
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
|
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
|
||||||
import com.stevesoltys.seedvault.crypto.Crypto
|
|
||||||
import com.stevesoltys.seedvault.encodeBase64
|
|
||||||
import com.stevesoltys.seedvault.metadata.ApkSplit
|
|
||||||
import com.stevesoltys.seedvault.metadata.MetadataManager
|
|
||||||
import com.stevesoltys.seedvault.metadata.PackageMetadata
|
import com.stevesoltys.seedvault.metadata.PackageMetadata
|
||||||
|
import com.stevesoltys.seedvault.proto.Snapshot
|
||||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||||
|
import com.stevesoltys.seedvault.transport.SnapshotManager
|
||||||
|
import com.stevesoltys.seedvault.transport.backup.AppBackupManager
|
||||||
|
import com.stevesoltys.seedvault.transport.backup.BackupReceiver
|
||||||
|
import com.stevesoltys.seedvault.transport.backup.forProto
|
||||||
|
import com.stevesoltys.seedvault.transport.backup.hexFromProto
|
||||||
import com.stevesoltys.seedvault.transport.backup.isNotUpdatedSystemApp
|
import com.stevesoltys.seedvault.transport.backup.isNotUpdatedSystemApp
|
||||||
import com.stevesoltys.seedvault.transport.backup.isTestOnly
|
import com.stevesoltys.seedvault.transport.backup.isTestOnly
|
||||||
|
import org.calyxos.seedvault.core.toHexString
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileInputStream
|
import java.io.FileInputStream
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.io.InputStream
|
|
||||||
import java.io.OutputStream
|
|
||||||
import java.security.MessageDigest
|
|
||||||
|
|
||||||
private val TAG = ApkBackup::class.java.simpleName
|
private val TAG = ApkBackup::class.java.simpleName
|
||||||
|
internal const val BASE_SPLIT = "org.calyxos.seedvault.BASE_SPLIT"
|
||||||
|
|
||||||
internal class ApkBackup(
|
internal class ApkBackup(
|
||||||
private val pm: PackageManager,
|
private val pm: PackageManager,
|
||||||
private val crypto: Crypto,
|
private val backupReceiver: BackupReceiver,
|
||||||
|
private val appBackupManager: AppBackupManager,
|
||||||
|
private val snapshotManager: SnapshotManager,
|
||||||
private val settingsManager: SettingsManager,
|
private val settingsManager: SettingsManager,
|
||||||
private val metadataManager: MetadataManager,
|
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
private val snapshotCreator
|
||||||
|
get() = appBackupManager.snapshotCreator ?: error("No SnapshotCreator")
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks if a new APK needs to get backed up,
|
* Checks if a new APK needs to get backed up,
|
||||||
* because the version code or the signatures have changed.
|
* because the version code or the signatures have changed.
|
||||||
* Only if an APK needs a backup, an [OutputStream] is obtained from the given streamGetter
|
* Only if APKs need backup, they get chunked and uploaded.
|
||||||
* and the APK binary written to it.
|
|
||||||
*
|
*
|
||||||
* @return new [PackageMetadata] if an APK backup was made or null if no backup was made.
|
* @return new [PackageMetadata] if an APK backup was made or null if no backup was made.
|
||||||
*/
|
*/
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
@SuppressLint("NewApi") // can be removed when minSdk is set to 30
|
suspend fun backupApkIfNecessary(packageInfo: PackageInfo) {
|
||||||
suspend fun backupApkIfNecessary(
|
|
||||||
packageInfo: PackageInfo,
|
|
||||||
streamGetter: suspend (name: String) -> OutputStream,
|
|
||||||
): PackageMetadata? {
|
|
||||||
// do not back up @pm@
|
// do not back up @pm@
|
||||||
val packageName = packageInfo.packageName
|
val packageName = packageInfo.packageName
|
||||||
if (packageName == MAGIC_PACKAGE_MANAGER) return null
|
if (packageName == MAGIC_PACKAGE_MANAGER) return
|
||||||
|
|
||||||
// do not back up when setting is not enabled
|
// do not back up when setting is not enabled
|
||||||
if (!settingsManager.backupApks()) return null
|
if (!settingsManager.backupApks()) return
|
||||||
|
|
||||||
// do not back up if package is blacklisted
|
// do not back up if package is blacklisted
|
||||||
if (!settingsManager.isBackupEnabled(packageName)) {
|
if (!settingsManager.isBackupEnabled(packageName)) {
|
||||||
Log.d(TAG, "Package $packageName is blacklisted. Not backing it up.")
|
Log.d(TAG, "Package $packageName is blacklisted. Not backing it up.")
|
||||||
return null
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// do not back up test-only apps as we can't re-install them anyway
|
// do not back up test-only apps as we can't re-install them anyway
|
||||||
// see: https://commonsware.com/blog/2017/10/31/android-studio-3p0-flag-test-only.html
|
// see: https://commonsware.com/blog/2017/10/31/android-studio-3p0-flag-test-only.html
|
||||||
if (packageInfo.isTestOnly()) {
|
if (packageInfo.isTestOnly()) {
|
||||||
Log.d(TAG, "Package $packageName is test-only app. Not backing it up.")
|
Log.d(TAG, "Package $packageName is test-only app. Not backing it up.")
|
||||||
return null
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// do not back up system apps that haven't been updated
|
// do not back up system apps that haven't been updated
|
||||||
if (packageInfo.isNotUpdatedSystemApp()) {
|
if (packageInfo.isNotUpdatedSystemApp()) {
|
||||||
Log.d(TAG, "Package $packageName is vanilla system app. Not backing it up.")
|
Log.d(TAG, "Package $packageName is vanilla system app. Not backing it up.")
|
||||||
return null
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO remove when adding support for packages with multiple signers
|
// TODO remove when adding support for packages with multiple signers
|
||||||
val signingInfo = packageInfo.signingInfo ?: return null
|
val signingInfo = packageInfo.signingInfo ?: return
|
||||||
if (signingInfo.hasMultipleSigners()) {
|
if (signingInfo.hasMultipleSigners()) {
|
||||||
Log.e(TAG, "Package $packageName has multiple signers. Not backing it up.")
|
Log.e(TAG, "Package $packageName has multiple signers. Not backing it up.")
|
||||||
return null
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// get signatures
|
// get signatures
|
||||||
val signatures = signingInfo.getSignatures()
|
val signatures = signingInfo.getSignaturesHex()
|
||||||
if (signatures.isEmpty()) {
|
if (signatures.isEmpty()) {
|
||||||
Log.e(TAG, "Package $packageName has no signatures. Not backing it up.")
|
Log.e(TAG, "Package $packageName has no signatures. Not backing it up.")
|
||||||
return null
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// get cached metadata about package
|
// get info from latest snapshot
|
||||||
val packageMetadata = metadataManager.getPackageMetadata(packageName)
|
|
||||||
?: PackageMetadata()
|
|
||||||
|
|
||||||
// get version codes
|
|
||||||
val version = packageInfo.longVersionCode
|
val version = packageInfo.longVersionCode
|
||||||
val backedUpVersion = packageMetadata.version ?: 0L // no version will cause backup
|
val oldApk = snapshotManager.latestSnapshot?.appsMap?.get(packageName)?.apk
|
||||||
|
val backedUpVersion = oldApk?.versionCode ?: 0L // no version will cause backup
|
||||||
|
|
||||||
// do not backup if we have the version already and signatures did not change
|
// do not backup if we have the version already and signatures did not change
|
||||||
if (version <= backedUpVersion && !signaturesChanged(packageMetadata, signatures)) {
|
if (version <= backedUpVersion && !signaturesChanged(oldApk, signatures)) {
|
||||||
Log.d(
|
Log.d(
|
||||||
TAG, "Package $packageName with version $version" +
|
TAG, "Package $packageName with version $version" +
|
||||||
" already has a backup ($backedUpVersion)" +
|
" already has a backup ($backedUpVersion)" +
|
||||||
|
@ -108,40 +104,52 @@ internal class ApkBackup(
|
||||||
)
|
)
|
||||||
// We could also check if there are new feature module splits to back up,
|
// We could also check if there are new feature module splits to back up,
|
||||||
// but we rely on the app themselves to re-download those, if needed after restore.
|
// but we rely on the app themselves to re-download those, if needed after restore.
|
||||||
return null
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// builder for Apk object
|
||||||
|
val apkBuilder = Snapshot.Apk.newBuilder()
|
||||||
|
.setVersionCode(version)
|
||||||
|
.setInstaller(pm.getInstallSourceInfo(packageName).installingPackageName)
|
||||||
|
.addAllSignatures(signatures.forProto())
|
||||||
|
|
||||||
// get an InputStream for the APK
|
// get an InputStream for the APK
|
||||||
val sourceDir = packageInfo.applicationInfo?.sourceDir ?: return null
|
val sourceDir = packageInfo.applicationInfo?.sourceDir ?: return
|
||||||
val inputStream = getApkInputStream(sourceDir)
|
// upload the APK to the backend
|
||||||
// copy the APK to the storage's output and calculate SHA-256 hash while at it
|
getApkInputStream(sourceDir).use { inputStream ->
|
||||||
val name = crypto.getNameForApk(metadataManager.salt, packageName)
|
backupReceiver.readFromStream(inputStream)
|
||||||
val sha256 = copyStreamsAndGetHash(inputStream, streamGetter(name))
|
}
|
||||||
|
val backupData = backupReceiver.finalize()
|
||||||
|
// store base split in builder
|
||||||
|
val baseSplit = Snapshot.Split.newBuilder()
|
||||||
|
.setName(BASE_SPLIT)
|
||||||
|
.addAllChunkIds(backupData.chunks.forProto())
|
||||||
|
apkBuilder
|
||||||
|
.addSplits(baseSplit)
|
||||||
|
val chunkMap = backupData.chunkMap.toMutableMap()
|
||||||
|
|
||||||
// back up splits if they exist
|
// back up splits if they exist
|
||||||
val splits =
|
val splits = if (packageInfo.splitNames == null) {
|
||||||
if (packageInfo.splitNames == null) null else backupSplitApks(packageInfo, streamGetter)
|
emptyList()
|
||||||
|
} else {
|
||||||
|
backupSplitApks(packageInfo, chunkMap)
|
||||||
|
}
|
||||||
|
apkBuilder.addAllSplits(splits)
|
||||||
|
val apk = apkBuilder.build()
|
||||||
|
snapshotCreator.onApkBackedUp(packageName, apk, chunkMap)
|
||||||
|
|
||||||
Log.d(TAG, "Backed up new APK of $packageName with version ${packageInfo.versionName}.")
|
Log.d(TAG, "Backed up new APK of $packageName with version ${packageInfo.versionName}.")
|
||||||
|
|
||||||
// return updated metadata
|
|
||||||
return packageMetadata.copy(
|
|
||||||
version = version,
|
|
||||||
installer = pm.getInstallSourceInfo(packageName).installingPackageName,
|
|
||||||
splits = splits,
|
|
||||||
sha256 = sha256,
|
|
||||||
signatures = signatures
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun signaturesChanged(
|
private fun signaturesChanged(
|
||||||
packageMetadata: PackageMetadata,
|
apk: Snapshot.Apk?,
|
||||||
signatures: List<String>,
|
signatures: List<String>,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
// no signatures in package metadata counts as them not having changed
|
// no signatures counts as them not having changed
|
||||||
if (packageMetadata.signatures == null) return false
|
if (apk == null || apk.signaturesList.isNullOrEmpty()) return false
|
||||||
|
val sigHex = apk.signaturesList.hexFromProto()
|
||||||
// TODO to support multiple signers check if lists differ
|
// TODO to support multiple signers check if lists differ
|
||||||
return packageMetadata.signatures.intersect(signatures).isEmpty()
|
return sigHex.intersect(signatures.toSet()).isEmpty()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
|
@ -159,8 +167,8 @@ internal class ApkBackup(
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
private suspend fun backupSplitApks(
|
private suspend fun backupSplitApks(
|
||||||
packageInfo: PackageInfo,
|
packageInfo: PackageInfo,
|
||||||
streamGetter: suspend (name: String) -> OutputStream,
|
chunkMap: MutableMap<String, Snapshot.Blob>,
|
||||||
): List<ApkSplit> {
|
): List<Snapshot.Split> {
|
||||||
check(packageInfo.splitNames != null)
|
check(packageInfo.splitNames != null)
|
||||||
// attention: though not documented, splitSourceDirs can be null
|
// attention: though not documented, splitSourceDirs can be null
|
||||||
val splitSourceDirs = packageInfo.applicationInfo?.splitSourceDirs ?: emptyArray()
|
val splitSourceDirs = packageInfo.applicationInfo?.splitSourceDirs ?: emptyArray()
|
||||||
|
@ -169,97 +177,42 @@ internal class ApkBackup(
|
||||||
"splitNames is ${packageInfo.splitNames.toList()}, " +
|
"splitNames is ${packageInfo.splitNames.toList()}, " +
|
||||||
"but splitSourceDirs is ${splitSourceDirs.toList()}"
|
"but splitSourceDirs is ${splitSourceDirs.toList()}"
|
||||||
}
|
}
|
||||||
val splits = ArrayList<ApkSplit>(packageInfo.splitNames.size)
|
val splits = ArrayList<Snapshot.Split>(packageInfo.splitNames.size)
|
||||||
for (i in packageInfo.splitNames.indices) {
|
for (i in packageInfo.splitNames.indices) {
|
||||||
val split = backupSplitApk(
|
// copy the split APK to the storage stream
|
||||||
packageName = packageInfo.packageName,
|
getApkInputStream(splitSourceDirs[i]).use { inputStream ->
|
||||||
splitName = packageInfo.splitNames[i],
|
backupReceiver.readFromStream(inputStream)
|
||||||
sourceDir = splitSourceDirs[i],
|
}
|
||||||
streamGetter = streamGetter
|
val backupData = backupReceiver.finalize()
|
||||||
)
|
val split = Snapshot.Split.newBuilder()
|
||||||
|
.setName(packageInfo.splitNames[i])
|
||||||
|
.addAllChunkIds(backupData.chunks.forProto())
|
||||||
|
.build()
|
||||||
splits.add(split)
|
splits.add(split)
|
||||||
|
chunkMap.putAll(backupData.chunkMap)
|
||||||
}
|
}
|
||||||
return splits
|
return splits
|
||||||
}
|
}
|
||||||
|
|
||||||
@Throws(IOException::class)
|
|
||||||
private suspend fun backupSplitApk(
|
|
||||||
packageName: String,
|
|
||||||
splitName: String,
|
|
||||||
sourceDir: String,
|
|
||||||
streamGetter: suspend (name: String) -> OutputStream,
|
|
||||||
): ApkSplit {
|
|
||||||
// Calculate sha256 hash first to determine file name suffix.
|
|
||||||
// We could also just use the split name as a suffix, but there is a theoretical risk
|
|
||||||
// that we exceed the maximum file name length, so we use the hash instead.
|
|
||||||
// The downside is that we need to read the file two times.
|
|
||||||
val messageDigest = MessageDigest.getInstance("SHA-256")
|
|
||||||
val size = getApkInputStream(sourceDir).use { inputStream ->
|
|
||||||
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
|
||||||
var readCount = 0
|
|
||||||
var bytes = inputStream.read(buffer)
|
|
||||||
while (bytes >= 0) {
|
|
||||||
readCount += bytes
|
|
||||||
messageDigest.update(buffer, 0, bytes)
|
|
||||||
bytes = inputStream.read(buffer)
|
|
||||||
}
|
|
||||||
readCount
|
|
||||||
}
|
|
||||||
val sha256 = messageDigest.digest().encodeBase64()
|
|
||||||
val name = crypto.getNameForApk(metadataManager.salt, packageName, splitName)
|
|
||||||
// copy the split APK to the storage stream
|
|
||||||
getApkInputStream(sourceDir).use { inputStream ->
|
|
||||||
streamGetter(name).use { outputStream ->
|
|
||||||
inputStream.copyTo(outputStream)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ApkSplit(splitName, size.toLong(), sha256)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Copy the APK from the given [InputStream] to the given [OutputStream]
|
* Returns a list of lowercase hex encoded SHA-256 signature hashes.
|
||||||
* and calculate the SHA-256 hash while at it.
|
|
||||||
*
|
|
||||||
* Both streams will be closed when the method returns.
|
|
||||||
*
|
|
||||||
* @return the APK's SHA-256 hash in Base64 format.
|
|
||||||
*/
|
*/
|
||||||
@Throws(IOException::class)
|
fun SigningInfo?.getSignaturesHex(): List<String> {
|
||||||
fun copyStreamsAndGetHash(inputStream: InputStream, outputStream: OutputStream): String {
|
|
||||||
val messageDigest = MessageDigest.getInstance("SHA-256")
|
|
||||||
outputStream.use { oStream ->
|
|
||||||
inputStream.use { inputStream ->
|
|
||||||
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
|
||||||
var bytes = inputStream.read(buffer)
|
|
||||||
while (bytes >= 0) {
|
|
||||||
oStream.write(buffer, 0, bytes)
|
|
||||||
messageDigest.update(buffer, 0, bytes)
|
|
||||||
bytes = inputStream.read(buffer)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return messageDigest.digest().encodeBase64()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns a list of Base64 encoded SHA-256 signature hashes.
|
|
||||||
*/
|
|
||||||
fun SigningInfo?.getSignatures(): List<String> {
|
|
||||||
return if (this == null) {
|
return if (this == null) {
|
||||||
emptyList()
|
emptyList()
|
||||||
} else if (hasMultipleSigners()) {
|
} else if (hasMultipleSigners()) {
|
||||||
apkContentsSigners.map { signature ->
|
apkContentsSigners.map { signature ->
|
||||||
hashSignature(signature).encodeBase64()
|
hashSignature(signature).toHexString()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
signingCertificateHistory.map { signature ->
|
signingCertificateHistory.map { signature ->
|
||||||
hashSignature(signature).encodeBase64()
|
hashSignature(signature).toHexString()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun hashSignature(signature: Signature): ByteArray {
|
internal fun hashSignature(signature: Signature): ByteArray {
|
||||||
return computeSha256DigestBytes(signature.toByteArray()) ?: throw AssertionError()
|
return computeSha256DigestBytes(signature.toByteArray()) ?: throw AssertionError()
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,19 +8,15 @@ package com.stevesoltys.seedvault.worker
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.pm.PackageInfo
|
import android.content.pm.PackageInfo
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import com.stevesoltys.seedvault.backend.isOutOfSpace
|
||||||
import com.stevesoltys.seedvault.metadata.MetadataManager
|
import com.stevesoltys.seedvault.metadata.MetadataManager
|
||||||
import com.stevesoltys.seedvault.metadata.PackageState.NOT_ALLOWED
|
import com.stevesoltys.seedvault.metadata.PackageState.NOT_ALLOWED
|
||||||
import com.stevesoltys.seedvault.metadata.PackageState.WAS_STOPPED
|
import com.stevesoltys.seedvault.metadata.PackageState.WAS_STOPPED
|
||||||
import com.stevesoltys.seedvault.backend.BackendManager
|
|
||||||
import com.stevesoltys.seedvault.backend.getMetadataOutputStream
|
|
||||||
import com.stevesoltys.seedvault.backend.isOutOfSpace
|
|
||||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||||
import com.stevesoltys.seedvault.transport.backup.PackageService
|
import com.stevesoltys.seedvault.transport.backup.PackageService
|
||||||
import com.stevesoltys.seedvault.transport.backup.isStopped
|
import com.stevesoltys.seedvault.transport.backup.isStopped
|
||||||
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
|
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
|
||||||
import com.stevesoltys.seedvault.ui.notification.getAppName
|
import com.stevesoltys.seedvault.ui.notification.getAppName
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import org.calyxos.seedvault.core.backends.LegacyAppBackupFile
|
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
|
||||||
internal class ApkBackupManager(
|
internal class ApkBackupManager(
|
||||||
|
@ -30,7 +26,6 @@ internal class ApkBackupManager(
|
||||||
private val packageService: PackageService,
|
private val packageService: PackageService,
|
||||||
private val iconManager: IconManager,
|
private val iconManager: IconManager,
|
||||||
private val apkBackup: ApkBackup,
|
private val apkBackup: ApkBackup,
|
||||||
private val backendManager: BackendManager,
|
|
||||||
private val nm: BackupNotificationManager,
|
private val nm: BackupNotificationManager,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
@ -51,14 +46,6 @@ internal class ApkBackupManager(
|
||||||
backUpApks()
|
backUpApks()
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
keepTrying {
|
|
||||||
// upload all local changes only at the end,
|
|
||||||
// so we don't have to re-upload the metadata
|
|
||||||
val token = settingsManager.getToken() ?: error("no token")
|
|
||||||
backendManager.backend.getMetadataOutputStream(token).use { outputStream ->
|
|
||||||
metadataManager.uploadMetadata(outputStream)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
nm.onApkBackupDone()
|
nm.onApkBackupDone()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -107,37 +94,15 @@ internal class ApkBackupManager(
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Backs up an APK for the given [PackageInfo].
|
* Backs up one (or more split) APK(s) for the given [PackageInfo], if needed.
|
||||||
*
|
|
||||||
* @return true if a backup was performed and false if no backup was needed or it failed.
|
|
||||||
*/
|
*/
|
||||||
private suspend fun backUpApk(packageInfo: PackageInfo): Boolean {
|
private suspend fun backUpApk(packageInfo: PackageInfo) {
|
||||||
val packageName = packageInfo.packageName
|
val packageName = packageInfo.packageName
|
||||||
return try {
|
try {
|
||||||
apkBackup.backupApkIfNecessary(packageInfo) { name ->
|
apkBackup.backupApkIfNecessary(packageInfo)
|
||||||
val token = settingsManager.getToken() ?: throw IOException("no current token")
|
|
||||||
backendManager.backend.save(LegacyAppBackupFile.Blob(token, name))
|
|
||||||
}?.let { packageMetadata ->
|
|
||||||
metadataManager.onApkBackedUp(packageInfo, packageMetadata)
|
|
||||||
true
|
|
||||||
} ?: false
|
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
Log.e(TAG, "Error while writing APK for $packageName", e)
|
Log.e(TAG, "Error while writing APK for $packageName", e)
|
||||||
if (e.isOutOfSpace()) nm.onInsufficientSpaceError()
|
if (e.isOutOfSpace()) nm.onInsufficientSpaceError()
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun keepTrying(n: Int = 3, block: suspend () -> Unit) {
|
|
||||||
for (i in 1..n) {
|
|
||||||
try {
|
|
||||||
block()
|
|
||||||
return
|
|
||||||
} catch (e: Exception) {
|
|
||||||
if (i == n) throw e
|
|
||||||
Log.e(TAG, "Error (#$i), we'll keep trying", e)
|
|
||||||
delay(1000)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -24,6 +24,7 @@ import androidx.work.WorkManager
|
||||||
import androidx.work.WorkerParameters
|
import androidx.work.WorkerParameters
|
||||||
import com.stevesoltys.seedvault.backend.BackendManager
|
import com.stevesoltys.seedvault.backend.BackendManager
|
||||||
import com.stevesoltys.seedvault.settings.SettingsManager
|
import com.stevesoltys.seedvault.settings.SettingsManager
|
||||||
|
import com.stevesoltys.seedvault.transport.backup.AppBackupManager
|
||||||
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
|
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
|
||||||
import com.stevesoltys.seedvault.ui.notification.NOTIFICATION_ID_OBSERVER
|
import com.stevesoltys.seedvault.ui.notification.NOTIFICATION_ID_OBSERVER
|
||||||
import org.koin.core.component.KoinComponent
|
import org.koin.core.component.KoinComponent
|
||||||
|
@ -101,6 +102,7 @@ class AppBackupWorker(
|
||||||
private val backupRequester: BackupRequester by inject()
|
private val backupRequester: BackupRequester by inject()
|
||||||
private val settingsManager: SettingsManager by inject()
|
private val settingsManager: SettingsManager by inject()
|
||||||
private val apkBackupManager: ApkBackupManager by inject()
|
private val apkBackupManager: ApkBackupManager by inject()
|
||||||
|
private val appBackupManager: AppBackupManager by inject()
|
||||||
private val backendManager: BackendManager by inject()
|
private val backendManager: BackendManager by inject()
|
||||||
private val nm: BackupNotificationManager by inject()
|
private val nm: BackupNotificationManager by inject()
|
||||||
|
|
||||||
|
@ -137,6 +139,15 @@ class AppBackupWorker(
|
||||||
|
|
||||||
private suspend fun doBackup(): Result {
|
private suspend fun doBackup(): Result {
|
||||||
var result: Result = Result.success()
|
var result: Result = Result.success()
|
||||||
|
if (!isStopped) {
|
||||||
|
Log.i(TAG, "Initializing backup info...")
|
||||||
|
try {
|
||||||
|
appBackupManager.beforeBackup()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error populating blobs cache: ", e)
|
||||||
|
return Result.retry()
|
||||||
|
}
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
Log.i(TAG, "Starting APK backup... (stopped: $isStopped)")
|
Log.i(TAG, "Starting APK backup... (stopped: $isStopped)")
|
||||||
if (!isStopped) apkBackupManager.backup()
|
if (!isStopped) apkBackupManager.backup()
|
||||||
|
|
|
@ -27,13 +27,14 @@ val workerModule = module {
|
||||||
appBackupManager = get(),
|
appBackupManager = get(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
single { AppBackupManager(get(), get(), get()) }
|
single { AppBackupManager(get(), get(), get(), get()) }
|
||||||
single {
|
single {
|
||||||
ApkBackup(
|
ApkBackup(
|
||||||
pm = androidContext().packageManager,
|
pm = androidContext().packageManager,
|
||||||
crypto = get(),
|
backupReceiver = get(),
|
||||||
|
appBackupManager = get(),
|
||||||
|
snapshotManager = get(),
|
||||||
settingsManager = get(),
|
settingsManager = get(),
|
||||||
metadataManager = get()
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
single {
|
single {
|
||||||
|
@ -44,7 +45,6 @@ val workerModule = module {
|
||||||
packageService = get(),
|
packageService = get(),
|
||||||
apkBackup = get(),
|
apkBackup = get(),
|
||||||
iconManager = get(),
|
iconManager = get(),
|
||||||
backendManager = get(),
|
|
||||||
nm = get()
|
nm = get()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -416,10 +416,7 @@ internal class AppSelectionManagerTest : TransportTest() {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getRestorableBackup(map: Map<String, PackageMetadata>) = RestorableBackup(
|
private fun getRestorableBackup(map: Map<String, PackageMetadata>) = RestorableBackup(
|
||||||
backupMetadata = backupMetadata.copy(
|
backupMetadata = backupMetadata.copy(packageMetadataMap = map as PackageMetadataMap),
|
||||||
version = 2,
|
|
||||||
packageMetadataMap = map as PackageMetadataMap,
|
|
||||||
),
|
|
||||||
repoId = repoId,
|
repoId = repoId,
|
||||||
snapshot = snapshot,
|
snapshot = snapshot,
|
||||||
)
|
)
|
||||||
|
|
|
@ -14,29 +14,43 @@ import android.content.pm.Signature
|
||||||
import android.graphics.drawable.Drawable
|
import android.graphics.drawable.Drawable
|
||||||
import android.util.PackageUtils
|
import android.util.PackageUtils
|
||||||
import app.cash.turbine.test
|
import app.cash.turbine.test
|
||||||
|
import com.google.protobuf.ByteString
|
||||||
|
import com.google.protobuf.ByteString.copyFromUtf8
|
||||||
import com.stevesoltys.seedvault.BackupStateManager
|
import com.stevesoltys.seedvault.BackupStateManager
|
||||||
import com.stevesoltys.seedvault.assertReadEquals
|
import com.stevesoltys.seedvault.assertReadEquals
|
||||||
|
import com.stevesoltys.seedvault.backend.BackendManager
|
||||||
|
import com.stevesoltys.seedvault.backend.LegacyStoragePlugin
|
||||||
|
import com.stevesoltys.seedvault.decodeBase64
|
||||||
import com.stevesoltys.seedvault.getRandomString
|
import com.stevesoltys.seedvault.getRandomString
|
||||||
import com.stevesoltys.seedvault.metadata.ApkSplit
|
|
||||||
import com.stevesoltys.seedvault.metadata.PackageMetadata
|
import com.stevesoltys.seedvault.metadata.PackageMetadata
|
||||||
import com.stevesoltys.seedvault.metadata.PackageMetadataMap
|
import com.stevesoltys.seedvault.metadata.PackageMetadataMap
|
||||||
import com.stevesoltys.seedvault.backend.LegacyStoragePlugin
|
import com.stevesoltys.seedvault.proto.Snapshot
|
||||||
import com.stevesoltys.seedvault.backend.BackendManager
|
|
||||||
import com.stevesoltys.seedvault.restore.RestorableBackup
|
|
||||||
import com.stevesoltys.seedvault.restore.install.ApkInstallState.IN_PROGRESS
|
import com.stevesoltys.seedvault.restore.install.ApkInstallState.IN_PROGRESS
|
||||||
import com.stevesoltys.seedvault.restore.install.ApkInstallState.QUEUED
|
import com.stevesoltys.seedvault.restore.install.ApkInstallState.QUEUED
|
||||||
import com.stevesoltys.seedvault.restore.install.ApkInstallState.SUCCEEDED
|
import com.stevesoltys.seedvault.restore.install.ApkInstallState.SUCCEEDED
|
||||||
|
import com.stevesoltys.seedvault.transport.SnapshotManager
|
||||||
import com.stevesoltys.seedvault.transport.TransportTest
|
import com.stevesoltys.seedvault.transport.TransportTest
|
||||||
|
import com.stevesoltys.seedvault.transport.backup.AppBackupManager
|
||||||
|
import com.stevesoltys.seedvault.transport.backup.BackupData
|
||||||
|
import com.stevesoltys.seedvault.transport.backup.BackupReceiver
|
||||||
|
import com.stevesoltys.seedvault.transport.backup.SnapshotCreator
|
||||||
|
import com.stevesoltys.seedvault.transport.backup.hexFromProto
|
||||||
|
import com.stevesoltys.seedvault.transport.restore.Loader
|
||||||
|
import com.stevesoltys.seedvault.transport.restore.RestorableBackup
|
||||||
import com.stevesoltys.seedvault.worker.ApkBackup
|
import com.stevesoltys.seedvault.worker.ApkBackup
|
||||||
|
import com.stevesoltys.seedvault.worker.BASE_SPLIT
|
||||||
|
import io.mockk.Runs
|
||||||
import io.mockk.coEvery
|
import io.mockk.coEvery
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
|
import io.mockk.just
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
import io.mockk.mockkStatic
|
import io.mockk.mockkStatic
|
||||||
import io.mockk.slot
|
import io.mockk.slot
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.calyxos.seedvault.core.backends.AppBackupFileType
|
||||||
import org.calyxos.seedvault.core.backends.Backend
|
import org.calyxos.seedvault.core.backends.Backend
|
||||||
import org.calyxos.seedvault.core.backends.LegacyAppBackupFile
|
import org.calyxos.seedvault.core.toHexString
|
||||||
import org.junit.jupiter.api.Assertions.assertArrayEquals
|
import org.junit.jupiter.api.Assertions.assertArrayEquals
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
import org.junit.jupiter.api.Assertions.assertFalse
|
import org.junit.jupiter.api.Assertions.assertFalse
|
||||||
|
@ -48,7 +62,7 @@ import java.io.ByteArrayInputStream
|
||||||
import java.io.ByteArrayOutputStream
|
import java.io.ByteArrayOutputStream
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileInputStream
|
import java.io.FileInputStream
|
||||||
import java.io.OutputStream
|
import java.io.InputStream
|
||||||
import java.nio.file.Path
|
import java.nio.file.Path
|
||||||
import kotlin.random.Random
|
import kotlin.random.Random
|
||||||
|
|
||||||
|
@ -63,6 +77,11 @@ internal class ApkBackupRestoreTest : TransportTest() {
|
||||||
private val backendManager: BackendManager = mockk()
|
private val backendManager: BackendManager = mockk()
|
||||||
private val backupManager: IBackupManager = mockk()
|
private val backupManager: IBackupManager = mockk()
|
||||||
private val backupStateManager: BackupStateManager = mockk()
|
private val backupStateManager: BackupStateManager = mockk()
|
||||||
|
private val backupReceiver: BackupReceiver = mockk()
|
||||||
|
private val appBackupManager: AppBackupManager = mockk()
|
||||||
|
private val snapshotManager: SnapshotManager = mockk()
|
||||||
|
private val snapshotCreator: SnapshotCreator = mockk()
|
||||||
|
private val loader: Loader = mockk()
|
||||||
|
|
||||||
@Suppress("Deprecation")
|
@Suppress("Deprecation")
|
||||||
private val legacyStoragePlugin: LegacyStoragePlugin = mockk()
|
private val legacyStoragePlugin: LegacyStoragePlugin = mockk()
|
||||||
|
@ -71,12 +90,14 @@ internal class ApkBackupRestoreTest : TransportTest() {
|
||||||
private val apkInstaller: ApkInstaller = mockk()
|
private val apkInstaller: ApkInstaller = mockk()
|
||||||
private val installRestriction: InstallRestriction = mockk()
|
private val installRestriction: InstallRestriction = mockk()
|
||||||
|
|
||||||
private val apkBackup = ApkBackup(pm, crypto, settingsManager, metadataManager)
|
private val apkBackup =
|
||||||
|
ApkBackup(pm, backupReceiver, appBackupManager, snapshotManager, settingsManager)
|
||||||
private val apkRestore: ApkRestore = ApkRestore(
|
private val apkRestore: ApkRestore = ApkRestore(
|
||||||
context = strictContext,
|
context = strictContext,
|
||||||
backupManager = backupManager,
|
backupManager = backupManager,
|
||||||
backupStateManager = backupStateManager,
|
backupStateManager = backupStateManager,
|
||||||
backendManager = backendManager,
|
backendManager = backendManager,
|
||||||
|
loader = loader,
|
||||||
legacyStoragePlugin = legacyStoragePlugin,
|
legacyStoragePlugin = legacyStoragePlugin,
|
||||||
crypto = crypto,
|
crypto = crypto,
|
||||||
splitCompatChecker = splitCompatChecker,
|
splitCompatChecker = splitCompatChecker,
|
||||||
|
@ -90,29 +111,46 @@ internal class ApkBackupRestoreTest : TransportTest() {
|
||||||
private val packageName: String = packageInfo.packageName
|
private val packageName: String = packageInfo.packageName
|
||||||
private val splitName = getRandomString()
|
private val splitName = getRandomString()
|
||||||
private val splitBytes = byteArrayOf(0x07, 0x08, 0x09)
|
private val splitBytes = byteArrayOf(0x07, 0x08, 0x09)
|
||||||
private val splitSha256 = "ZqZ1cVH47lXbEncWx-Pc4L6AdLZOIO2lQuXB5GypxB4"
|
private val apkChunkId = Random.nextBytes(32).toHexString()
|
||||||
private val packageMetadata = PackageMetadata(
|
private val splitChunkId = Random.nextBytes(32).toHexString()
|
||||||
time = Random.nextLong(),
|
private val apkBlob =
|
||||||
version = packageInfo.longVersionCode - 1,
|
Snapshot.Blob.newBuilder().setId(ByteString.copyFrom(Random.nextBytes(32))).build()
|
||||||
installer = getRandomString(),
|
private val splitBlob =
|
||||||
sha256 = "eHx5jjmlvBkQNVuubQzYejay4Q_QICqD47trAF2oNHI",
|
Snapshot.Blob.newBuilder().setId(ByteString.copyFrom(Random.nextBytes(32))).build()
|
||||||
signatures = listOf("AwIB"),
|
private val apkBackupData = BackupData(listOf(apkChunkId), mapOf(apkChunkId to apkBlob))
|
||||||
splits = listOf(ApkSplit(splitName, Random.nextLong(), splitSha256))
|
private val splitBackupData = BackupData(listOf(splitChunkId), mapOf(splitChunkId to splitBlob))
|
||||||
)
|
private val chunkMap = apkBackupData.chunkMap + splitBackupData.chunkMap
|
||||||
private val packageMetadataMap: PackageMetadataMap = hashMapOf(packageName to packageMetadata)
|
private val baseSplit = Snapshot.Split.newBuilder().setName(BASE_SPLIT)
|
||||||
private val installerName = packageMetadata.installer
|
.addAllChunkIds(listOf(ByteString.fromHex(apkChunkId))).build()
|
||||||
|
private val apkSplit = Snapshot.Split.newBuilder().setName(splitName)
|
||||||
|
.addAllChunkIds(listOf(ByteString.fromHex(splitChunkId))).build()
|
||||||
|
private val apk = Snapshot.Apk.newBuilder()
|
||||||
|
.setVersionCode(packageInfo.longVersionCode - 1)
|
||||||
|
.setInstaller(getRandomString())
|
||||||
|
.addAllSignatures(mutableListOf(copyFromUtf8("AwIB".decodeBase64())))
|
||||||
|
.addSplits(baseSplit)
|
||||||
|
.addSplits(apkSplit)
|
||||||
|
.build()
|
||||||
|
private val app = Snapshot.App.newBuilder()
|
||||||
|
.setApk(apk)
|
||||||
|
.build()
|
||||||
|
private val snapshot = Snapshot.newBuilder()
|
||||||
|
.setToken(token)
|
||||||
|
.putApps(packageName, app)
|
||||||
|
.putAllBlobs(chunkMap)
|
||||||
|
.build()
|
||||||
|
private val packageMetadataMap: PackageMetadataMap =
|
||||||
|
hashMapOf(packageName to PackageMetadata.fromSnapshot(app))
|
||||||
|
private val installerName = apk.installer
|
||||||
private val icon: Drawable = mockk()
|
private val icon: Drawable = mockk()
|
||||||
private val appName = getRandomString()
|
private val appName = getRandomString()
|
||||||
private val suffixName = getRandomString()
|
|
||||||
private val outputStream = ByteArrayOutputStream()
|
private val outputStream = ByteArrayOutputStream()
|
||||||
private val splitOutputStream = ByteArrayOutputStream()
|
private val splitOutputStream = ByteArrayOutputStream()
|
||||||
private val outputStreamGetter: suspend (name: String) -> OutputStream = { name ->
|
|
||||||
if (name == this.name) outputStream else splitOutputStream
|
|
||||||
}
|
|
||||||
|
|
||||||
init {
|
init {
|
||||||
mockkStatic(PackageUtils::class)
|
mockkStatic(PackageUtils::class)
|
||||||
every { backendManager.backend } returns backend
|
every { backendManager.backend } returns backend
|
||||||
|
every { appBackupManager.snapshotCreator } returns snapshotCreator
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
@ -128,6 +166,7 @@ internal class ApkBackupRestoreTest : TransportTest() {
|
||||||
assertTrue(createNewFile())
|
assertTrue(createNewFile())
|
||||||
writeBytes(splitBytes)
|
writeBytes(splitBytes)
|
||||||
}.absolutePath)
|
}.absolutePath)
|
||||||
|
val capturedApkStream = slot<InputStream>()
|
||||||
|
|
||||||
// related to starting/stopping service
|
// related to starting/stopping service
|
||||||
every { strictContext.packageName } returns "org.foo.bar"
|
every { strictContext.packageName } returns "org.foo.bar"
|
||||||
|
@ -141,16 +180,19 @@ internal class ApkBackupRestoreTest : TransportTest() {
|
||||||
every { sigInfo.hasMultipleSigners() } returns false
|
every { sigInfo.hasMultipleSigners() } returns false
|
||||||
every { sigInfo.signingCertificateHistory } returns sigs
|
every { sigInfo.signingCertificateHistory } returns sigs
|
||||||
every { PackageUtils.computeSha256DigestBytes(signatureBytes) } returns signatureHash
|
every { PackageUtils.computeSha256DigestBytes(signatureBytes) } returns signatureHash
|
||||||
every {
|
every { snapshotManager.latestSnapshot } returns snapshot
|
||||||
metadataManager.getPackageMetadata(packageInfo.packageName)
|
|
||||||
} returns packageMetadata
|
|
||||||
every { pm.getInstallSourceInfo(packageInfo.packageName) } returns mockk(relaxed = true)
|
every { pm.getInstallSourceInfo(packageInfo.packageName) } returns mockk(relaxed = true)
|
||||||
every { metadataManager.salt } returns salt
|
coEvery { backupReceiver.readFromStream(capture(capturedApkStream)) } answers {
|
||||||
every { crypto.getNameForApk(salt, packageName) } returns name
|
capturedApkStream.captured.copyTo(outputStream)
|
||||||
every { crypto.getNameForApk(salt, packageName, splitName) } returns suffixName
|
} andThenAnswer {
|
||||||
every { backend.providerPackageName } returns storageProviderPackageName
|
capturedApkStream.captured.copyTo(splitOutputStream)
|
||||||
|
}
|
||||||
|
coEvery { backupReceiver.finalize() } returns apkBackupData andThen splitBackupData
|
||||||
|
every {
|
||||||
|
snapshotCreator.onApkBackedUp(packageInfo, any<Snapshot.Apk>(), chunkMap)
|
||||||
|
} just Runs
|
||||||
|
|
||||||
apkBackup.backupApkIfNecessary(packageInfo, outputStreamGetter)
|
apkBackup.backupApkIfNecessary(packageInfo)
|
||||||
|
|
||||||
assertArrayEquals(apkBytes, outputStream.toByteArray())
|
assertArrayEquals(apkBytes, outputStream.toByteArray())
|
||||||
assertArrayEquals(splitBytes, splitOutputStream.toByteArray())
|
assertArrayEquals(splitBytes, splitOutputStream.toByteArray())
|
||||||
|
@ -159,23 +201,23 @@ internal class ApkBackupRestoreTest : TransportTest() {
|
||||||
val splitInputStream = ByteArrayInputStream(splitBytes)
|
val splitInputStream = ByteArrayInputStream(splitBytes)
|
||||||
val apkPath = slot<String>()
|
val apkPath = slot<String>()
|
||||||
val cacheFiles = slot<List<File>>()
|
val cacheFiles = slot<List<File>>()
|
||||||
|
val repoId = getRandomString()
|
||||||
|
val apkHandle = AppBackupFileType.Blob(repoId, apkBlob.id.hexFromProto())
|
||||||
|
val splitHandle = AppBackupFileType.Blob(repoId, splitBlob.id.hexFromProto())
|
||||||
|
|
||||||
|
every { backend.providerPackageName } returns storageProviderPackageName
|
||||||
every { installRestriction.isAllowedToInstallApks() } returns true
|
every { installRestriction.isAllowedToInstallApks() } returns true
|
||||||
every { backupStateManager.isAutoRestoreEnabled } returns false
|
every { backupStateManager.isAutoRestoreEnabled } returns false
|
||||||
every { pm.getPackageInfo(packageName, any<Int>()) } throws NameNotFoundException()
|
every { pm.getPackageInfo(packageName, any<Int>()) } throws NameNotFoundException()
|
||||||
every { strictContext.cacheDir } returns tmpFile
|
every { strictContext.cacheDir } returns tmpFile
|
||||||
every { crypto.getNameForApk(salt, packageName, "") } returns name
|
coEvery { loader.loadFiles(listOf(apkHandle)) } returns inputStream
|
||||||
coEvery { backend.load(LegacyAppBackupFile.Blob(token, name)) } returns inputStream
|
|
||||||
every { pm.getPackageArchiveInfo(capture(apkPath), any<Int>()) } returns packageInfo
|
every { pm.getPackageArchiveInfo(capture(apkPath), any<Int>()) } returns packageInfo
|
||||||
every { applicationInfo.loadIcon(pm) } returns icon
|
every { applicationInfo.loadIcon(pm) } returns icon
|
||||||
every { pm.getApplicationLabel(packageInfo.applicationInfo!!) } returns appName
|
every { pm.getApplicationLabel(packageInfo.applicationInfo!!) } returns appName
|
||||||
every {
|
every {
|
||||||
splitCompatChecker.isCompatible(metadata.deviceName, listOf(splitName))
|
splitCompatChecker.isCompatible(metadata.deviceName, listOf(splitName))
|
||||||
} returns true
|
} returns true
|
||||||
every { crypto.getNameForApk(salt, packageName, splitName) } returns suffixName
|
coEvery { loader.loadFiles(listOf(splitHandle)) } returns splitInputStream
|
||||||
coEvery {
|
|
||||||
backend.load(LegacyAppBackupFile.Blob(token, suffixName))
|
|
||||||
} returns splitInputStream
|
|
||||||
val resultMap = mapOf(
|
val resultMap = mapOf(
|
||||||
packageName to ApkInstallResult(
|
packageName to ApkInstallResult(
|
||||||
packageName,
|
packageName,
|
||||||
|
@ -187,7 +229,11 @@ internal class ApkBackupRestoreTest : TransportTest() {
|
||||||
apkInstaller.install(capture(cacheFiles), packageName, installerName, any())
|
apkInstaller.install(capture(cacheFiles), packageName, installerName, any())
|
||||||
} returns InstallResult(resultMap)
|
} returns InstallResult(resultMap)
|
||||||
|
|
||||||
val backup = RestorableBackup(metadata.copy(packageMetadataMap = packageMetadataMap))
|
val backup = RestorableBackup(
|
||||||
|
backupMetadata = metadata.copy(packageMetadataMap = packageMetadataMap),
|
||||||
|
repoId = repoId,
|
||||||
|
snapshot = snapshot,
|
||||||
|
)
|
||||||
apkRestore.installResult.test {
|
apkRestore.installResult.test {
|
||||||
awaitItem() // initial empty state
|
awaitItem() // initial empty state
|
||||||
apkRestore.restore(backup)
|
apkRestore.restore(backup)
|
||||||
|
|
|
@ -17,15 +17,19 @@ import android.content.pm.PackageManager.NameNotFoundException
|
||||||
import android.graphics.drawable.Drawable
|
import android.graphics.drawable.Drawable
|
||||||
import app.cash.turbine.TurbineTestContext
|
import app.cash.turbine.TurbineTestContext
|
||||||
import app.cash.turbine.test
|
import app.cash.turbine.test
|
||||||
|
import com.google.protobuf.ByteString
|
||||||
|
import com.google.protobuf.ByteString.copyFromUtf8
|
||||||
|
import com.google.protobuf.ByteString.fromHex
|
||||||
import com.stevesoltys.seedvault.BackupStateManager
|
import com.stevesoltys.seedvault.BackupStateManager
|
||||||
|
import com.stevesoltys.seedvault.backend.BackendManager
|
||||||
|
import com.stevesoltys.seedvault.backend.LegacyStoragePlugin
|
||||||
|
import com.stevesoltys.seedvault.decodeBase64
|
||||||
import com.stevesoltys.seedvault.getRandomBase64
|
import com.stevesoltys.seedvault.getRandomBase64
|
||||||
import com.stevesoltys.seedvault.getRandomByteArray
|
|
||||||
import com.stevesoltys.seedvault.getRandomString
|
import com.stevesoltys.seedvault.getRandomString
|
||||||
import com.stevesoltys.seedvault.metadata.ApkSplit
|
import com.stevesoltys.seedvault.metadata.ApkSplit
|
||||||
import com.stevesoltys.seedvault.metadata.PackageMetadata
|
import com.stevesoltys.seedvault.metadata.PackageMetadata
|
||||||
import com.stevesoltys.seedvault.metadata.PackageMetadataMap
|
import com.stevesoltys.seedvault.metadata.PackageMetadataMap
|
||||||
import com.stevesoltys.seedvault.backend.LegacyStoragePlugin
|
import com.stevesoltys.seedvault.proto.Snapshot
|
||||||
import com.stevesoltys.seedvault.backend.BackendManager
|
|
||||||
import com.stevesoltys.seedvault.restore.RestorableBackup
|
import com.stevesoltys.seedvault.restore.RestorableBackup
|
||||||
import com.stevesoltys.seedvault.restore.install.ApkInstallState.FAILED
|
import com.stevesoltys.seedvault.restore.install.ApkInstallState.FAILED
|
||||||
import com.stevesoltys.seedvault.restore.install.ApkInstallState.FAILED_SYSTEM_APP
|
import com.stevesoltys.seedvault.restore.install.ApkInstallState.FAILED_SYSTEM_APP
|
||||||
|
@ -33,7 +37,10 @@ import com.stevesoltys.seedvault.restore.install.ApkInstallState.IN_PROGRESS
|
||||||
import com.stevesoltys.seedvault.restore.install.ApkInstallState.QUEUED
|
import com.stevesoltys.seedvault.restore.install.ApkInstallState.QUEUED
|
||||||
import com.stevesoltys.seedvault.restore.install.ApkInstallState.SUCCEEDED
|
import com.stevesoltys.seedvault.restore.install.ApkInstallState.SUCCEEDED
|
||||||
import com.stevesoltys.seedvault.transport.TransportTest
|
import com.stevesoltys.seedvault.transport.TransportTest
|
||||||
import com.stevesoltys.seedvault.worker.getSignatures
|
import com.stevesoltys.seedvault.transport.backup.BackupData
|
||||||
|
import com.stevesoltys.seedvault.transport.backup.hexFromProto
|
||||||
|
import com.stevesoltys.seedvault.transport.restore.Loader
|
||||||
|
import com.stevesoltys.seedvault.worker.BASE_SPLIT
|
||||||
import io.mockk.Runs
|
import io.mockk.Runs
|
||||||
import io.mockk.coEvery
|
import io.mockk.coEvery
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
|
@ -43,8 +50,9 @@ import io.mockk.mockkStatic
|
||||||
import io.mockk.verifyOrder
|
import io.mockk.verifyOrder
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.calyxos.seedvault.core.backends.AppBackupFileType
|
||||||
import org.calyxos.seedvault.core.backends.Backend
|
import org.calyxos.seedvault.core.backends.Backend
|
||||||
import org.calyxos.seedvault.core.backends.LegacyAppBackupFile
|
import org.calyxos.seedvault.core.toHexString
|
||||||
import org.junit.jupiter.api.Assertions
|
import org.junit.jupiter.api.Assertions
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
import org.junit.jupiter.api.Assertions.assertFalse
|
import org.junit.jupiter.api.Assertions.assertFalse
|
||||||
|
@ -68,6 +76,7 @@ internal class ApkRestoreTest : TransportTest() {
|
||||||
private val backupManager: IBackupManager = mockk()
|
private val backupManager: IBackupManager = mockk()
|
||||||
private val backupStateManager: BackupStateManager = mockk()
|
private val backupStateManager: BackupStateManager = mockk()
|
||||||
private val backendManager: BackendManager = mockk()
|
private val backendManager: BackendManager = mockk()
|
||||||
|
private val loader: Loader = mockk()
|
||||||
private val backend: Backend = mockk()
|
private val backend: Backend = mockk()
|
||||||
private val legacyStoragePlugin: LegacyStoragePlugin = mockk()
|
private val legacyStoragePlugin: LegacyStoragePlugin = mockk()
|
||||||
private val splitCompatChecker: ApkSplitCompatibilityChecker = mockk()
|
private val splitCompatChecker: ApkSplitCompatibilityChecker = mockk()
|
||||||
|
@ -79,6 +88,7 @@ internal class ApkRestoreTest : TransportTest() {
|
||||||
backupManager = backupManager,
|
backupManager = backupManager,
|
||||||
backupStateManager = backupStateManager,
|
backupStateManager = backupStateManager,
|
||||||
backendManager = backendManager,
|
backendManager = backendManager,
|
||||||
|
loader = loader,
|
||||||
legacyStoragePlugin = legacyStoragePlugin,
|
legacyStoragePlugin = legacyStoragePlugin,
|
||||||
crypto = crypto,
|
crypto = crypto,
|
||||||
splitCompatChecker = splitCompatChecker,
|
splitCompatChecker = splitCompatChecker,
|
||||||
|
@ -90,20 +100,39 @@ internal class ApkRestoreTest : TransportTest() {
|
||||||
|
|
||||||
private val deviceName = metadata.deviceName
|
private val deviceName = metadata.deviceName
|
||||||
private val packageName = packageInfo.packageName
|
private val packageName = packageInfo.packageName
|
||||||
private val packageMetadata = PackageMetadata(
|
|
||||||
time = Random.nextLong(),
|
|
||||||
version = packageInfo.longVersionCode - 1,
|
|
||||||
installer = getRandomString(),
|
|
||||||
sha256 = "eHx5jjmlvBkQNVuubQzYejay4Q_QICqD47trAF2oNHI",
|
|
||||||
signatures = listOf("AwIB")
|
|
||||||
)
|
|
||||||
private val packageMetadataMap: PackageMetadataMap = hashMapOf(packageName to packageMetadata)
|
|
||||||
private val apkBytes = byteArrayOf(0x04, 0x05, 0x06)
|
private val apkBytes = byteArrayOf(0x04, 0x05, 0x06)
|
||||||
private val apkInputStream = ByteArrayInputStream(apkBytes)
|
private val apkInputStream = ByteArrayInputStream(apkBytes)
|
||||||
private val appName = getRandomString()
|
private val appName = getRandomString()
|
||||||
|
private val repoId = Random.nextBytes(32).toHexString()
|
||||||
|
private val apkChunkId = Random.nextBytes(32).toHexString()
|
||||||
|
private val apkBlob =
|
||||||
|
Snapshot.Blob.newBuilder().setId(ByteString.copyFrom(Random.nextBytes(32))).build()
|
||||||
|
private val apkBlobHandle = AppBackupFileType.Blob(repoId, apkBlob.id.hexFromProto())
|
||||||
|
private val apkBackupData = BackupData(listOf(apkChunkId), mapOf(apkChunkId to apkBlob))
|
||||||
|
private val baseSplit = Snapshot.Split.newBuilder().setName(BASE_SPLIT)
|
||||||
|
.addAllChunkIds(listOf(fromHex(apkChunkId))).build()
|
||||||
|
private val apk = Snapshot.Apk.newBuilder()
|
||||||
|
.setVersionCode(packageInfo.longVersionCode - 1)
|
||||||
|
.setInstaller(getRandomString())
|
||||||
|
.addAllSignatures(mutableListOf(copyFromUtf8("AwIB".decodeBase64())))
|
||||||
|
.addSplits(baseSplit)
|
||||||
|
.build()
|
||||||
|
private val app = Snapshot.App.newBuilder()
|
||||||
|
.setApk(apk)
|
||||||
|
.build()
|
||||||
|
private val snapshot = Snapshot.newBuilder()
|
||||||
|
.setToken(token)
|
||||||
|
.putApps(packageName, app)
|
||||||
|
.putAllBlobs(apkBackupData.chunkMap)
|
||||||
|
.build()
|
||||||
|
private val packageMetadata = PackageMetadata.fromSnapshot(app)
|
||||||
|
private val packageMetadataMap: PackageMetadataMap = hashMapOf(packageName to packageMetadata)
|
||||||
private val installerName = packageMetadata.installer
|
private val installerName = packageMetadata.installer
|
||||||
private val backup = RestorableBackup(metadata.copy(packageMetadataMap = packageMetadataMap))
|
private val backup = RestorableBackup(
|
||||||
private val suffixName = getRandomString()
|
repoId = repoId,
|
||||||
|
snapshot = snapshot,
|
||||||
|
backupMetadata = metadata.copy(packageMetadataMap = packageMetadataMap),
|
||||||
|
)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
// as we don't do strict signature checking, we can use a relaxed mock
|
// as we don't do strict signature checking, we can use a relaxed mock
|
||||||
|
@ -119,26 +148,6 @@ internal class ApkRestoreTest : TransportTest() {
|
||||||
every { strictContext.stopService(any()) } returns true
|
every { strictContext.stopService(any()) } returns true
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `signature mismatch causes FAILED status`(@TempDir tmpDir: Path) = runBlocking {
|
|
||||||
// change SHA256 signature to random
|
|
||||||
val packageMetadata = packageMetadata.copy(sha256 = getRandomString())
|
|
||||||
val backup = swapPackages(hashMapOf(packageName to packageMetadata))
|
|
||||||
|
|
||||||
every { installRestriction.isAllowedToInstallApks() } returns true
|
|
||||||
every { backupStateManager.isAutoRestoreEnabled } returns false
|
|
||||||
every { strictContext.cacheDir } returns File(tmpDir.toString())
|
|
||||||
every { crypto.getNameForApk(salt, packageName, "") } returns name
|
|
||||||
coEvery { backend.load(handle) } returns apkInputStream
|
|
||||||
every { backend.providerPackageName } returns storageProviderPackageName
|
|
||||||
|
|
||||||
apkRestore.installResult.test {
|
|
||||||
awaitItem() // initial empty state
|
|
||||||
apkRestore.restore(backup)
|
|
||||||
assertQueuedFailFinished()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `test app without APK does not attempt install`(@TempDir tmpDir: Path) = runBlocking {
|
fun `test app without APK does not attempt install`(@TempDir tmpDir: Path) = runBlocking {
|
||||||
// remove all APK info
|
// remove all APK info
|
||||||
|
@ -201,9 +210,9 @@ internal class ApkRestoreTest : TransportTest() {
|
||||||
|
|
||||||
every { installRestriction.isAllowedToInstallApks() } returns true
|
every { installRestriction.isAllowedToInstallApks() } returns true
|
||||||
every { backupStateManager.isAutoRestoreEnabled } returns false
|
every { backupStateManager.isAutoRestoreEnabled } returns false
|
||||||
|
every { pm.getPackageInfo(packageName, any<Int>()) } throws NameNotFoundException()
|
||||||
every { strictContext.cacheDir } returns File(tmpDir.toString())
|
every { strictContext.cacheDir } returns File(tmpDir.toString())
|
||||||
every { crypto.getNameForApk(salt, packageName, "") } returns name
|
coEvery { loader.loadFiles(listOf(apkBlobHandle)) } returns apkInputStream
|
||||||
coEvery { backend.load(handle) } returns apkInputStream
|
|
||||||
every { pm.getPackageArchiveInfo(any(), any<Int>()) } returns packageInfo
|
every { pm.getPackageArchiveInfo(any(), any<Int>()) } returns packageInfo
|
||||||
every { backend.providerPackageName } returns storageProviderPackageName
|
every { backend.providerPackageName } returns storageProviderPackageName
|
||||||
|
|
||||||
|
@ -259,46 +268,10 @@ internal class ApkRestoreTest : TransportTest() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `v0 test successful run`(@TempDir tmpDir: Path) = runBlocking {
|
|
||||||
// This is a legacy backup with version 0
|
|
||||||
val backup = backup.copy(backupMetadata = backup.backupMetadata.copy(version = 0))
|
|
||||||
// Install will be successful
|
|
||||||
val packagesMap = mapOf(
|
|
||||||
packageName to ApkInstallResult(
|
|
||||||
packageName,
|
|
||||||
state = SUCCEEDED,
|
|
||||||
metadata = PackageMetadata(),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
val installResult = InstallResult(packagesMap)
|
|
||||||
|
|
||||||
every { installRestriction.isAllowedToInstallApks() } returns true
|
|
||||||
every { backupStateManager.isAutoRestoreEnabled } returns false
|
|
||||||
every { pm.getPackageInfo(packageName, any<Int>()) } throws NameNotFoundException()
|
|
||||||
every { strictContext.cacheDir } returns File(tmpDir.toString())
|
|
||||||
coEvery {
|
|
||||||
legacyStoragePlugin.getApkInputStream(token, packageName, "")
|
|
||||||
} returns apkInputStream
|
|
||||||
every { pm.getPackageArchiveInfo(any(), any<Int>()) } returns packageInfo
|
|
||||||
every { applicationInfo.loadIcon(pm) } returns icon
|
|
||||||
every { pm.getApplicationLabel(packageInfo.applicationInfo!!) } returns appName
|
|
||||||
coEvery {
|
|
||||||
apkInstaller.install(match { it.size == 1 }, packageName, installerName, any())
|
|
||||||
} returns installResult
|
|
||||||
every { backend.providerPackageName } returns storageProviderPackageName
|
|
||||||
|
|
||||||
apkRestore.installResult.test {
|
|
||||||
awaitItem() // initial empty state
|
|
||||||
apkRestore.restore(backup)
|
|
||||||
assertQueuedProgressSuccessFinished()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `test app only installed not already installed`(@TempDir tmpDir: Path) = runBlocking {
|
fun `test app only installed not already installed`(@TempDir tmpDir: Path) = runBlocking {
|
||||||
val packageInfo: PackageInfo = mockk()
|
val packageInfo: PackageInfo = mockk()
|
||||||
mockkStatic("com.stevesoltys.seedvault.worker.ApkBackupKt")
|
mockkStatic("com.stevesoltys.seedvault.restore.install.ApkRestoreKt")
|
||||||
every { installRestriction.isAllowedToInstallApks() } returns true
|
every { installRestriction.isAllowedToInstallApks() } returns true
|
||||||
every { backupStateManager.isAutoRestoreEnabled } returns false
|
every { backupStateManager.isAutoRestoreEnabled } returns false
|
||||||
every { backend.providerPackageName } returns storageProviderPackageName
|
every { backend.providerPackageName } returns storageProviderPackageName
|
||||||
|
@ -327,7 +300,7 @@ internal class ApkRestoreTest : TransportTest() {
|
||||||
fun `test app still installed if older version is installed`(@TempDir tmpDir: Path) =
|
fun `test app still installed if older version is installed`(@TempDir tmpDir: Path) =
|
||||||
runBlocking {
|
runBlocking {
|
||||||
val packageInfo: PackageInfo = mockk()
|
val packageInfo: PackageInfo = mockk()
|
||||||
mockkStatic("com.stevesoltys.seedvault.worker.ApkBackupKt")
|
mockkStatic("com.stevesoltys.seedvault.restore.install.ApkRestoreKt")
|
||||||
every { installRestriction.isAllowedToInstallApks() } returns true
|
every { installRestriction.isAllowedToInstallApks() } returns true
|
||||||
every { backupStateManager.isAutoRestoreEnabled } returns false
|
every { backupStateManager.isAutoRestoreEnabled } returns false
|
||||||
every { backend.providerPackageName } returns storageProviderPackageName
|
every { backend.providerPackageName } returns storageProviderPackageName
|
||||||
|
@ -367,7 +340,7 @@ internal class ApkRestoreTest : TransportTest() {
|
||||||
@Test
|
@Test
|
||||||
fun `test app fails if installed with different signer`(@TempDir tmpDir: Path) = runBlocking {
|
fun `test app fails if installed with different signer`(@TempDir tmpDir: Path) = runBlocking {
|
||||||
val packageInfo: PackageInfo = mockk()
|
val packageInfo: PackageInfo = mockk()
|
||||||
mockkStatic("com.stevesoltys.seedvault.worker.ApkBackupKt")
|
mockkStatic("com.stevesoltys.seedvault.restore.install.ApkRestoreKt")
|
||||||
every { installRestriction.isAllowedToInstallApks() } returns true
|
every { installRestriction.isAllowedToInstallApks() } returns true
|
||||||
every { backupStateManager.isAutoRestoreEnabled } returns false
|
every { backupStateManager.isAutoRestoreEnabled } returns false
|
||||||
every { backend.providerPackageName } returns storageProviderPackageName
|
every { backend.providerPackageName } returns storageProviderPackageName
|
||||||
|
@ -486,56 +459,32 @@ internal class ApkRestoreTest : TransportTest() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `split signature mismatch causes FAILED state`(@TempDir tmpDir: Path) = runBlocking {
|
|
||||||
// add one APK split to metadata
|
|
||||||
val splitName = getRandomString()
|
|
||||||
packageMetadataMap[packageName] = packageMetadataMap[packageName]!!.copy(
|
|
||||||
splits = listOf(ApkSplit(splitName, Random.nextLong(), getRandomBase64(23)))
|
|
||||||
)
|
|
||||||
|
|
||||||
every { installRestriction.isAllowedToInstallApks() } returns true
|
|
||||||
every { backupStateManager.isAutoRestoreEnabled } returns false
|
|
||||||
every { pm.getPackageInfo(packageName, any<Int>()) } throws NameNotFoundException()
|
|
||||||
// cache APK and get icon as well as app name
|
|
||||||
cacheBaseApkAndGetInfo(tmpDir)
|
|
||||||
|
|
||||||
every { splitCompatChecker.isCompatible(deviceName, listOf(splitName)) } returns true
|
|
||||||
every { crypto.getNameForApk(salt, packageName, splitName) } returns suffixName
|
|
||||||
coEvery {
|
|
||||||
backend.load(LegacyAppBackupFile.Blob(token, suffixName))
|
|
||||||
} returns ByteArrayInputStream(getRandomByteArray())
|
|
||||||
every { backend.providerPackageName } returns storageProviderPackageName
|
|
||||||
|
|
||||||
apkRestore.installResult.test {
|
|
||||||
awaitItem() // initial empty state
|
|
||||||
apkRestore.restore(backup)
|
|
||||||
assertQueuedProgressFailFinished()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `exception while getting split data causes FAILED state`(@TempDir tmpDir: Path) =
|
fun `exception while getting split data causes FAILED state`(@TempDir tmpDir: Path) =
|
||||||
runBlocking {
|
runBlocking {
|
||||||
// add one APK split to metadata
|
// add one APK split to metadata
|
||||||
val splitName = getRandomString()
|
val splitName = getRandomString()
|
||||||
val sha256 = getRandomBase64(23)
|
val sha256 = getRandomBase64(23)
|
||||||
|
val splitChunkId = Random.nextBytes(32).toHexString()
|
||||||
|
val splitBlobId = Random.nextBytes(32).toHexString()
|
||||||
|
val split = ApkSplit(splitName, Random.nextLong(), sha256, listOf(splitChunkId))
|
||||||
packageMetadataMap[packageName] = packageMetadataMap[packageName]!!.copy(
|
packageMetadataMap[packageName] = packageMetadataMap[packageName]!!.copy(
|
||||||
splits = listOf(ApkSplit(splitName, Random.nextLong(), sha256))
|
splits = listOf(split)
|
||||||
)
|
)
|
||||||
|
val blobHandle = AppBackupFileType.Blob(repoId, splitBlobId)
|
||||||
|
val splitBlob = Snapshot.Blob.newBuilder().setId(fromHex(splitBlobId)).build()
|
||||||
|
val snapshot = snapshot.toBuilder().putBlobs(splitChunkId, splitBlob).build()
|
||||||
|
val backup = backup.copy(snapshot = snapshot)
|
||||||
|
|
||||||
every { installRestriction.isAllowedToInstallApks() } returns true
|
every { installRestriction.isAllowedToInstallApks() } returns true
|
||||||
every { backupStateManager.isAutoRestoreEnabled } returns false
|
every { backupStateManager.isAutoRestoreEnabled } returns false
|
||||||
|
every { backend.providerPackageName } returns storageProviderPackageName
|
||||||
every { pm.getPackageInfo(packageName, any<Int>()) } throws NameNotFoundException()
|
every { pm.getPackageInfo(packageName, any<Int>()) } throws NameNotFoundException()
|
||||||
// cache APK and get icon as well as app name
|
// cache APK and get icon as well as app name
|
||||||
cacheBaseApkAndGetInfo(tmpDir)
|
cacheBaseApkAndGetInfo(tmpDir)
|
||||||
|
|
||||||
every { splitCompatChecker.isCompatible(deviceName, listOf(splitName)) } returns true
|
every { splitCompatChecker.isCompatible(deviceName, listOf(splitName)) } returns true
|
||||||
every { crypto.getNameForApk(salt, packageName, splitName) } returns suffixName
|
coEvery { loader.loadFiles(listOf(blobHandle)) } throws IOException()
|
||||||
coEvery {
|
|
||||||
backend.load(LegacyAppBackupFile.Blob(token, suffixName))
|
|
||||||
} throws IOException()
|
|
||||||
every { backend.providerPackageName } returns storageProviderPackageName
|
|
||||||
|
|
||||||
apkRestore.installResult.test {
|
apkRestore.installResult.test {
|
||||||
awaitItem() // initial empty state
|
awaitItem() // initial empty state
|
||||||
|
@ -549,17 +498,31 @@ internal class ApkRestoreTest : TransportTest() {
|
||||||
// add one APK split to metadata
|
// add one APK split to metadata
|
||||||
val split1Name = getRandomString()
|
val split1Name = getRandomString()
|
||||||
val split2Name = getRandomString()
|
val split2Name = getRandomString()
|
||||||
val split1sha256 = "A5BYxvLAy0ksUzsKTRTvd8wPeKvMztUofYShogEc-4E"
|
val splitChunkId1 = Random.nextBytes(32).toHexString()
|
||||||
val split2sha256 = "ZqZ1cVH47lXbEncWx-Pc4L6AdLZOIO2lQuXB5GypxB4"
|
val splitChunkId2 = Random.nextBytes(32).toHexString()
|
||||||
packageMetadataMap[packageName] = packageMetadataMap[packageName]!!.copy(
|
val apkSplit1 = Snapshot.Split.newBuilder().setName(split1Name)
|
||||||
splits = listOf(
|
.addAllChunkIds(listOf(fromHex(splitChunkId1))).build()
|
||||||
ApkSplit(split1Name, Random.nextLong(), split1sha256),
|
val apkSplit2 = Snapshot.Split.newBuilder().setName(split2Name)
|
||||||
ApkSplit(split2Name, Random.nextLong(), split2sha256)
|
.addAllChunkIds(listOf(fromHex(splitChunkId2))).build()
|
||||||
)
|
val splitBlob1 =
|
||||||
)
|
Snapshot.Blob.newBuilder().setId(ByteString.copyFrom(Random.nextBytes(32))).build()
|
||||||
|
val splitBlob2 =
|
||||||
|
Snapshot.Blob.newBuilder().setId(ByteString.copyFrom(Random.nextBytes(32))).build()
|
||||||
|
val apk = apk.toBuilder().addSplits(apkSplit1).addSplits(apkSplit2).build()
|
||||||
|
val app = app.toBuilder().setApk(apk).build()
|
||||||
|
val blobMap = apkBackupData.chunkMap +
|
||||||
|
mapOf(splitChunkId1 to splitBlob1) +
|
||||||
|
mapOf(splitChunkId2 to splitBlob2)
|
||||||
|
val snapshot = snapshot.toBuilder()
|
||||||
|
.putApps(packageName, app)
|
||||||
|
.putAllBlobs(blobMap)
|
||||||
|
.build()
|
||||||
|
packageMetadataMap[packageName] = PackageMetadata.fromSnapshot(app)
|
||||||
|
val backup = backup.copy(snapshot = snapshot)
|
||||||
|
|
||||||
every { installRestriction.isAllowedToInstallApks() } returns true
|
every { installRestriction.isAllowedToInstallApks() } returns true
|
||||||
every { backupStateManager.isAutoRestoreEnabled } returns false
|
every { backupStateManager.isAutoRestoreEnabled } returns false
|
||||||
|
every { backend.providerPackageName } returns storageProviderPackageName
|
||||||
every { pm.getPackageInfo(packageName, any<Int>()) } throws NameNotFoundException()
|
every { pm.getPackageInfo(packageName, any<Int>()) } throws NameNotFoundException()
|
||||||
// cache APK and get icon as well as app name
|
// cache APK and get icon as well as app name
|
||||||
cacheBaseApkAndGetInfo(tmpDir)
|
cacheBaseApkAndGetInfo(tmpDir)
|
||||||
|
@ -573,17 +536,10 @@ internal class ApkRestoreTest : TransportTest() {
|
||||||
val split2Bytes = byteArrayOf(0x07, 0x08, 0x09)
|
val split2Bytes = byteArrayOf(0x07, 0x08, 0x09)
|
||||||
val split1InputStream = ByteArrayInputStream(split1Bytes)
|
val split1InputStream = ByteArrayInputStream(split1Bytes)
|
||||||
val split2InputStream = ByteArrayInputStream(split2Bytes)
|
val split2InputStream = ByteArrayInputStream(split2Bytes)
|
||||||
val suffixName1 = getRandomString()
|
val splitHandle1 = AppBackupFileType.Blob(repoId, splitBlob1.id.hexFromProto())
|
||||||
val suffixName2 = getRandomString()
|
val splitHandle2 = AppBackupFileType.Blob(repoId, splitBlob2.id.hexFromProto())
|
||||||
every { crypto.getNameForApk(salt, packageName, split1Name) } returns suffixName1
|
coEvery { loader.loadFiles(listOf(splitHandle1)) } returns split1InputStream
|
||||||
coEvery {
|
coEvery { loader.loadFiles(listOf(splitHandle2)) } returns split2InputStream
|
||||||
backend.load(LegacyAppBackupFile.Blob(token, suffixName1))
|
|
||||||
} returns split1InputStream
|
|
||||||
every { crypto.getNameForApk(salt, packageName, split2Name) } returns suffixName2
|
|
||||||
coEvery {
|
|
||||||
backend.load(LegacyAppBackupFile.Blob(token, suffixName2))
|
|
||||||
} returns split2InputStream
|
|
||||||
every { backend.providerPackageName } returns storageProviderPackageName
|
|
||||||
|
|
||||||
val resultMap = mapOf(
|
val resultMap = mapOf(
|
||||||
packageName to ApkInstallResult(
|
packageName to ApkInstallResult(
|
||||||
|
@ -709,8 +665,7 @@ internal class ApkRestoreTest : TransportTest() {
|
||||||
|
|
||||||
private fun cacheBaseApkAndGetInfo(tmpDir: Path) {
|
private fun cacheBaseApkAndGetInfo(tmpDir: Path) {
|
||||||
every { strictContext.cacheDir } returns File(tmpDir.toString())
|
every { strictContext.cacheDir } returns File(tmpDir.toString())
|
||||||
every { crypto.getNameForApk(salt, packageName, "") } returns name
|
coEvery { loader.loadFiles(listOf(apkBlobHandle)) } returns apkInputStream
|
||||||
coEvery { backend.load(handle) } returns apkInputStream
|
|
||||||
every { pm.getPackageArchiveInfo(any(), any<Int>()) } returns packageInfo
|
every { pm.getPackageArchiveInfo(any(), any<Int>()) } returns packageInfo
|
||||||
every { applicationInfo.loadIcon(pm) } returns icon
|
every { applicationInfo.loadIcon(pm) } returns icon
|
||||||
every { pm.getApplicationLabel(packageInfo.applicationInfo!!) } returns appName
|
every { pm.getApplicationLabel(packageInfo.applicationInfo!!) } returns appName
|
||||||
|
@ -718,6 +673,14 @@ internal class ApkRestoreTest : TransportTest() {
|
||||||
|
|
||||||
private suspend fun TurbineTestContext<InstallResult>.assertQueuedFailFinished() {
|
private suspend fun TurbineTestContext<InstallResult>.assertQueuedFailFinished() {
|
||||||
awaitQueuedItem()
|
awaitQueuedItem()
|
||||||
|
awaitItem().also { item ->
|
||||||
|
val result = item[packageName]
|
||||||
|
assertEquals(IN_PROGRESS, result.state)
|
||||||
|
assertFalse(item.hasFailed)
|
||||||
|
assertEquals(1, item.total)
|
||||||
|
assertEquals(1, item.list.size)
|
||||||
|
assertNull(result.icon)
|
||||||
|
}
|
||||||
awaitItem().also { failedItem ->
|
awaitItem().also { failedItem ->
|
||||||
val result = failedItem[packageName]
|
val result = failedItem[packageName]
|
||||||
assertEquals(FAILED, result.state)
|
assertEquals(FAILED, result.state)
|
||||||
|
@ -796,6 +759,6 @@ internal class ApkRestoreTest : TransportTest() {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private operator fun InstallResult.get(packageName: String): ApkInstallResult {
|
internal operator fun InstallResult.get(packageName: String): ApkInstallResult {
|
||||||
return this.installResults[packageName] ?: Assertions.fail("$packageName not found")
|
return this.installResults[packageName] ?: Assertions.fail("$packageName not found")
|
||||||
}
|
}
|
||||||
|
|
|
@ -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.copyInto(value2.captured) // write the app data into the passed ByteArray
|
||||||
appData2.size
|
appData2.size
|
||||||
}
|
}
|
||||||
coEvery {
|
coEvery { apkBackup.backupApkIfNecessary(packageInfo) } just Runs
|
||||||
apkBackup.backupApkIfNecessary(packageInfo, any())
|
|
||||||
} returns packageMetadata
|
|
||||||
coEvery {
|
coEvery {
|
||||||
backend.save(LegacyAppBackupFile.Metadata(token))
|
backend.save(LegacyAppBackupFile.Metadata(token))
|
||||||
} returns metadataOutputStream
|
} returns metadataOutputStream
|
||||||
|
@ -238,7 +236,7 @@ internal class CoordinatorIntegrationTest : TransportTest() {
|
||||||
appData.copyInto(value.captured) // write the app data into the passed ByteArray
|
appData.copyInto(value.captured) // write the app data into the passed ByteArray
|
||||||
appData.size
|
appData.size
|
||||||
}
|
}
|
||||||
coEvery { apkBackup.backupApkIfNecessary(packageInfo, any()) } returns null
|
coEvery { apkBackup.backupApkIfNecessary(packageInfo) } just Runs
|
||||||
every { settingsManager.getToken() } returns token
|
every { settingsManager.getToken() } returns token
|
||||||
coEvery {
|
coEvery {
|
||||||
backend.save(LegacyAppBackupFile.Metadata(token))
|
backend.save(LegacyAppBackupFile.Metadata(token))
|
||||||
|
@ -307,7 +305,7 @@ internal class CoordinatorIntegrationTest : TransportTest() {
|
||||||
} returns bOutputStream
|
} returns bOutputStream
|
||||||
every { inputFactory.getInputStream(fileDescriptor) } returns bInputStream
|
every { inputFactory.getInputStream(fileDescriptor) } returns bInputStream
|
||||||
every { settingsManager.isQuotaUnlimited() } returns false
|
every { settingsManager.isQuotaUnlimited() } returns false
|
||||||
coEvery { apkBackup.backupApkIfNecessary(packageInfo, any()) } returns packageMetadata
|
coEvery { apkBackup.backupApkIfNecessary(packageInfo) } just Runs
|
||||||
every { settingsManager.getToken() } returns token
|
every { settingsManager.getToken() } returns token
|
||||||
every { metadataManager.salt } returns salt
|
every { metadataManager.salt } returns salt
|
||||||
coEvery {
|
coEvery {
|
||||||
|
|
|
@ -14,13 +14,13 @@ import android.content.pm.PackageInfo
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.ParcelFileDescriptor
|
import android.os.ParcelFileDescriptor
|
||||||
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
|
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
|
||||||
|
import com.stevesoltys.seedvault.backend.BackendManager
|
||||||
import com.stevesoltys.seedvault.coAssertThrows
|
import com.stevesoltys.seedvault.coAssertThrows
|
||||||
import com.stevesoltys.seedvault.getRandomString
|
import com.stevesoltys.seedvault.getRandomString
|
||||||
import com.stevesoltys.seedvault.metadata.BackupType
|
import com.stevesoltys.seedvault.metadata.BackupType
|
||||||
import com.stevesoltys.seedvault.metadata.PackageMetadata
|
import com.stevesoltys.seedvault.metadata.PackageMetadata
|
||||||
import com.stevesoltys.seedvault.metadata.PackageState.NO_DATA
|
import com.stevesoltys.seedvault.metadata.PackageState.NO_DATA
|
||||||
import com.stevesoltys.seedvault.metadata.PackageState.QUOTA_EXCEEDED
|
import com.stevesoltys.seedvault.metadata.PackageState.QUOTA_EXCEEDED
|
||||||
import com.stevesoltys.seedvault.backend.BackendManager
|
|
||||||
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
|
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
|
||||||
import com.stevesoltys.seedvault.worker.ApkBackup
|
import com.stevesoltys.seedvault.worker.ApkBackup
|
||||||
import io.mockk.Runs
|
import io.mockk.Runs
|
||||||
|
@ -273,7 +273,7 @@ internal class BackupCoordinatorTest : BackupTest() {
|
||||||
coEvery {
|
coEvery {
|
||||||
full.performFullBackup(packageInfo, fileDescriptor, 0, token, salt)
|
full.performFullBackup(packageInfo, fileDescriptor, 0, token, salt)
|
||||||
} returns TRANSPORT_OK
|
} returns TRANSPORT_OK
|
||||||
coEvery { apkBackup.backupApkIfNecessary(packageInfo, any()) } returns null
|
coEvery { apkBackup.backupApkIfNecessary(packageInfo) } just Runs
|
||||||
|
|
||||||
assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, fileDescriptor, 0))
|
assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, fileDescriptor, 0))
|
||||||
}
|
}
|
||||||
|
@ -382,7 +382,7 @@ internal class BackupCoordinatorTest : BackupTest() {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun expectApkBackupAndMetadataWrite() {
|
private fun expectApkBackupAndMetadataWrite() {
|
||||||
coEvery { apkBackup.backupApkIfNecessary(any(), any()) } returns packageMetadata
|
coEvery { apkBackup.backupApkIfNecessary(packageInfo) } just Runs
|
||||||
every { settingsManager.getToken() } returns token
|
every { settingsManager.getToken() } returns token
|
||||||
coEvery { backend.save(LegacyAppBackupFile.Metadata(token)) } returns metadataOutputStream
|
coEvery { backend.save(LegacyAppBackupFile.Metadata(token)) } returns metadataOutputStream
|
||||||
every { metadataManager.onApkBackedUp(any(), packageMetadata) } just Runs
|
every { metadataManager.onApkBackedUp(any(), packageMetadata) } just Runs
|
||||||
|
|
|
@ -10,16 +10,15 @@ import android.content.pm.ApplicationInfo.FLAG_ALLOW_BACKUP
|
||||||
import android.content.pm.ApplicationInfo.FLAG_INSTALLED
|
import android.content.pm.ApplicationInfo.FLAG_INSTALLED
|
||||||
import android.content.pm.ApplicationInfo.FLAG_STOPPED
|
import android.content.pm.ApplicationInfo.FLAG_STOPPED
|
||||||
import android.content.pm.PackageInfo
|
import android.content.pm.PackageInfo
|
||||||
|
import com.stevesoltys.seedvault.backend.BackendManager
|
||||||
import com.stevesoltys.seedvault.metadata.PackageMetadata
|
import com.stevesoltys.seedvault.metadata.PackageMetadata
|
||||||
import com.stevesoltys.seedvault.metadata.PackageState.NOT_ALLOWED
|
import com.stevesoltys.seedvault.metadata.PackageState.NOT_ALLOWED
|
||||||
import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR
|
import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR
|
||||||
import com.stevesoltys.seedvault.metadata.PackageState.WAS_STOPPED
|
import com.stevesoltys.seedvault.metadata.PackageState.WAS_STOPPED
|
||||||
import com.stevesoltys.seedvault.backend.BackendManager
|
|
||||||
import com.stevesoltys.seedvault.transport.TransportTest
|
import com.stevesoltys.seedvault.transport.TransportTest
|
||||||
import com.stevesoltys.seedvault.transport.backup.PackageService
|
import com.stevesoltys.seedvault.transport.backup.PackageService
|
||||||
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
|
import com.stevesoltys.seedvault.ui.notification.BackupNotificationManager
|
||||||
import io.mockk.Runs
|
import io.mockk.Runs
|
||||||
import io.mockk.andThenJust
|
|
||||||
import io.mockk.coEvery
|
import io.mockk.coEvery
|
||||||
import io.mockk.coVerify
|
import io.mockk.coVerify
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
|
@ -29,11 +28,7 @@ import io.mockk.verify
|
||||||
import io.mockk.verifyAll
|
import io.mockk.verifyAll
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import org.calyxos.seedvault.core.backends.Backend
|
import org.calyxos.seedvault.core.backends.Backend
|
||||||
import org.calyxos.seedvault.core.backends.LegacyAppBackupFile
|
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
import java.io.ByteArrayOutputStream
|
|
||||||
import java.io.IOException
|
|
||||||
import java.io.OutputStream
|
|
||||||
|
|
||||||
internal class ApkBackupManagerTest : TransportTest() {
|
internal class ApkBackupManagerTest : TransportTest() {
|
||||||
|
|
||||||
|
@ -49,13 +44,11 @@ internal class ApkBackupManagerTest : TransportTest() {
|
||||||
settingsManager = settingsManager,
|
settingsManager = settingsManager,
|
||||||
metadataManager = metadataManager,
|
metadataManager = metadataManager,
|
||||||
packageService = packageService,
|
packageService = packageService,
|
||||||
apkBackup = apkBackup,
|
|
||||||
iconManager = iconManager,
|
iconManager = iconManager,
|
||||||
backendManager = backendManager,
|
apkBackup = apkBackup,
|
||||||
nm = nm,
|
nm = nm,
|
||||||
)
|
)
|
||||||
|
|
||||||
private val metadataOutputStream = mockk<OutputStream>()
|
|
||||||
private val packageMetadata: PackageMetadata = mockk()
|
private val packageMetadata: PackageMetadata = mockk()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
|
@ -77,14 +70,12 @@ internal class ApkBackupManagerTest : TransportTest() {
|
||||||
every { metadataManager.onPackageDoesNotGetBackedUp(packageInfo, NOT_ALLOWED) } just Runs
|
every { metadataManager.onPackageDoesNotGetBackedUp(packageInfo, NOT_ALLOWED) } just Runs
|
||||||
|
|
||||||
every { settingsManager.backupApks() } returns false
|
every { settingsManager.backupApks() } returns false
|
||||||
expectFinalUpload()
|
|
||||||
every { nm.onApkBackupDone() } just Runs
|
every { nm.onApkBackupDone() } just Runs
|
||||||
|
|
||||||
apkBackupManager.backup()
|
apkBackupManager.backup()
|
||||||
|
|
||||||
verify {
|
verify {
|
||||||
metadataManager.onPackageDoesNotGetBackedUp(packageInfo, NOT_ALLOWED)
|
metadataManager.onPackageDoesNotGetBackedUp(packageInfo, NOT_ALLOWED)
|
||||||
metadataOutputStream.close()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -102,14 +93,12 @@ internal class ApkBackupManagerTest : TransportTest() {
|
||||||
every { metadataManager.onPackageDoesNotGetBackedUp(packageInfo, NOT_ALLOWED) } just Runs
|
every { metadataManager.onPackageDoesNotGetBackedUp(packageInfo, NOT_ALLOWED) } just Runs
|
||||||
|
|
||||||
every { settingsManager.backupApks() } returns false
|
every { settingsManager.backupApks() } returns false
|
||||||
expectFinalUpload()
|
|
||||||
every { nm.onApkBackupDone() } just Runs
|
every { nm.onApkBackupDone() } just Runs
|
||||||
|
|
||||||
apkBackupManager.backup()
|
apkBackupManager.backup()
|
||||||
|
|
||||||
verify {
|
verify {
|
||||||
metadataManager.onPackageDoesNotGetBackedUp(packageInfo, NOT_ALLOWED)
|
metadataManager.onPackageDoesNotGetBackedUp(packageInfo, NOT_ALLOWED)
|
||||||
metadataOutputStream.close()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -135,14 +124,12 @@ internal class ApkBackupManagerTest : TransportTest() {
|
||||||
every { metadataManager.onPackageDoesNotGetBackedUp(packageInfo, WAS_STOPPED) } just Runs
|
every { metadataManager.onPackageDoesNotGetBackedUp(packageInfo, WAS_STOPPED) } just Runs
|
||||||
|
|
||||||
every { settingsManager.backupApks() } returns false
|
every { settingsManager.backupApks() } returns false
|
||||||
expectFinalUpload()
|
|
||||||
every { nm.onApkBackupDone() } just Runs
|
every { nm.onApkBackupDone() } just Runs
|
||||||
|
|
||||||
apkBackupManager.backup()
|
apkBackupManager.backup()
|
||||||
|
|
||||||
verify {
|
verify {
|
||||||
metadataManager.onPackageDoesNotGetBackedUp(packageInfo, WAS_STOPPED)
|
metadataManager.onPackageDoesNotGetBackedUp(packageInfo, WAS_STOPPED)
|
||||||
metadataOutputStream.close()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -160,7 +147,6 @@ internal class ApkBackupManagerTest : TransportTest() {
|
||||||
every { packageMetadata.state } returns NOT_ALLOWED
|
every { packageMetadata.state } returns NOT_ALLOWED
|
||||||
|
|
||||||
every { settingsManager.backupApks() } returns false
|
every { settingsManager.backupApks() } returns false
|
||||||
expectFinalUpload()
|
|
||||||
every { nm.onApkBackupDone() } just Runs
|
every { nm.onApkBackupDone() } just Runs
|
||||||
|
|
||||||
apkBackupManager.backup()
|
apkBackupManager.backup()
|
||||||
|
@ -179,7 +165,6 @@ internal class ApkBackupManagerTest : TransportTest() {
|
||||||
expectUploadIcons()
|
expectUploadIcons()
|
||||||
|
|
||||||
every { settingsManager.backupApks() } returns false
|
every { settingsManager.backupApks() } returns false
|
||||||
expectFinalUpload()
|
|
||||||
every { nm.onApkBackupDone() } just Runs
|
every { nm.onApkBackupDone() } just Runs
|
||||||
|
|
||||||
apkBackupManager.backup()
|
apkBackupManager.backup()
|
||||||
|
@ -211,32 +196,22 @@ internal class ApkBackupManagerTest : TransportTest() {
|
||||||
nm.onApkBackup(notAllowedPackages[0].packageName, any(), 0, notAllowedPackages.size)
|
nm.onApkBackup(notAllowedPackages[0].packageName, any(), 0, notAllowedPackages.size)
|
||||||
} just Runs
|
} just Runs
|
||||||
// no backup needed
|
// no backup needed
|
||||||
coEvery {
|
coEvery { apkBackup.backupApkIfNecessary(notAllowedPackages[0]) } just Runs
|
||||||
apkBackup.backupApkIfNecessary(notAllowedPackages[0], any())
|
|
||||||
} returns null
|
|
||||||
// update notification for second package
|
// update notification for second package
|
||||||
every {
|
every {
|
||||||
nm.onApkBackup(notAllowedPackages[1].packageName, any(), 1, notAllowedPackages.size)
|
nm.onApkBackup(notAllowedPackages[1].packageName, any(), 1, notAllowedPackages.size)
|
||||||
} just Runs
|
} just Runs
|
||||||
// was backed up, get new packageMetadata
|
// was backed up, get new packageMetadata
|
||||||
coEvery {
|
coEvery { apkBackup.backupApkIfNecessary(notAllowedPackages[1]) } just Runs
|
||||||
apkBackup.backupApkIfNecessary(notAllowedPackages[1], any())
|
|
||||||
} returns packageMetadata
|
|
||||||
every { metadataManager.onApkBackedUp(notAllowedPackages[1], packageMetadata) } just Runs
|
every { metadataManager.onApkBackedUp(notAllowedPackages[1], packageMetadata) } just Runs
|
||||||
|
|
||||||
expectFinalUpload()
|
|
||||||
every { nm.onApkBackupDone() } just Runs
|
every { nm.onApkBackupDone() } just Runs
|
||||||
|
|
||||||
apkBackupManager.backup()
|
apkBackupManager.backup()
|
||||||
|
|
||||||
coVerify {
|
coVerify {
|
||||||
apkBackup.backupApkIfNecessary(notAllowedPackages[0], any())
|
apkBackup.backupApkIfNecessary(notAllowedPackages[0])
|
||||||
apkBackup.backupApkIfNecessary(notAllowedPackages[1], any())
|
apkBackup.backupApkIfNecessary(notAllowedPackages[1])
|
||||||
metadataOutputStream.close()
|
|
||||||
}
|
|
||||||
// metadata should only get uploaded once
|
|
||||||
verify(exactly = 1) {
|
|
||||||
metadataManager.uploadMetadata(metadataOutputStream)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -256,29 +231,17 @@ internal class ApkBackupManagerTest : TransportTest() {
|
||||||
|
|
||||||
every { settingsManager.backupApks() } returns false
|
every { settingsManager.backupApks() } returns false
|
||||||
|
|
||||||
// final upload
|
|
||||||
every { settingsManager.getToken() } returns token
|
|
||||||
coEvery { backend.save(LegacyAppBackupFile.Metadata(token)) } returns metadataOutputStream
|
|
||||||
every {
|
|
||||||
metadataManager.uploadMetadata(metadataOutputStream)
|
|
||||||
} throws IOException() andThenThrows SecurityException() andThenJust Runs
|
|
||||||
every { metadataOutputStream.close() } just Runs
|
|
||||||
|
|
||||||
every { nm.onApkBackupDone() } just Runs
|
every { nm.onApkBackupDone() } just Runs
|
||||||
|
|
||||||
apkBackupManager.backup()
|
apkBackupManager.backup()
|
||||||
|
|
||||||
verify {
|
verify {
|
||||||
metadataManager.onPackageDoesNotGetBackedUp(packageInfo, NOT_ALLOWED)
|
metadataManager.onPackageDoesNotGetBackedUp(packageInfo, NOT_ALLOWED)
|
||||||
metadataOutputStream.close()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun expectUploadIcons() {
|
private suspend fun expectUploadIcons() {
|
||||||
every { settingsManager.getToken() } returns token
|
coEvery { iconManager.uploadIcons() } just Runs
|
||||||
val stream = ByteArrayOutputStream()
|
|
||||||
coEvery { backend.save(LegacyAppBackupFile.IconsFile(token)) } returns stream
|
|
||||||
every { iconManager.uploadIcons(token, stream) } just Runs
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun expectAllAppsWillGetBackedUp() {
|
private fun expectAllAppsWillGetBackedUp() {
|
||||||
|
@ -286,11 +249,4 @@ internal class ApkBackupManagerTest : TransportTest() {
|
||||||
every { packageService.notBackedUpPackages } returns emptyList()
|
every { packageService.notBackedUpPackages } returns emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun expectFinalUpload() {
|
|
||||||
every { settingsManager.getToken() } returns token
|
|
||||||
coEvery { backend.save(LegacyAppBackupFile.Metadata(token)) } returns metadataOutputStream
|
|
||||||
every { metadataManager.uploadMetadata(metadataOutputStream) } just Runs
|
|
||||||
every { metadataOutputStream.close() } just Runs
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,20 +13,25 @@ import android.content.pm.PackageInfo
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.content.pm.Signature
|
import android.content.pm.Signature
|
||||||
import android.util.PackageUtils
|
import android.util.PackageUtils
|
||||||
|
import com.google.protobuf.ByteString
|
||||||
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
|
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
|
||||||
import com.stevesoltys.seedvault.getRandomString
|
import com.stevesoltys.seedvault.getRandomString
|
||||||
import com.stevesoltys.seedvault.metadata.ApkSplit
|
import com.stevesoltys.seedvault.proto.Snapshot
|
||||||
import com.stevesoltys.seedvault.metadata.PackageMetadata
|
import com.stevesoltys.seedvault.transport.SnapshotManager
|
||||||
import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR
|
import com.stevesoltys.seedvault.transport.backup.AppBackupManager
|
||||||
|
import com.stevesoltys.seedvault.transport.backup.BackupData
|
||||||
|
import com.stevesoltys.seedvault.transport.backup.BackupReceiver
|
||||||
import com.stevesoltys.seedvault.transport.backup.BackupTest
|
import com.stevesoltys.seedvault.transport.backup.BackupTest
|
||||||
|
import com.stevesoltys.seedvault.transport.backup.SnapshotCreator
|
||||||
|
import io.mockk.Runs
|
||||||
import io.mockk.coEvery
|
import io.mockk.coEvery
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
|
import io.mockk.just
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
import io.mockk.mockkStatic
|
import io.mockk.mockkStatic
|
||||||
|
import io.mockk.slot
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import org.junit.jupiter.api.Assertions.assertArrayEquals
|
import org.junit.jupiter.api.Assertions.assertArrayEquals
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
|
||||||
import org.junit.jupiter.api.Assertions.assertNull
|
|
||||||
import org.junit.jupiter.api.Assertions.assertThrows
|
import org.junit.jupiter.api.Assertions.assertThrows
|
||||||
import org.junit.jupiter.api.Assertions.assertTrue
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
|
@ -34,34 +39,41 @@ import org.junit.jupiter.api.io.TempDir
|
||||||
import java.io.ByteArrayOutputStream
|
import java.io.ByteArrayOutputStream
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.io.OutputStream
|
import java.io.InputStream
|
||||||
import java.nio.file.Path
|
import java.nio.file.Path
|
||||||
import kotlin.random.Random
|
|
||||||
|
|
||||||
internal class ApkBackupTest : BackupTest() {
|
internal class ApkBackupTest : BackupTest() {
|
||||||
|
|
||||||
private val pm: PackageManager = mockk()
|
private val pm: PackageManager = mockk()
|
||||||
private val streamGetter: suspend (name: String) -> OutputStream = mockk()
|
private val backupReceiver: BackupReceiver = mockk()
|
||||||
|
private val appBackupManager: AppBackupManager = mockk()
|
||||||
|
private val snapshotManager: SnapshotManager = mockk()
|
||||||
|
private val snapshotCreator: SnapshotCreator = mockk()
|
||||||
|
|
||||||
private val apkBackup = ApkBackup(pm, crypto, settingsManager, metadataManager)
|
private val apkBackup =
|
||||||
|
ApkBackup(pm, backupReceiver, appBackupManager, snapshotManager, settingsManager)
|
||||||
|
|
||||||
private val signatureBytes = byteArrayOf(0x01, 0x02, 0x03)
|
private val signatureBytes = byteArrayOf(0x01, 0x02, 0x03)
|
||||||
private val signatureHash = byteArrayOf(0x03, 0x02, 0x01)
|
private val signatureHash = byteArrayOf(0x03, 0x02, 0x01)
|
||||||
private val sigs = arrayOf(Signature(signatureBytes))
|
private val sigs = arrayOf(Signature(signatureBytes))
|
||||||
private val packageMetadata = PackageMetadata(
|
private val apk = Snapshot.Apk.newBuilder()
|
||||||
time = Random.nextLong(),
|
.setVersionCode(packageInfo.longVersionCode - 1)
|
||||||
version = packageInfo.longVersionCode - 1,
|
.addSignatures(ByteString.copyFrom(signatureHash))
|
||||||
signatures = listOf("AwIB")
|
.build()
|
||||||
)
|
private val snapshot = Snapshot.newBuilder()
|
||||||
|
.setToken(token)
|
||||||
|
.putApps(packageInfo.packageName, Snapshot.App.newBuilder().setApk(apk).build())
|
||||||
|
.build()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
mockkStatic(PackageUtils::class)
|
mockkStatic(PackageUtils::class)
|
||||||
|
every { appBackupManager.snapshotCreator } returns snapshotCreator
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `does not back up @pm@`() = runBlocking {
|
fun `does not back up @pm@`() = runBlocking {
|
||||||
val packageInfo = PackageInfo().apply { packageName = MAGIC_PACKAGE_MANAGER }
|
val packageInfo = PackageInfo().apply { packageName = MAGIC_PACKAGE_MANAGER }
|
||||||
assertNull(apkBackup.backupApkIfNecessary(packageInfo, streamGetter))
|
apkBackup.backupApkIfNecessary(packageInfo)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
@ -69,7 +81,7 @@ internal class ApkBackupTest : BackupTest() {
|
||||||
every { settingsManager.backupApks() } returns false
|
every { settingsManager.backupApks() } returns false
|
||||||
every { settingsManager.isBackupEnabled(any()) } returns true
|
every { settingsManager.isBackupEnabled(any()) } returns true
|
||||||
|
|
||||||
assertNull(apkBackup.backupApkIfNecessary(packageInfo, streamGetter))
|
apkBackup.backupApkIfNecessary(packageInfo)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
@ -77,7 +89,7 @@ internal class ApkBackupTest : BackupTest() {
|
||||||
every { settingsManager.backupApks() } returns true
|
every { settingsManager.backupApks() } returns true
|
||||||
every { settingsManager.isBackupEnabled(any()) } returns false
|
every { settingsManager.isBackupEnabled(any()) } returns false
|
||||||
|
|
||||||
assertNull(apkBackup.backupApkIfNecessary(packageInfo, streamGetter))
|
apkBackup.backupApkIfNecessary(packageInfo)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
@ -86,7 +98,7 @@ internal class ApkBackupTest : BackupTest() {
|
||||||
|
|
||||||
every { settingsManager.isBackupEnabled(any()) } returns true
|
every { settingsManager.isBackupEnabled(any()) } returns true
|
||||||
every { settingsManager.backupApks() } returns true
|
every { settingsManager.backupApks() } returns true
|
||||||
assertNull(apkBackup.backupApkIfNecessary(packageInfo, streamGetter))
|
apkBackup.backupApkIfNecessary(packageInfo)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
@ -95,45 +107,50 @@ internal class ApkBackupTest : BackupTest() {
|
||||||
|
|
||||||
every { settingsManager.isBackupEnabled(any()) } returns true
|
every { settingsManager.isBackupEnabled(any()) } returns true
|
||||||
every { settingsManager.backupApks() } returns true
|
every { settingsManager.backupApks() } returns true
|
||||||
assertNull(apkBackup.backupApkIfNecessary(packageInfo, streamGetter))
|
apkBackup.backupApkIfNecessary(packageInfo)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `does not back up the same version`() = runBlocking {
|
fun `does not back up the same version`() = runBlocking {
|
||||||
packageInfo.applicationInfo!!.flags = FLAG_UPDATED_SYSTEM_APP
|
packageInfo.applicationInfo!!.flags = FLAG_UPDATED_SYSTEM_APP
|
||||||
val packageMetadata = packageMetadata.copy(
|
val apk = apk.toBuilder().setVersionCode(packageInfo.longVersionCode).build()
|
||||||
version = packageInfo.longVersionCode
|
val app = Snapshot.App.newBuilder().setApk(apk).build()
|
||||||
)
|
expectChecks(snapshot.toBuilder().putApps(packageInfo.packageName, app).build())
|
||||||
|
|
||||||
expectChecks(packageMetadata)
|
apkBackup.backupApkIfNecessary(packageInfo)
|
||||||
|
|
||||||
assertNull(apkBackup.backupApkIfNecessary(packageInfo, streamGetter))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `does back up the same version when signatures changes`() {
|
fun `does back up the same version when signatures changes`() {
|
||||||
packageInfo.applicationInfo!!.sourceDir = "/tmp/doesNotExist"
|
packageInfo.applicationInfo!!.sourceDir = "/tmp/doesNotExist"
|
||||||
|
val apk = apk.toBuilder()
|
||||||
expectChecks()
|
.clearSignatures()
|
||||||
|
.addSignatures(ByteString.copyFromUtf8("foo"))
|
||||||
|
.setVersionCode(packageInfo.longVersionCode)
|
||||||
|
.build()
|
||||||
|
val app = Snapshot.App.newBuilder().setApk(apk).build()
|
||||||
|
expectChecks(snapshot.toBuilder().putApps(packageInfo.packageName, app).build())
|
||||||
|
every {
|
||||||
|
pm.getInstallSourceInfo(packageInfo.packageName)
|
||||||
|
} returns InstallSourceInfo(null, null, null, getRandomString())
|
||||||
|
|
||||||
assertThrows(IOException::class.java) {
|
assertThrows(IOException::class.java) {
|
||||||
runBlocking {
|
runBlocking {
|
||||||
assertNull(apkBackup.backupApkIfNecessary(packageInfo, streamGetter))
|
apkBackup.backupApkIfNecessary(packageInfo)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Unit
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `do not accept empty signature`() = runBlocking {
|
fun `do not accept empty signature`() = runBlocking {
|
||||||
every { settingsManager.backupApks() } returns true
|
every { settingsManager.backupApks() } returns true
|
||||||
every { settingsManager.isBackupEnabled(any()) } returns true
|
every { settingsManager.isBackupEnabled(any()) } returns true
|
||||||
every {
|
every { snapshotManager.latestSnapshot } returns snapshot
|
||||||
metadataManager.getPackageMetadata(packageInfo.packageName)
|
|
||||||
} returns packageMetadata
|
|
||||||
every { sigInfo.hasMultipleSigners() } returns false
|
every { sigInfo.hasMultipleSigners() } returns false
|
||||||
every { sigInfo.signingCertificateHistory } returns emptyArray()
|
every { sigInfo.signingCertificateHistory } returns emptyArray()
|
||||||
|
|
||||||
assertNull(apkBackup.backupApkIfNecessary(packageInfo, streamGetter))
|
apkBackup.backupApkIfNecessary(packageInfo)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
@ -145,27 +162,24 @@ internal class ApkBackupTest : BackupTest() {
|
||||||
writeBytes(apkBytes)
|
writeBytes(apkBytes)
|
||||||
}.absolutePath
|
}.absolutePath
|
||||||
val apkOutputStream = ByteArrayOutputStream()
|
val apkOutputStream = ByteArrayOutputStream()
|
||||||
val updatedMetadata = PackageMetadata(
|
val installer = getRandomString()
|
||||||
time = packageMetadata.time,
|
val capturedStream = slot<InputStream>()
|
||||||
state = UNKNOWN_ERROR,
|
|
||||||
version = packageInfo.longVersionCode,
|
|
||||||
installer = getRandomString(),
|
|
||||||
sha256 = "eHx5jjmlvBkQNVuubQzYejay4Q_QICqD47trAF2oNHI",
|
|
||||||
signatures = packageMetadata.signatures
|
|
||||||
)
|
|
||||||
|
|
||||||
expectChecks()
|
expectChecks()
|
||||||
every { metadataManager.salt } returns salt
|
|
||||||
every { crypto.getNameForApk(salt, packageInfo.packageName) } returns name
|
|
||||||
coEvery { streamGetter.invoke(name) } returns apkOutputStream
|
|
||||||
every {
|
every {
|
||||||
pm.getInstallSourceInfo(packageInfo.packageName)
|
pm.getInstallSourceInfo(packageInfo.packageName)
|
||||||
} returns InstallSourceInfo(null, null, null, updatedMetadata.installer)
|
} returns InstallSourceInfo(null, null, null, installer)
|
||||||
|
coEvery { backupReceiver.readFromStream(capture(capturedStream)) } answers {
|
||||||
|
capturedStream.captured.copyTo(apkOutputStream)
|
||||||
|
}
|
||||||
|
coEvery { backupReceiver.finalize() } returns BackupData(emptyList(), emptyMap())
|
||||||
|
every {
|
||||||
|
snapshotCreator.onApkBackedUp(packageInfo, match<Snapshot.Apk> {
|
||||||
|
it.installer == installer
|
||||||
|
}, emptyMap())
|
||||||
|
} just Runs
|
||||||
|
|
||||||
assertEquals(
|
apkBackup.backupApkIfNecessary(packageInfo)
|
||||||
updatedMetadata,
|
|
||||||
apkBackup.backupApkIfNecessary(packageInfo, streamGetter)
|
|
||||||
)
|
|
||||||
assertArrayEquals(apkBytes, apkOutputStream.toByteArray())
|
assertArrayEquals(apkBytes, apkOutputStream.toByteArray())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -184,9 +198,7 @@ internal class ApkBackupTest : BackupTest() {
|
||||||
packageInfo.splitNames = arrayOf(split1Name, split2Name)
|
packageInfo.splitNames = arrayOf(split1Name, split2Name)
|
||||||
// create two split APKs
|
// create two split APKs
|
||||||
val split1Bytes = byteArrayOf(0x07, 0x08, 0x09)
|
val split1Bytes = byteArrayOf(0x07, 0x08, 0x09)
|
||||||
val split1Sha256 = "ZqZ1cVH47lXbEncWx-Pc4L6AdLZOIO2lQuXB5GypxB4"
|
|
||||||
val split2Bytes = byteArrayOf(0x01, 0x02, 0x03)
|
val split2Bytes = byteArrayOf(0x01, 0x02, 0x03)
|
||||||
val split2Sha256 = "A5BYxvLAy0ksUzsKTRTvd8wPeKvMztUofYShogEc-4E"
|
|
||||||
packageInfo.applicationInfo!!.splitSourceDirs = arrayOf(
|
packageInfo.applicationInfo!!.splitSourceDirs = arrayOf(
|
||||||
File(tmpFile, "test-$split1Name.apk").apply {
|
File(tmpFile, "test-$split1Name.apk").apply {
|
||||||
assertTrue(createNewFile())
|
assertTrue(createNewFile())
|
||||||
|
@ -201,54 +213,39 @@ internal class ApkBackupTest : BackupTest() {
|
||||||
val apkOutputStream = ByteArrayOutputStream()
|
val apkOutputStream = ByteArrayOutputStream()
|
||||||
val split1OutputStream = ByteArrayOutputStream()
|
val split1OutputStream = ByteArrayOutputStream()
|
||||||
val split2OutputStream = ByteArrayOutputStream()
|
val split2OutputStream = ByteArrayOutputStream()
|
||||||
// expected new metadata for package
|
val capturedStream = slot<InputStream>()
|
||||||
val updatedMetadata = PackageMetadata(
|
val installer = getRandomString()
|
||||||
time = packageMetadata.time,
|
|
||||||
state = UNKNOWN_ERROR,
|
|
||||||
version = packageInfo.longVersionCode,
|
|
||||||
installer = getRandomString(),
|
|
||||||
splits = listOf(
|
|
||||||
ApkSplit(split1Name, split1Bytes.size.toLong(), split1Sha256),
|
|
||||||
ApkSplit(split2Name, split2Bytes.size.toLong(), split2Sha256)
|
|
||||||
),
|
|
||||||
sha256 = "eHx5jjmlvBkQNVuubQzYejay4Q_QICqD47trAF2oNHI",
|
|
||||||
signatures = packageMetadata.signatures
|
|
||||||
)
|
|
||||||
val suffixName1 = getRandomString()
|
|
||||||
val suffixName2 = getRandomString()
|
|
||||||
|
|
||||||
expectChecks()
|
expectChecks()
|
||||||
every { metadataManager.salt } returns salt
|
|
||||||
every { crypto.getNameForApk(salt, packageInfo.packageName) } returns name
|
|
||||||
every {
|
|
||||||
crypto.getNameForApk(salt, packageInfo.packageName, split1Name)
|
|
||||||
} returns suffixName1
|
|
||||||
every {
|
|
||||||
crypto.getNameForApk(salt, packageInfo.packageName, split2Name)
|
|
||||||
} returns suffixName2
|
|
||||||
coEvery { streamGetter.invoke(name) } returns apkOutputStream
|
|
||||||
coEvery { streamGetter.invoke(suffixName1) } returns split1OutputStream
|
|
||||||
coEvery { streamGetter.invoke(suffixName2) } returns split2OutputStream
|
|
||||||
|
|
||||||
every {
|
every {
|
||||||
pm.getInstallSourceInfo(packageInfo.packageName)
|
pm.getInstallSourceInfo(packageInfo.packageName)
|
||||||
} returns InstallSourceInfo(null, null, null, updatedMetadata.installer)
|
} returns InstallSourceInfo(null, null, null, installer)
|
||||||
|
coEvery { backupReceiver.readFromStream(capture(capturedStream)) } answers {
|
||||||
|
capturedStream.captured.copyTo(apkOutputStream)
|
||||||
|
} andThenAnswer {
|
||||||
|
capturedStream.captured.copyTo(split1OutputStream)
|
||||||
|
} andThenAnswer {
|
||||||
|
capturedStream.captured.copyTo(split2OutputStream)
|
||||||
|
}
|
||||||
|
coEvery { backupReceiver.finalize() } returns BackupData(emptyList(), emptyMap())
|
||||||
|
every {
|
||||||
|
snapshotCreator.onApkBackedUp(packageInfo, match<Snapshot.Apk> {
|
||||||
|
it.installer == installer &&
|
||||||
|
it.getSplits(1).name == split1Name &&
|
||||||
|
it.getSplits(2).name == split2Name
|
||||||
|
}, emptyMap())
|
||||||
|
} just Runs
|
||||||
|
|
||||||
assertEquals(
|
apkBackup.backupApkIfNecessary(packageInfo)
|
||||||
updatedMetadata,
|
|
||||||
apkBackup.backupApkIfNecessary(packageInfo, streamGetter)
|
|
||||||
)
|
|
||||||
assertArrayEquals(apkBytes, apkOutputStream.toByteArray())
|
assertArrayEquals(apkBytes, apkOutputStream.toByteArray())
|
||||||
assertArrayEquals(split1Bytes, split1OutputStream.toByteArray())
|
assertArrayEquals(split1Bytes, split1OutputStream.toByteArray())
|
||||||
assertArrayEquals(split2Bytes, split2OutputStream.toByteArray())
|
assertArrayEquals(split2Bytes, split2OutputStream.toByteArray())
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun expectChecks(packageMetadata: PackageMetadata = this.packageMetadata) {
|
private fun expectChecks(snapshot: Snapshot = this.snapshot) {
|
||||||
every { settingsManager.isBackupEnabled(any()) } returns true
|
every { settingsManager.isBackupEnabled(any()) } returns true
|
||||||
every { settingsManager.backupApks() } returns true
|
every { settingsManager.backupApks() } returns true
|
||||||
every {
|
every { snapshotManager.latestSnapshot } returns snapshot
|
||||||
metadataManager.getPackageMetadata(packageInfo.packageName)
|
|
||||||
} returns packageMetadata
|
|
||||||
every { PackageUtils.computeSha256DigestBytes(signatureBytes) } returns signatureHash
|
every { PackageUtils.computeSha256DigestBytes(signatureBytes) } returns signatureHash
|
||||||
every { sigInfo.hasMultipleSigners() } returns false
|
every { sigInfo.hasMultipleSigners() } returns false
|
||||||
every { sigInfo.signingCertificateHistory } returns sigs
|
every { sigInfo.signingCertificateHistory } returns sigs
|
||||||
|
|
Loading…
Reference in a new issue