diff --git a/app/src/main/java/com/stevesoltys/seedvault/metadata/Metadata.kt b/app/src/main/java/com/stevesoltys/seedvault/metadata/Metadata.kt index cd9ed1a1..b05dec43 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/metadata/Metadata.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/metadata/Metadata.kt @@ -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 @@ -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" diff --git a/app/src/main/java/com/stevesoltys/seedvault/metadata/MetadataManager.kt b/app/src/main/java/com/stevesoltys/seedvault/metadata/MetadataManager.kt index 1a40d417..98c5e4ea 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/metadata/MetadataManager.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/metadata/MetadataManager.kt @@ -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() } /** diff --git a/app/src/main/java/com/stevesoltys/seedvault/metadata/MetadataReader.kt b/app/src/main/java/com/stevesoltys/seedvault/metadata/MetadataReader.kt index a79901a1..719a3648 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/metadata/MetadataReader.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/metadata/MetadataReader.kt @@ -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, diff --git a/app/src/main/java/com/stevesoltys/seedvault/metadata/MetadataWriter.kt b/app/src/main/java/com/stevesoltys/seedvault/metadata/MetadataWriter.kt index 643b534a..b55eb9ce 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/metadata/MetadataWriter.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/metadata/MetadataWriter.kt @@ -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) } diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/ApkBackup.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/ApkBackup.kt index e7a8372c..cbbd6c53 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/ApkBackup.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/ApkBackup.kt @@ -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): Boolean { diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinator.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinator.kt index 9daa0a81..d5aa8d28 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinator.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinator.kt @@ -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) diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupModule.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupModule.kt index 8775e0fe..d76dd68a 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupModule.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/BackupModule.kt @@ -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().kvBackupPlugin, get(), get(), get()) } single { FullBackup(get().fullBackupPlugin, get(), get(), get()) } single { BackupCoordinator(androidContext(), get(), get(), get(), get(), get(), get(), get(), get()) } diff --git a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/FullBackup.kt b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/FullBackup.kt index 4159e990..198e7bb5 100644 --- a/app/src/main/java/com/stevesoltys/seedvault/transport/backup/FullBackup.kt +++ b/app/src/main/java/com/stevesoltys/seedvault/transport/backup/FullBackup.kt @@ -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 } } diff --git a/app/src/test/java/com/stevesoltys/seedvault/metadata/MetadataManagerTest.kt b/app/src/test/java/com/stevesoltys/seedvault/metadata/MetadataManagerTest.kt index 193448a9..a18843d0 100644 --- a/app/src/test/java/com/stevesoltys/seedvault/metadata/MetadataManagerTest.kt +++ b/app/src/test/java/com/stevesoltys/seedvault/metadata/MetadataManagerTest.kt @@ -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 diff --git a/app/src/test/java/com/stevesoltys/seedvault/metadata/MetadataReaderTest.kt b/app/src/test/java/com/stevesoltys/seedvault/metadata/MetadataReaderTest.kt index 5263440e..88f1b6af 100644 --- a/app/src/test/java/com/stevesoltys/seedvault/metadata/MetadataReaderTest.kt +++ b/app/src/test/java/com/stevesoltys/seedvault/metadata/MetadataReaderTest.kt @@ -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().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 = HashMap()): BackupMetadata { + private fun getMetadata(packageMetadata: PackageMetadataMap = PackageMetadataMap()): BackupMetadata { return BackupMetadata( version = 1.toByte(), token = Random.nextLong(), diff --git a/app/src/test/java/com/stevesoltys/seedvault/metadata/MetadataWriterDecoderTest.kt b/app/src/test/java/com/stevesoltys/seedvault/metadata/MetadataWriterDecoderTest.kt index 235a26c9..dfdeefc7 100644 --- a/app/src/test/java/com/stevesoltys/seedvault/metadata/MetadataWriterDecoderTest.kt +++ b/app/src/test/java/com/stevesoltys/seedvault/metadata/MetadataWriterDecoderTest.kt @@ -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().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().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().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(), diff --git a/app/src/test/java/com/stevesoltys/seedvault/transport/CoordinatorIntegrationTest.kt b/app/src/test/java/com/stevesoltys/seedvault/transport/CoordinatorIntegrationTest.kt index e7e7c947..0780d02d 100644 --- a/app/src/test/java/com/stevesoltys/seedvault/transport/CoordinatorIntegrationTest.kt +++ b/app/src/test/java/com/stevesoltys/seedvault/transport/CoordinatorIntegrationTest.kt @@ -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 diff --git a/app/src/test/java/com/stevesoltys/seedvault/transport/backup/ApkBackupTest.kt b/app/src/test/java/com/stevesoltys/seedvault/transport/backup/ApkBackupTest.kt index 296a3128..9cab41b1 100644 --- a/app/src/test/java/com/stevesoltys/seedvault/transport/backup/ApkBackupTest.kt +++ b/app/src/test/java/com/stevesoltys/seedvault/transport/backup/ApkBackupTest.kt @@ -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()) } diff --git a/app/src/test/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinatorTest.kt b/app/src/test/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinatorTest.kt index e587e9c7..e8497d89 100644 --- a/app/src/test/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinatorTest.kt +++ b/app/src/test/java/com/stevesoltys/seedvault/transport/backup/BackupCoordinatorTest.kt @@ -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() private val kv = mockk() @@ -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() + 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 } }