Also back up APKs of apps that have no data or are above quota

This should also affect apps that have other errors during the backup
process, but it does not affect apps that opt-out of backup completely.

First part of #65
This commit is contained in:
Torsten Grote 2020-01-13 12:47:27 -03:00
parent 3f73119b52
commit 3d296e1335
No known key found for this signature in database
GPG key ID: 3E5F77D92CF891FF
14 changed files with 312 additions and 90 deletions

View file

@ -2,6 +2,7 @@ package com.stevesoltys.seedvault.metadata
import android.os.Build
import com.stevesoltys.seedvault.header.VERSION
import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR
import java.io.InputStream
typealias PackageMetadataMap = HashMap<String, PackageMetadata>
@ -24,8 +25,33 @@ internal const val JSON_METADATA_SDK_INT = "sdk_int"
internal const val JSON_METADATA_INCREMENTAL = "incremental"
internal const val JSON_METADATA_NAME = "name"
enum class PackageState {
/**
* Both, the APK and the package's data was backed up.
* This is the expected state of all user-installed packages.
*/
APK_AND_DATA,
/**
* Package data could not get backed up, because the app exceeded the allowed quota.
*/
QUOTA_EXCEEDED,
/**
* Package data could not get backed up, because the app reported no data to back up.
*/
NO_DATA,
/**
* Package data could not get backed up, because an error occurred during backup.
*/
UNKNOWN_ERROR,
}
data class PackageMetadata(
internal var time: Long,
/**
* The timestamp in milliseconds of the last app data backup.
* It is 0 if there never was a data backup.
*/
internal var time: Long = 0L,
internal var state: PackageState = UNKNOWN_ERROR,
internal val version: Long? = null,
internal val installer: String? = null,
internal val sha256: String? = null,
@ -37,6 +63,7 @@ data class PackageMetadata(
}
internal const val JSON_PACKAGE_TIME = "time"
internal const val JSON_PACKAGE_STATE = "state"
internal const val JSON_PACKAGE_VERSION = "version"
internal const val JSON_PACKAGE_INSTALLER = "installer"
internal const val JSON_PACKAGE_SHA256 = "sha256"

View file

@ -6,6 +6,7 @@ import android.util.Log
import androidx.annotation.VisibleForTesting
import androidx.annotation.WorkerThread
import com.stevesoltys.seedvault.Clock
import com.stevesoltys.seedvault.metadata.PackageState.APK_AND_DATA
import java.io.FileNotFoundException
import java.io.IOException
import java.io.OutputStream
@ -44,22 +45,21 @@ class MetadataManager(
@Synchronized
@Throws(IOException::class)
fun onDeviceInitialization(token: Long, metadataOutputStream: OutputStream) {
metadata = BackupMetadata(token = token)
metadataWriter.write(metadata, metadataOutputStream)
writeMetadataToCache()
modifyMetadata(metadataOutputStream) {
metadata = BackupMetadata(token = token)
}
}
/**
* Call this after an APK as been successfully written to backup storage.
* It will update the package's metadata, but NOT write it storage or internal cache.
* You still need to call [onPackageBackedUp] afterwards to write it out.
* Call this after a package's APK has been backed up successfully.
*
* It updates the packages' metadata
* and writes it encrypted to the given [OutputStream] as well as the internal cache.
*/
@Synchronized
fun onApkBackedUp(packageName: String, packageMetadata: PackageMetadata) {
@Throws(IOException::class)
fun onApkBackedUp(packageName: String, packageMetadata: PackageMetadata, metadataOutputStream: OutputStream) {
metadata.packageMetadataMap[packageName]?.let {
check(it.time <= packageMetadata.time) {
"APK backup set time of $packageName backwards"
}
check(packageMetadata.version != null) {
"APK backup returned version null"
}
@ -67,7 +67,16 @@ class MetadataManager(
"APK backup backed up the same or a smaller version: was ${it.version} is ${packageMetadata.version}"
}
}
metadata.packageMetadataMap[packageName] = packageMetadata
modifyMetadata(metadataOutputStream) {
val oldPackageMetadata = metadata.packageMetadataMap[packageName]
?: PackageMetadata()
metadata.packageMetadataMap[packageName] = oldPackageMetadata.copy(
version = packageMetadata.version,
installer = packageMetadata.installer,
sha256 = packageMetadata.sha256,
signatures = packageMetadata.signatures
)
}
}
/**
@ -79,24 +88,56 @@ class MetadataManager(
@Synchronized
@Throws(IOException::class)
fun onPackageBackedUp(packageName: String, metadataOutputStream: OutputStream) {
val oldMetadata = metadata.copy()
val now = clock.time()
metadata.time = now
if (metadata.packageMetadataMap.containsKey(packageName)) {
metadata.packageMetadataMap[packageName]?.time = now
} else {
metadata.packageMetadataMap[packageName] = PackageMetadata(time = now)
modifyMetadata(metadataOutputStream) {
val now = clock.time()
metadata.time = now
if (metadata.packageMetadataMap.containsKey(packageName)) {
metadata.packageMetadataMap[packageName]!!.time = now
metadata.packageMetadataMap[packageName]!!.state = APK_AND_DATA
} else {
metadata.packageMetadataMap[packageName] = PackageMetadata(
time = now,
state = APK_AND_DATA
)
}
}
}
/**
* Call this after a package data backup failed.
*
* It updates the packages' metadata
* and writes it encrypted to the given [OutputStream] as well as the internal cache.
*/
@Synchronized
@Throws(IOException::class)
internal fun onPackageBackupError(packageName: String, packageState: PackageState, metadataOutputStream: OutputStream) {
check(packageState != APK_AND_DATA) { "Backup Error with non-error package state." }
modifyMetadata(metadataOutputStream) {
if (metadata.packageMetadataMap.containsKey(packageName)) {
metadata.packageMetadataMap[packageName]!!.state = packageState
} else {
metadata.packageMetadataMap[packageName] = PackageMetadata(
time = 0L,
state = packageState
)
}
}
}
@Throws(IOException::class)
private fun modifyMetadata(metadataOutputStream: OutputStream, modFun: () -> Unit) {
val oldMetadata = metadata.copy()
try {
modFun.invoke()
metadataWriter.write(metadata, metadataOutputStream)
writeMetadataToCache()
} catch (e: IOException) {
Log.w(TAG, "Error writing metadata to storage", e)
// revert metadata and do not write it to cache
// TODO also revert changes made by last [onApkBackedUp]
metadata = oldMetadata
throw IOException(e)
}
writeMetadataToCache()
}
/**

View file

@ -4,6 +4,7 @@ import com.stevesoltys.seedvault.Utf8
import com.stevesoltys.seedvault.crypto.Crypto
import com.stevesoltys.seedvault.header.UnsupportedVersionException
import com.stevesoltys.seedvault.header.VERSION
import com.stevesoltys.seedvault.metadata.PackageState.*
import org.json.JSONException
import org.json.JSONObject
import java.io.IOException
@ -59,8 +60,14 @@ internal class MetadataReaderImpl(private val crypto: Crypto) : MetadataReader {
for (packageName in json.keys()) {
if (packageName == JSON_METADATA) continue
val p = json.getJSONObject(packageName)
val pState = when(p.optString(JSON_PACKAGE_STATE)) {
"" -> APK_AND_DATA
QUOTA_EXCEEDED.name -> QUOTA_EXCEEDED
NO_DATA.name -> NO_DATA
else -> UNKNOWN_ERROR
}
val pVersion = p.optLong(JSON_PACKAGE_VERSION, 0L)
val pInstaller = p.optString(JSON_PACKAGE_INSTALLER, "")
val pInstaller = p.optString(JSON_PACKAGE_INSTALLER)
val pSha256 = p.optString(JSON_PACKAGE_SHA256)
val pSignatures = p.optJSONArray(JSON_PACKAGE_SIGNATURES)
val signatures = if (pSignatures == null) null else
@ -71,6 +78,7 @@ internal class MetadataReaderImpl(private val crypto: Crypto) : MetadataReader {
}
packageMetadataMap[packageName] = PackageMetadata(
time = p.getLong(JSON_PACKAGE_TIME),
state = pState,
version = if (pVersion == 0L) null else pVersion,
installer = if (pInstaller == "") null else pInstaller,
sha256 = if (pSha256 == "") null else pSha256,

View file

@ -2,6 +2,7 @@ package com.stevesoltys.seedvault.metadata
import com.stevesoltys.seedvault.Utf8
import com.stevesoltys.seedvault.crypto.Crypto
import com.stevesoltys.seedvault.metadata.PackageState.APK_AND_DATA
import org.json.JSONArray
import org.json.JSONObject
import java.io.IOException
@ -36,6 +37,9 @@ internal class MetadataWriterImpl(private val crypto: Crypto) : MetadataWriter {
for ((packageName, packageMetadata) in metadata.packageMetadataMap) {
json.put(packageName, JSONObject().apply {
put(JSON_PACKAGE_TIME, packageMetadata.time)
if (packageMetadata.state != APK_AND_DATA) {
put(JSON_PACKAGE_STATE, packageMetadata.state.name)
}
packageMetadata.version?.let { put(JSON_PACKAGE_VERSION, it) }
packageMetadata.installer?.let { put(JSON_PACKAGE_INSTALLER, it) }
packageMetadata.sha256?.let { put(JSON_PACKAGE_SHA256, it) }

View file

@ -8,7 +8,6 @@ import android.content.pm.Signature
import android.content.pm.SigningInfo
import android.util.Log
import android.util.PackageUtils.computeSha256DigestBytes
import com.stevesoltys.seedvault.Clock
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
import com.stevesoltys.seedvault.encodeBase64
import com.stevesoltys.seedvault.metadata.MetadataManager
@ -24,43 +23,50 @@ private val TAG = ApkBackup::class.java.simpleName
class ApkBackup(
private val pm: PackageManager,
private val clock: Clock,
private val settingsManager: SettingsManager,
private val metadataManager: MetadataManager) {
/**
* Checks if a new APK needs to get backed up,
* because the version code or the signatures have changed.
* Only if an APK needs a backup, an [OutputStream] is obtained from the given streamGetter
* and the APK binary written to it.
*
* @return new [PackageMetadata] if an APK backup was made or null if no backup was made.
*/
@Throws(IOException::class)
fun backupApkIfNecessary(packageInfo: PackageInfo, streamGetter: () -> OutputStream): Boolean {
fun backupApkIfNecessary(packageInfo: PackageInfo, streamGetter: () -> OutputStream): PackageMetadata? {
// do not back up @pm@
val packageName = packageInfo.packageName
if (packageName == MAGIC_PACKAGE_MANAGER) return false
if (packageName == MAGIC_PACKAGE_MANAGER) return null
// do not back up when setting is not enabled
if (!settingsManager.backupApks()) return false
if (!settingsManager.backupApks()) return null
// do not back up system apps that haven't been updated
val isSystemApp = packageInfo.applicationInfo.flags and FLAG_SYSTEM != 0
val isUpdatedSystemApp = packageInfo.applicationInfo.flags and FLAG_UPDATED_SYSTEM_APP != 0
if (isSystemApp && !isUpdatedSystemApp) {
Log.d(TAG, "Package $packageName is vanilla system app. Not backing it up.")
return false
return null
}
// TODO remove when adding support for packages with multiple signers
if (packageInfo.signingInfo.hasMultipleSigners()) {
Log.e(TAG, "Package $packageName has multiple signers. Not backing it up.")
return false
return null
}
// get signatures
val signatures = packageInfo.signingInfo.getSignatures()
if (signatures.isEmpty()) {
Log.e(TAG, "Package $packageName has no signatures. Not backing it up.")
return false
return null
}
// get cached metadata about package
val packageMetadata = metadataManager.getPackageMetadata(packageName)
?: PackageMetadata(time = clock.time())
?: PackageMetadata()
// get version codes
val version = packageInfo.longVersionCode
@ -69,7 +75,7 @@ class ApkBackup(
// do not backup if we have the version already and signatures did not change
if (version <= backedUpVersion && !signaturesChanged(packageMetadata, signatures)) {
Log.d(TAG, "Package $packageName with version $version already has a backup ($backedUpVersion) with the same signature. Not backing it up.")
return false
return null
}
// get an InputStream for the APK
@ -100,17 +106,13 @@ class ApkBackup(
val sha256 = messageDigest.digest().encodeBase64()
Log.d(TAG, "Backed up new APK of $packageName with version $version.")
// update the metadata
val installer = pm.getInstallerPackageName(packageName)
val updatedMetadata = PackageMetadata(
time = clock.time(),
// return updated metadata
return PackageMetadata(
version = version,
installer = installer,
installer = pm.getInstallerPackageName(packageName),
sha256 = sha256,
signatures = signatures
)
metadataManager.onApkBackedUp(packageName, updatedMetadata)
return true
}
private fun signaturesChanged(packageMetadata: PackageMetadata, signatures: List<String>): Boolean {

View file

@ -9,6 +9,8 @@ import com.stevesoltys.seedvault.BackupNotificationManager
import com.stevesoltys.seedvault.Clock
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
import com.stevesoltys.seedvault.metadata.MetadataManager
import com.stevesoltys.seedvault.metadata.PackageState
import com.stevesoltys.seedvault.metadata.PackageState.*
import com.stevesoltys.seedvault.settings.SettingsManager
import java.io.IOException
import java.util.concurrent.TimeUnit.DAYS
@ -32,6 +34,7 @@ internal class BackupCoordinator(
private var calledInitialize = false
private var calledClearBackupData = false
private var cancelReason: PackageState = UNKNOWN_ERROR
// ------------------------------------------------------------------------------------
// Transport initialization and quota
@ -110,13 +113,15 @@ internal class BackupCoordinator(
}
fun performIncrementalBackup(packageInfo: PackageInfo, data: ParcelFileDescriptor, flags: Int): Int {
cancelReason = UNKNOWN_ERROR
val packageName = packageInfo.packageName
// backups of package manager metadata do not respect backoff
// we need to reject them manually when now is not a good time for a backup
if (packageName == MAGIC_PACKAGE_MANAGER && getBackupBackoff() != 0L) {
return TRANSPORT_PACKAGE_REJECTED
}
return kv.performBackup(packageInfo, data, flags)
val result = kv.performBackup(packageInfo, data, flags)
return backUpApk(result, packageInfo)
}
// ------------------------------------------------------------------------------------
@ -140,10 +145,17 @@ internal class BackupCoordinator(
Log.i(TAG, "Request full backup time. Returned $this")
}
fun checkFullBackupSize(size: Long) = full.checkFullBackupSize(size)
fun checkFullBackupSize(size: Long): Int {
val result = full.checkFullBackupSize(size)
if (result == TRANSPORT_PACKAGE_REJECTED) cancelReason = NO_DATA
else if (result == TRANSPORT_QUOTA_EXCEEDED) cancelReason = QUOTA_EXCEEDED
return result
}
fun performFullBackup(targetPackage: PackageInfo, fileDescriptor: ParcelFileDescriptor, flags: Int): Int {
return full.performFullBackup(targetPackage, fileDescriptor, flags)
cancelReason = UNKNOWN_ERROR
val result = full.performFullBackup(targetPackage, fileDescriptor, flags)
return backUpApk(result, targetPackage)
}
fun sendBackupData(numBytes: Int) = full.sendBackupData(numBytes)
@ -161,7 +173,13 @@ internal class BackupCoordinator(
* If the transport receives this callback, it will *not* receive a call to [finishBackup].
* It needs to tear down any ongoing backup state here.
*/
fun cancelFullBackup() = full.cancelFullBackup()
fun cancelFullBackup() {
val packageInfo = full.getCurrentPackage()
?: throw AssertionError("Cancelling full backup, but no current package")
Log.i(TAG, "Cancel full backup of ${packageInfo.packageName} because of $cancelReason")
onPackageBackupError(packageInfo)
full.cancelFullBackup()
}
// Clear and Finish
@ -193,6 +211,14 @@ internal class BackupCoordinator(
return TRANSPORT_OK
}
/**
* Finish sending application data to the backup destination.
* This must be called after [performIncrementalBackup], [performFullBackup], or [clearBackupData]
* to ensure that all data is sent and the operation properly finalized.
* Only when this method returns true can a backup be assumed to have succeeded.
*
* @return the same error codes as [performIncrementalBackup] or [performFullBackup].
*/
fun finishBackup(): Int = when {
kv.hasState() -> {
check(!full.hasState()) { "K/V backup has state, but full backup has dangling state as well" }
@ -212,10 +238,25 @@ internal class BackupCoordinator(
else -> throw IllegalStateException("Unexpected state in finishBackup()")
}
private fun backUpApk(result: Int, packageInfo: PackageInfo): Int {
val packageName = packageInfo.packageName
return try {
apkBackup.backupApkIfNecessary(packageInfo) {
plugin.getApkOutputStream(packageInfo)
}?.let { packageMetadata ->
val outputStream = plugin.getMetadataOutputStream()
metadataManager.onApkBackedUp(packageName, packageMetadata, outputStream)
}
result
} catch (e: IOException) {
Log.e(TAG, "Error while writing APK or metadata for $packageName", e)
TRANSPORT_PACKAGE_REJECTED
}
}
private fun onPackageBackedUp(packageInfo: PackageInfo) {
val packageName = packageInfo.packageName
try {
apkBackup.backupApkIfNecessary(packageInfo) { plugin.getApkOutputStream(packageInfo) }
val outputStream = plugin.getMetadataOutputStream()
metadataManager.onPackageBackedUp(packageName, outputStream)
} catch (e: IOException) {
@ -223,6 +264,16 @@ internal class BackupCoordinator(
}
}
private fun onPackageBackupError(packageInfo: PackageInfo) {
val packageName = packageInfo.packageName
try {
val outputStream = plugin.getMetadataOutputStream()
metadataManager.onPackageBackupError(packageName, cancelReason, outputStream)
} catch (e: IOException) {
Log.e(TAG, "Error while writing metadata for $packageName", e)
}
}
private fun getBackupBackoff(): Long {
val noBackoff = 0L
val defaultBackoff = DAYS.toMillis(30)

View file

@ -5,7 +5,7 @@ import org.koin.dsl.module
val backupModule = module {
single { InputFactory() }
single { ApkBackup(androidContext().packageManager, get(), get(), get()) }
single { ApkBackup(androidContext().packageManager, get(), get()) }
single { KVBackup(get<BackupPlugin>().kvBackupPlugin, get(), get(), get()) }
single { FullBackup(get<BackupPlugin>().fullBackupPlugin, get(), get(), get()) }
single { BackupCoordinator(androidContext(), get(), get(), get(), get(), get(), get(), get(), get()) }

View file

@ -45,7 +45,7 @@ internal class FullBackup(
Log.i(TAG, "Check full backup size of $size bytes.")
return when {
size <= 0 -> TRANSPORT_PACKAGE_REJECTED
size > plugin.getQuota() -> TRANSPORT_QUOTA_EXCEEDED
size > getQuota() -> TRANSPORT_QUOTA_EXCEEDED
else -> TRANSPORT_OK
}
}

View file

@ -6,6 +6,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import com.stevesoltys.seedvault.Clock
import com.stevesoltys.seedvault.getRandomByteArray
import com.stevesoltys.seedvault.getRandomString
import com.stevesoltys.seedvault.metadata.PackageState.APK_AND_DATA
import io.mockk.Runs
import io.mockk.every
import io.mockk.just
@ -46,8 +47,8 @@ class MetadataManagerTest {
@Test
fun `test onDeviceInitialization()`() {
every { clock.time() } returns time
every { metadataWriter.write(initialMetadata, storageOutputStream) } just Runs
expectWriteToCache(initialMetadata)
expectReadFromCache()
expectModifyMetadata(initialMetadata)
manager.onDeviceInitialization(token, storageOutputStream)
@ -58,15 +59,16 @@ class MetadataManagerTest {
@Test
fun `test onApkBackedUp() with no prior package metadata`() {
val packageMetadata = PackageMetadata(
time = time + 1,
time = 0L,
version = Random.nextLong(Long.MAX_VALUE),
installer = getRandomString(),
signatures = listOf("sig")
)
expectReadFromCache()
expectModifyMetadata(initialMetadata)
manager.onApkBackedUp(packageName, packageMetadata)
manager.onApkBackedUp(packageName, packageMetadata, storageOutputStream)
assertEquals(packageMetadata, manager.getPackageMetadata(packageName))
}
@ -81,15 +83,16 @@ class MetadataManagerTest {
)
initialMetadata.packageMetadataMap[packageName] = packageMetadata
val updatedPackageMetadata = PackageMetadata(
time = time + 1,
time = time,
version = packageMetadata.version!! + 1,
installer = getRandomString(),
signatures = listOf("sig foo")
)
expectReadFromCache()
expectModifyMetadata(initialMetadata)
manager.onApkBackedUp(packageName, updatedPackageMetadata)
manager.onApkBackedUp(packageName, updatedPackageMetadata, storageOutputStream)
assertEquals(updatedPackageMetadata, manager.getPackageMetadata(packageName))
}
@ -102,8 +105,7 @@ class MetadataManagerTest {
expectReadFromCache()
every { clock.time() } returns time
every { metadataWriter.write(updatedMetadata, storageOutputStream) } just Runs
expectWriteToCache(updatedMetadata)
expectModifyMetadata(updatedMetadata)
manager.onPackageBackedUp(packageName, storageOutputStream)
@ -142,19 +144,18 @@ class MetadataManagerTest {
cachedMetadata.packageMetadataMap[packageName] = PackageMetadata(cacheTime)
val updatedMetadata = cachedMetadata.copy(time = time)
cachedMetadata.packageMetadataMap[cachedPackageName] = PackageMetadata(time)
cachedMetadata.packageMetadataMap[packageName] = PackageMetadata(time)
updatedMetadata.packageMetadataMap[cachedPackageName] = PackageMetadata(time)
updatedMetadata.packageMetadataMap[packageName] = PackageMetadata(time, state = APK_AND_DATA)
expectReadFromCache()
every { clock.time() } returns time
every { metadataWriter.write(updatedMetadata, storageOutputStream) } just Runs
expectWriteToCache(updatedMetadata)
expectModifyMetadata(updatedMetadata)
manager.onPackageBackedUp(packageName, storageOutputStream)
assertEquals(time, manager.getLastBackupTime())
assertEquals(PackageMetadata(time), manager.getPackageMetadata(cachedPackageName))
assertEquals(PackageMetadata(time), manager.getPackageMetadata(packageName))
assertEquals(updatedMetadata.packageMetadataMap[packageName], manager.getPackageMetadata(packageName))
}
@Test
@ -181,7 +182,8 @@ class MetadataManagerTest {
assertEquals(initialMetadata.token, manager.getBackupToken())
}
private fun expectWriteToCache(metadata: BackupMetadata) {
private fun expectModifyMetadata(metadata: BackupMetadata) {
every { metadataWriter.write(metadata, storageOutputStream) } just Runs
every { metadataWriter.encode(metadata) } returns encodedMetadata
every { context.openFileOutput(METADATA_CACHE_FILE, MODE_PRIVATE) } returns cacheOutputStream
every { cacheOutputStream.write(encodedMetadata) } just Runs

View file

@ -3,6 +3,8 @@ package com.stevesoltys.seedvault.metadata
import com.stevesoltys.seedvault.Utf8
import com.stevesoltys.seedvault.crypto.Crypto
import com.stevesoltys.seedvault.getRandomString
import com.stevesoltys.seedvault.metadata.PackageState.QUOTA_EXCEEDED
import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR
import io.mockk.mockk
import org.json.JSONArray
import org.json.JSONObject
@ -82,6 +84,7 @@ class MetadataReaderTest {
val packageMetadata = HashMap<String, PackageMetadata>().apply {
put("org.example", PackageMetadata(
time = Random.nextLong(),
state = QUOTA_EXCEEDED,
version = Random.nextLong(),
installer = getRandomString(),
sha256 = getRandomString(),
@ -108,6 +111,22 @@ class MetadataReaderTest {
}
}
@Test
fun `package metadata unknown state gets mapped to error`() {
val json = JSONObject(metadataByteArray.toString(Utf8))
json.put("org.example", JSONObject().apply {
put(JSON_PACKAGE_TIME, Random.nextLong())
put(JSON_PACKAGE_STATE, getRandomString())
put(JSON_PACKAGE_VERSION, Random.nextLong())
put(JSON_PACKAGE_INSTALLER, getRandomString())
put(JSON_PACKAGE_SHA256, getRandomString())
put(JSON_PACKAGE_SIGNATURES, JSONArray(listOf(getRandomString(), getRandomString())))
})
val jsonBytes = json.toString().toByteArray(Utf8)
val metadata = decoder.decode(jsonBytes, metadata.version, metadata.token)
assertEquals(UNKNOWN_ERROR, metadata.packageMetadataMap["org.example"]!!.state)
}
@Test
fun `package metadata can only include time`() {
val json = JSONObject(metadataByteArray.toString(Utf8))
@ -124,7 +143,7 @@ class MetadataReaderTest {
assertNull(packageMetadata.signatures)
}
private fun getMetadata(packageMetadata: HashMap<String, PackageMetadata> = HashMap()): BackupMetadata {
private fun getMetadata(packageMetadata: PackageMetadataMap = PackageMetadataMap()): BackupMetadata {
return BackupMetadata(
version = 1.toByte(),
token = Random.nextLong(),

View file

@ -2,6 +2,7 @@ package com.stevesoltys.seedvault.metadata
import com.stevesoltys.seedvault.crypto.Crypto
import com.stevesoltys.seedvault.getRandomString
import com.stevesoltys.seedvault.metadata.PackageState.*
import io.mockk.mockk
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
@ -27,7 +28,7 @@ internal class MetadataWriterDecoderTest {
fun `encoded metadata matches decoded metadata (with package, no apk info)`() {
val time = Random.nextLong()
val packages = HashMap<String, PackageMetadata>().apply {
put(getRandomString(), PackageMetadata(time))
put(getRandomString(), PackageMetadata(time, APK_AND_DATA))
}
val metadata = getMetadata(packages)
assertEquals(metadata, decoder.decode(encoder.encode(metadata), metadata.version, metadata.token))
@ -38,6 +39,7 @@ internal class MetadataWriterDecoderTest {
val packages = HashMap<String, PackageMetadata>().apply {
put(getRandomString(), PackageMetadata(
time = Random.nextLong(),
state = APK_AND_DATA,
version = Random.nextLong(),
installer = getRandomString(),
sha256 = getRandomString(),
@ -52,6 +54,7 @@ internal class MetadataWriterDecoderTest {
val packages = HashMap<String, PackageMetadata>().apply {
put(getRandomString(), PackageMetadata(
time = Random.nextLong(),
state = QUOTA_EXCEEDED,
version = Random.nextLong(),
installer = getRandomString(),
sha256 = getRandomString(),
@ -59,6 +62,7 @@ internal class MetadataWriterDecoderTest {
))
put(getRandomString(), PackageMetadata(
time = Random.nextLong(),
state = NO_DATA,
version = Random.nextLong(),
installer = getRandomString(),
sha256 = getRandomString(),

View file

@ -16,6 +16,7 @@ import com.stevesoltys.seedvault.header.HeaderReaderImpl
import com.stevesoltys.seedvault.header.HeaderWriterImpl
import com.stevesoltys.seedvault.header.MAX_SEGMENT_CLEARTEXT_LENGTH
import com.stevesoltys.seedvault.metadata.MetadataReaderImpl
import com.stevesoltys.seedvault.metadata.PackageMetadata
import com.stevesoltys.seedvault.transport.backup.*
import com.stevesoltys.seedvault.transport.restore.*
import io.mockk.*
@ -23,7 +24,6 @@ import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.Test
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.OutputStream
import kotlin.random.Random
internal class CoordinatorIntegrationTest : TransportTest() {
@ -59,6 +59,7 @@ internal class CoordinatorIntegrationTest : TransportTest() {
private val appData = ByteArray(42).apply { Random.nextBytes(this) }
private val appData2 = ByteArray(1337).apply { Random.nextBytes(this) }
private val metadataOutputStream = ByteArrayOutputStream()
private val packageMetadata = PackageMetadata(time = 0L)
private val key = "RestoreKey"
private val key64 = key.encodeBase64()
private val key2 = "RestoreKey2"
@ -93,8 +94,9 @@ internal class CoordinatorIntegrationTest : TransportTest() {
appData2.size
}
every { kvBackupPlugin.getOutputStreamForRecord(packageInfo, key264) } returns bOutputStream2
every { apkBackup.backupApkIfNecessary(packageInfo, any()) } returns true
every { apkBackup.backupApkIfNecessary(packageInfo, any()) } returns packageMetadata
every { backupPlugin.getMetadataOutputStream() } returns metadataOutputStream
every { metadataManager.onApkBackedUp(packageInfo.packageName, packageMetadata, metadataOutputStream) } just Runs
every { metadataManager.onPackageBackedUp(packageInfo.packageName, metadataOutputStream) } just Runs
// start and finish K/V backup
@ -146,7 +148,7 @@ internal class CoordinatorIntegrationTest : TransportTest() {
appData.size
}
every { kvBackupPlugin.getOutputStreamForRecord(packageInfo, key64) } returns bOutputStream
every { apkBackup.backupApkIfNecessary(packageInfo, any()) } returns false
every { apkBackup.backupApkIfNecessary(packageInfo, any()) } returns null
every { backupPlugin.getMetadataOutputStream() } returns metadataOutputStream
every { metadataManager.onPackageBackedUp(packageInfo.packageName, metadataOutputStream) } just Runs
@ -184,8 +186,9 @@ internal class CoordinatorIntegrationTest : TransportTest() {
every { fullBackupPlugin.getOutputStream(packageInfo) } returns bOutputStream
every { inputFactory.getInputStream(fileDescriptor) } returns bInputStream
every { fullBackupPlugin.getQuota() } returns DEFAULT_QUOTA_FULL_BACKUP
every { apkBackup.backupApkIfNecessary(packageInfo, any()) } returns true
every { apkBackup.backupApkIfNecessary(packageInfo, any()) } returns packageMetadata
every { backupPlugin.getMetadataOutputStream() } returns metadataOutputStream
every { metadataManager.onApkBackedUp(packageInfo.packageName, packageMetadata, metadataOutputStream) } just Runs
every { metadataManager.onPackageBackedUp(packageInfo.packageName, metadataOutputStream) } just Runs
// perform backup to output stream

View file

@ -9,6 +9,7 @@ import android.util.PackageUtils
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
import com.stevesoltys.seedvault.getRandomString
import com.stevesoltys.seedvault.metadata.PackageMetadata
import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR
import io.mockk.*
import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.Test
@ -26,7 +27,7 @@ internal class ApkBackupTest : BackupTest() {
private val pm: PackageManager = mockk()
private val streamGetter: () -> OutputStream = mockk()
private val apkBackup = ApkBackup(pm, clock, settingsManager, metadataManager)
private val apkBackup = ApkBackup(pm, settingsManager, metadataManager)
private val signatureBytes = byteArrayOf(0x01, 0x02, 0x03)
private val signatureHash = byteArrayOf(0x03, 0x02, 0x01)
@ -44,14 +45,14 @@ internal class ApkBackupTest : BackupTest() {
@Test
fun `does not back up @pm@`() {
val packageInfo = PackageInfo().apply { packageName = MAGIC_PACKAGE_MANAGER }
assertFalse(apkBackup.backupApkIfNecessary(packageInfo, streamGetter))
assertNull(apkBackup.backupApkIfNecessary(packageInfo, streamGetter))
}
@Test
fun `does not back up when setting disabled`() {
every { settingsManager.backupApks() } returns false
assertFalse(apkBackup.backupApkIfNecessary(packageInfo, streamGetter))
assertNull(apkBackup.backupApkIfNecessary(packageInfo, streamGetter))
}
@Test
@ -60,7 +61,7 @@ internal class ApkBackupTest : BackupTest() {
every { settingsManager.backupApks() } returns true
assertFalse(apkBackup.backupApkIfNecessary(packageInfo, streamGetter))
assertNull(apkBackup.backupApkIfNecessary(packageInfo, streamGetter))
}
@Test
@ -72,7 +73,7 @@ internal class ApkBackupTest : BackupTest() {
expectChecks(packageMetadata)
assertFalse(apkBackup.backupApkIfNecessary(packageInfo, streamGetter))
assertNull(apkBackup.backupApkIfNecessary(packageInfo, streamGetter))
}
@Test
@ -82,7 +83,7 @@ internal class ApkBackupTest : BackupTest() {
expectChecks()
assertThrows(IOException::class.java) {
assertFalse(apkBackup.backupApkIfNecessary(packageInfo, streamGetter))
assertNull(apkBackup.backupApkIfNecessary(packageInfo, streamGetter))
}
}
@ -93,7 +94,7 @@ internal class ApkBackupTest : BackupTest() {
every { sigInfo.hasMultipleSigners() } returns false
every { sigInfo.signingCertificateHistory } returns emptyArray()
assertFalse(apkBackup.backupApkIfNecessary(packageInfo, streamGetter))
assertNull(apkBackup.backupApkIfNecessary(packageInfo, streamGetter))
}
@Test
@ -106,7 +107,8 @@ internal class ApkBackupTest : BackupTest() {
}.absolutePath
val apkOutputStream = ByteArrayOutputStream()
val updatedMetadata = PackageMetadata(
time = Random.nextLong(),
time = 0L,
state = UNKNOWN_ERROR,
version = packageInfo.longVersionCode,
installer = getRandomString(),
sha256 = "eHx5jjmlvBkQNVuubQzYejay4Q_QICqD47trAF2oNHI",
@ -116,10 +118,9 @@ internal class ApkBackupTest : BackupTest() {
expectChecks()
every { streamGetter.invoke() } returns apkOutputStream
every { pm.getInstallerPackageName(packageInfo.packageName) } returns updatedMetadata.installer
every { clock.time() } returns updatedMetadata.time
every { metadataManager.onApkBackedUp(packageInfo.packageName, updatedMetadata) } just Runs
every { metadataManager.onApkBackedUp(packageInfo.packageName, updatedMetadata, outputStream) } just Runs
assertTrue(apkBackup.backupApkIfNecessary(packageInfo, streamGetter))
assertEquals(updatedMetadata, apkBackup.backupApkIfNecessary(packageInfo, streamGetter))
assertArrayEquals(apkBytes, apkOutputStream.toByteArray())
}

View file

@ -1,16 +1,16 @@
package com.stevesoltys.seedvault.transport.backup
import android.app.backup.BackupTransport.TRANSPORT_ERROR
import android.app.backup.BackupTransport.TRANSPORT_OK
import android.app.backup.BackupTransport.*
import android.net.Uri
import android.os.ParcelFileDescriptor
import androidx.documentfile.provider.DocumentFile
import com.stevesoltys.seedvault.BackupNotificationManager
import com.stevesoltys.seedvault.getRandomString
import com.stevesoltys.seedvault.metadata.PackageMetadata
import com.stevesoltys.seedvault.metadata.PackageState.NO_DATA
import com.stevesoltys.seedvault.metadata.PackageState.QUOTA_EXCEEDED
import com.stevesoltys.seedvault.settings.Storage
import io.mockk.Runs
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.*
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Test
@ -18,7 +18,7 @@ import java.io.IOException
import java.io.OutputStream
import kotlin.random.Random
internal class BackupCoordinatorTest: BackupTest() {
internal class BackupCoordinatorTest : BackupTest() {
private val plugin = mockk<BackupPlugin>()
private val kv = mockk<KVBackup>()
@ -29,6 +29,9 @@ internal class BackupCoordinatorTest: BackupTest() {
private val backup = BackupCoordinator(context, plugin, kv, full, apkBackup, clock, metadataManager, settingsManager, notificationManager)
private val metadataOutputStream = mockk<OutputStream>()
private val fileDescriptor: ParcelFileDescriptor = mockk()
private val packageMetadata: PackageMetadata = mockk()
private val storage = Storage(Uri.EMPTY, getRandomString(), false)
@Test
fun `device initialization succeeds and delegates to plugin`() {
@ -56,8 +59,6 @@ internal class BackupCoordinatorTest: BackupTest() {
@Test
fun `error notification when device initialization fails`() {
val storage = Storage(Uri.EMPTY, getRandomString(), false)
every { clock.time() } returns token
every { plugin.initializeDevice(token) } throws IOException()
every { settingsManager.getStorage() } returns storage
@ -143,7 +144,8 @@ internal class BackupCoordinatorTest: BackupTest() {
every { kv.hasState() } returns true
every { full.hasState() } returns false
every { kv.getCurrentPackage() } returns packageInfo
expectApkBackupAndMetadataWrite()
every { plugin.getMetadataOutputStream() } returns metadataOutputStream
every { metadataManager.onPackageBackedUp(packageInfo.packageName, metadataOutputStream) } just Runs
every { kv.finishBackup() } returns result
assertEquals(result, backup.finishBackup())
@ -156,16 +158,74 @@ internal class BackupCoordinatorTest: BackupTest() {
every { kv.hasState() } returns false
every { full.hasState() } returns true
every { full.getCurrentPackage() } returns packageInfo
expectApkBackupAndMetadataWrite()
every { plugin.getMetadataOutputStream() } returns metadataOutputStream
every { metadataManager.onPackageBackedUp(packageInfo.packageName, metadataOutputStream) } just Runs
every { full.finishBackup() } returns result
assertEquals(result, backup.finishBackup())
}
@Test
fun `metadata does not get updated when no APK was backed up`() {
every { full.performFullBackup(packageInfo, fileDescriptor, 0) } returns TRANSPORT_OK
every { apkBackup.backupApkIfNecessary(packageInfo, any()) } returns null
assertEquals(TRANSPORT_OK, backup.performFullBackup(packageInfo, fileDescriptor, 0))
}
@Test
fun `app exceeding quota gets cancelled and reason written to metadata`() {
every { full.performFullBackup(packageInfo, fileDescriptor, 0) } returns TRANSPORT_OK
expectApkBackupAndMetadataWrite()
every { full.getQuota() } returns DEFAULT_QUOTA_FULL_BACKUP
every { full.checkFullBackupSize(DEFAULT_QUOTA_FULL_BACKUP + 1) } returns TRANSPORT_QUOTA_EXCEEDED
every { full.getCurrentPackage() } returns packageInfo
every { metadataManager.onPackageBackupError(packageInfo.packageName, QUOTA_EXCEEDED, metadataOutputStream) } just Runs
every { full.cancelFullBackup() } just Runs
every { settingsManager.getStorage() } returns storage
assertEquals(TRANSPORT_OK,
backup.performFullBackup(packageInfo, fileDescriptor, 0))
assertEquals(DEFAULT_QUOTA_FULL_BACKUP,
backup.getBackupQuota(packageInfo.packageName, true))
assertEquals(TRANSPORT_QUOTA_EXCEEDED,
backup.checkFullBackupSize(DEFAULT_QUOTA_FULL_BACKUP + 1))
backup.cancelFullBackup()
assertEquals(0L, backup.requestFullBackupTime())
verify(exactly = 1) {
metadataManager.onPackageBackupError(packageInfo.packageName, QUOTA_EXCEEDED, metadataOutputStream)
}
}
@Test
fun `app with no data gets cancelled and reason written to metadata`() {
every { full.performFullBackup(packageInfo, fileDescriptor, 0) } returns TRANSPORT_OK
expectApkBackupAndMetadataWrite()
every { full.getQuota() } returns DEFAULT_QUOTA_FULL_BACKUP
every { full.checkFullBackupSize(0) } returns TRANSPORT_PACKAGE_REJECTED
every { full.getCurrentPackage() } returns packageInfo
every { metadataManager.onPackageBackupError(packageInfo.packageName, NO_DATA, metadataOutputStream) } just Runs
every { full.cancelFullBackup() } just Runs
every { settingsManager.getStorage() } returns storage
assertEquals(TRANSPORT_OK,
backup.performFullBackup(packageInfo, fileDescriptor, 0))
assertEquals(DEFAULT_QUOTA_FULL_BACKUP,
backup.getBackupQuota(packageInfo.packageName, true))
assertEquals(TRANSPORT_PACKAGE_REJECTED, backup.checkFullBackupSize(0))
backup.cancelFullBackup()
assertEquals(0L, backup.requestFullBackupTime())
verify(exactly = 1) {
metadataManager.onPackageBackupError(packageInfo.packageName, NO_DATA, metadataOutputStream)
}
}
private fun expectApkBackupAndMetadataWrite() {
every { apkBackup.backupApkIfNecessary(packageInfo, any()) } returns true
every { apkBackup.backupApkIfNecessary(packageInfo, any()) } returns packageMetadata
every { plugin.getMetadataOutputStream() } returns metadataOutputStream
every { metadataManager.onPackageBackedUp(packageInfo.packageName, metadataOutputStream) } just Runs
every { metadataManager.onApkBackedUp(packageInfo.packageName, packageMetadata, metadataOutputStream) } just Runs
}
}