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:
parent
3f73119b52
commit
3d296e1335
14 changed files with 312 additions and 90 deletions
|
@ -2,6 +2,7 @@ package com.stevesoltys.seedvault.metadata
|
||||||
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import com.stevesoltys.seedvault.header.VERSION
|
import com.stevesoltys.seedvault.header.VERSION
|
||||||
|
import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
|
|
||||||
typealias PackageMetadataMap = HashMap<String, PackageMetadata>
|
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_INCREMENTAL = "incremental"
|
||||||
internal const val JSON_METADATA_NAME = "name"
|
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(
|
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 version: Long? = null,
|
||||||
internal val installer: String? = null,
|
internal val installer: String? = null,
|
||||||
internal val sha256: 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_TIME = "time"
|
||||||
|
internal const val JSON_PACKAGE_STATE = "state"
|
||||||
internal const val JSON_PACKAGE_VERSION = "version"
|
internal const val JSON_PACKAGE_VERSION = "version"
|
||||||
internal const val JSON_PACKAGE_INSTALLER = "installer"
|
internal const val JSON_PACKAGE_INSTALLER = "installer"
|
||||||
internal const val JSON_PACKAGE_SHA256 = "sha256"
|
internal const val JSON_PACKAGE_SHA256 = "sha256"
|
||||||
|
|
|
@ -6,6 +6,7 @@ import android.util.Log
|
||||||
import androidx.annotation.VisibleForTesting
|
import androidx.annotation.VisibleForTesting
|
||||||
import androidx.annotation.WorkerThread
|
import androidx.annotation.WorkerThread
|
||||||
import com.stevesoltys.seedvault.Clock
|
import com.stevesoltys.seedvault.Clock
|
||||||
|
import com.stevesoltys.seedvault.metadata.PackageState.APK_AND_DATA
|
||||||
import java.io.FileNotFoundException
|
import java.io.FileNotFoundException
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.io.OutputStream
|
import java.io.OutputStream
|
||||||
|
@ -44,22 +45,21 @@ class MetadataManager(
|
||||||
@Synchronized
|
@Synchronized
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
fun onDeviceInitialization(token: Long, metadataOutputStream: OutputStream) {
|
fun onDeviceInitialization(token: Long, metadataOutputStream: OutputStream) {
|
||||||
metadata = BackupMetadata(token = token)
|
modifyMetadata(metadataOutputStream) {
|
||||||
metadataWriter.write(metadata, metadataOutputStream)
|
metadata = BackupMetadata(token = token)
|
||||||
writeMetadataToCache()
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Call this after an APK as been successfully written to backup storage.
|
* Call this after a package's APK has been backed up successfully.
|
||||||
* 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.
|
* It updates the packages' metadata
|
||||||
|
* and writes it encrypted to the given [OutputStream] as well as the internal cache.
|
||||||
*/
|
*/
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun onApkBackedUp(packageName: String, packageMetadata: PackageMetadata) {
|
@Throws(IOException::class)
|
||||||
|
fun onApkBackedUp(packageName: String, packageMetadata: PackageMetadata, metadataOutputStream: OutputStream) {
|
||||||
metadata.packageMetadataMap[packageName]?.let {
|
metadata.packageMetadataMap[packageName]?.let {
|
||||||
check(it.time <= packageMetadata.time) {
|
|
||||||
"APK backup set time of $packageName backwards"
|
|
||||||
}
|
|
||||||
check(packageMetadata.version != null) {
|
check(packageMetadata.version != null) {
|
||||||
"APK backup returned 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}"
|
"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
|
@Synchronized
|
||||||
@Throws(IOException::class)
|
@Throws(IOException::class)
|
||||||
fun onPackageBackedUp(packageName: String, metadataOutputStream: OutputStream) {
|
fun onPackageBackedUp(packageName: String, metadataOutputStream: OutputStream) {
|
||||||
val oldMetadata = metadata.copy()
|
modifyMetadata(metadataOutputStream) {
|
||||||
val now = clock.time()
|
val now = clock.time()
|
||||||
metadata.time = now
|
metadata.time = now
|
||||||
if (metadata.packageMetadataMap.containsKey(packageName)) {
|
if (metadata.packageMetadataMap.containsKey(packageName)) {
|
||||||
metadata.packageMetadataMap[packageName]?.time = now
|
metadata.packageMetadataMap[packageName]!!.time = now
|
||||||
} else {
|
metadata.packageMetadataMap[packageName]!!.state = APK_AND_DATA
|
||||||
metadata.packageMetadataMap[packageName] = PackageMetadata(time = now)
|
} 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 {
|
try {
|
||||||
|
modFun.invoke()
|
||||||
metadataWriter.write(metadata, metadataOutputStream)
|
metadataWriter.write(metadata, metadataOutputStream)
|
||||||
|
writeMetadataToCache()
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
Log.w(TAG, "Error writing metadata to storage", e)
|
Log.w(TAG, "Error writing metadata to storage", e)
|
||||||
// revert metadata and do not write it to cache
|
// revert metadata and do not write it to cache
|
||||||
// TODO also revert changes made by last [onApkBackedUp]
|
|
||||||
metadata = oldMetadata
|
metadata = oldMetadata
|
||||||
throw IOException(e)
|
throw IOException(e)
|
||||||
}
|
}
|
||||||
writeMetadataToCache()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -4,6 +4,7 @@ import com.stevesoltys.seedvault.Utf8
|
||||||
import com.stevesoltys.seedvault.crypto.Crypto
|
import com.stevesoltys.seedvault.crypto.Crypto
|
||||||
import com.stevesoltys.seedvault.header.UnsupportedVersionException
|
import com.stevesoltys.seedvault.header.UnsupportedVersionException
|
||||||
import com.stevesoltys.seedvault.header.VERSION
|
import com.stevesoltys.seedvault.header.VERSION
|
||||||
|
import com.stevesoltys.seedvault.metadata.PackageState.*
|
||||||
import org.json.JSONException
|
import org.json.JSONException
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
@ -59,8 +60,14 @@ internal class MetadataReaderImpl(private val crypto: Crypto) : MetadataReader {
|
||||||
for (packageName in json.keys()) {
|
for (packageName in json.keys()) {
|
||||||
if (packageName == JSON_METADATA) continue
|
if (packageName == JSON_METADATA) continue
|
||||||
val p = json.getJSONObject(packageName)
|
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 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 pSha256 = p.optString(JSON_PACKAGE_SHA256)
|
||||||
val pSignatures = p.optJSONArray(JSON_PACKAGE_SIGNATURES)
|
val pSignatures = p.optJSONArray(JSON_PACKAGE_SIGNATURES)
|
||||||
val signatures = if (pSignatures == null) null else
|
val signatures = if (pSignatures == null) null else
|
||||||
|
@ -71,6 +78,7 @@ internal class MetadataReaderImpl(private val crypto: Crypto) : MetadataReader {
|
||||||
}
|
}
|
||||||
packageMetadataMap[packageName] = PackageMetadata(
|
packageMetadataMap[packageName] = PackageMetadata(
|
||||||
time = p.getLong(JSON_PACKAGE_TIME),
|
time = p.getLong(JSON_PACKAGE_TIME),
|
||||||
|
state = pState,
|
||||||
version = if (pVersion == 0L) null else pVersion,
|
version = if (pVersion == 0L) null else pVersion,
|
||||||
installer = if (pInstaller == "") null else pInstaller,
|
installer = if (pInstaller == "") null else pInstaller,
|
||||||
sha256 = if (pSha256 == "") null else pSha256,
|
sha256 = if (pSha256 == "") null else pSha256,
|
||||||
|
|
|
@ -2,6 +2,7 @@ package com.stevesoltys.seedvault.metadata
|
||||||
|
|
||||||
import com.stevesoltys.seedvault.Utf8
|
import com.stevesoltys.seedvault.Utf8
|
||||||
import com.stevesoltys.seedvault.crypto.Crypto
|
import com.stevesoltys.seedvault.crypto.Crypto
|
||||||
|
import com.stevesoltys.seedvault.metadata.PackageState.APK_AND_DATA
|
||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
@ -36,6 +37,9 @@ internal class MetadataWriterImpl(private val crypto: Crypto) : MetadataWriter {
|
||||||
for ((packageName, packageMetadata) in metadata.packageMetadataMap) {
|
for ((packageName, packageMetadata) in metadata.packageMetadataMap) {
|
||||||
json.put(packageName, JSONObject().apply {
|
json.put(packageName, JSONObject().apply {
|
||||||
put(JSON_PACKAGE_TIME, packageMetadata.time)
|
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.version?.let { put(JSON_PACKAGE_VERSION, it) }
|
||||||
packageMetadata.installer?.let { put(JSON_PACKAGE_INSTALLER, it) }
|
packageMetadata.installer?.let { put(JSON_PACKAGE_INSTALLER, it) }
|
||||||
packageMetadata.sha256?.let { put(JSON_PACKAGE_SHA256, it) }
|
packageMetadata.sha256?.let { put(JSON_PACKAGE_SHA256, it) }
|
||||||
|
|
|
@ -8,7 +8,6 @@ import android.content.pm.Signature
|
||||||
import android.content.pm.SigningInfo
|
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.Clock
|
|
||||||
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
|
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
|
||||||
import com.stevesoltys.seedvault.encodeBase64
|
import com.stevesoltys.seedvault.encodeBase64
|
||||||
import com.stevesoltys.seedvault.metadata.MetadataManager
|
import com.stevesoltys.seedvault.metadata.MetadataManager
|
||||||
|
@ -24,43 +23,50 @@ private val TAG = ApkBackup::class.java.simpleName
|
||||||
|
|
||||||
class ApkBackup(
|
class ApkBackup(
|
||||||
private val pm: PackageManager,
|
private val pm: PackageManager,
|
||||||
private val clock: Clock,
|
|
||||||
private val settingsManager: SettingsManager,
|
private val settingsManager: SettingsManager,
|
||||||
private val metadataManager: MetadataManager) {
|
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)
|
@Throws(IOException::class)
|
||||||
fun backupApkIfNecessary(packageInfo: PackageInfo, streamGetter: () -> OutputStream): Boolean {
|
fun backupApkIfNecessary(packageInfo: PackageInfo, streamGetter: () -> 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 false
|
if (packageName == MAGIC_PACKAGE_MANAGER) return null
|
||||||
|
|
||||||
// do not back up when setting is not enabled
|
// 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
|
// do not back up system apps that haven't been updated
|
||||||
val isSystemApp = packageInfo.applicationInfo.flags and FLAG_SYSTEM != 0
|
val isSystemApp = packageInfo.applicationInfo.flags and FLAG_SYSTEM != 0
|
||||||
val isUpdatedSystemApp = packageInfo.applicationInfo.flags and FLAG_UPDATED_SYSTEM_APP != 0
|
val isUpdatedSystemApp = packageInfo.applicationInfo.flags and FLAG_UPDATED_SYSTEM_APP != 0
|
||||||
if (isSystemApp && !isUpdatedSystemApp) {
|
if (isSystemApp && !isUpdatedSystemApp) {
|
||||||
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 false
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO remove when adding support for packages with multiple signers
|
// TODO remove when adding support for packages with multiple signers
|
||||||
if (packageInfo.signingInfo.hasMultipleSigners()) {
|
if (packageInfo.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 false
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// get signatures
|
// get signatures
|
||||||
val signatures = packageInfo.signingInfo.getSignatures()
|
val signatures = packageInfo.signingInfo.getSignatures()
|
||||||
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 false
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// get cached metadata about package
|
// get cached metadata about package
|
||||||
val packageMetadata = metadataManager.getPackageMetadata(packageName)
|
val packageMetadata = metadataManager.getPackageMetadata(packageName)
|
||||||
?: PackageMetadata(time = clock.time())
|
?: PackageMetadata()
|
||||||
|
|
||||||
// get version codes
|
// get version codes
|
||||||
val version = packageInfo.longVersionCode
|
val version = packageInfo.longVersionCode
|
||||||
|
@ -69,7 +75,7 @@ class ApkBackup(
|
||||||
// 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(packageMetadata, signatures)) {
|
||||||
Log.d(TAG, "Package $packageName with version $version already has a backup ($backedUpVersion) with the same signature. Not backing it up.")
|
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
|
// get an InputStream for the APK
|
||||||
|
@ -100,17 +106,13 @@ class ApkBackup(
|
||||||
val sha256 = messageDigest.digest().encodeBase64()
|
val sha256 = messageDigest.digest().encodeBase64()
|
||||||
Log.d(TAG, "Backed up new APK of $packageName with version $version.")
|
Log.d(TAG, "Backed up new APK of $packageName with version $version.")
|
||||||
|
|
||||||
// update the metadata
|
// return updated metadata
|
||||||
val installer = pm.getInstallerPackageName(packageName)
|
return PackageMetadata(
|
||||||
val updatedMetadata = PackageMetadata(
|
|
||||||
time = clock.time(),
|
|
||||||
version = version,
|
version = version,
|
||||||
installer = installer,
|
installer = pm.getInstallerPackageName(packageName),
|
||||||
sha256 = sha256,
|
sha256 = sha256,
|
||||||
signatures = signatures
|
signatures = signatures
|
||||||
)
|
)
|
||||||
metadataManager.onApkBackedUp(packageName, updatedMetadata)
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun signaturesChanged(packageMetadata: PackageMetadata, signatures: List<String>): Boolean {
|
private fun signaturesChanged(packageMetadata: PackageMetadata, signatures: List<String>): Boolean {
|
||||||
|
|
|
@ -9,6 +9,8 @@ import com.stevesoltys.seedvault.BackupNotificationManager
|
||||||
import com.stevesoltys.seedvault.Clock
|
import com.stevesoltys.seedvault.Clock
|
||||||
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
|
import com.stevesoltys.seedvault.MAGIC_PACKAGE_MANAGER
|
||||||
import com.stevesoltys.seedvault.metadata.MetadataManager
|
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 com.stevesoltys.seedvault.settings.SettingsManager
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.util.concurrent.TimeUnit.DAYS
|
import java.util.concurrent.TimeUnit.DAYS
|
||||||
|
@ -32,6 +34,7 @@ internal class BackupCoordinator(
|
||||||
|
|
||||||
private var calledInitialize = false
|
private var calledInitialize = false
|
||||||
private var calledClearBackupData = false
|
private var calledClearBackupData = false
|
||||||
|
private var cancelReason: PackageState = UNKNOWN_ERROR
|
||||||
|
|
||||||
// ------------------------------------------------------------------------------------
|
// ------------------------------------------------------------------------------------
|
||||||
// Transport initialization and quota
|
// Transport initialization and quota
|
||||||
|
@ -110,13 +113,15 @@ internal class BackupCoordinator(
|
||||||
}
|
}
|
||||||
|
|
||||||
fun performIncrementalBackup(packageInfo: PackageInfo, data: ParcelFileDescriptor, flags: Int): Int {
|
fun performIncrementalBackup(packageInfo: PackageInfo, data: ParcelFileDescriptor, flags: Int): Int {
|
||||||
|
cancelReason = UNKNOWN_ERROR
|
||||||
val packageName = packageInfo.packageName
|
val packageName = packageInfo.packageName
|
||||||
// backups of package manager metadata do not respect backoff
|
// 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
|
// we need to reject them manually when now is not a good time for a backup
|
||||||
if (packageName == MAGIC_PACKAGE_MANAGER && getBackupBackoff() != 0L) {
|
if (packageName == MAGIC_PACKAGE_MANAGER && getBackupBackoff() != 0L) {
|
||||||
return TRANSPORT_PACKAGE_REJECTED
|
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")
|
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 {
|
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)
|
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].
|
* If the transport receives this callback, it will *not* receive a call to [finishBackup].
|
||||||
* It needs to tear down any ongoing backup state here.
|
* 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
|
// Clear and Finish
|
||||||
|
|
||||||
|
@ -193,6 +211,14 @@ internal class BackupCoordinator(
|
||||||
return TRANSPORT_OK
|
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 {
|
fun finishBackup(): Int = when {
|
||||||
kv.hasState() -> {
|
kv.hasState() -> {
|
||||||
check(!full.hasState()) { "K/V backup has state, but full backup has dangling state as well" }
|
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()")
|
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) {
|
private fun onPackageBackedUp(packageInfo: PackageInfo) {
|
||||||
val packageName = packageInfo.packageName
|
val packageName = packageInfo.packageName
|
||||||
try {
|
try {
|
||||||
apkBackup.backupApkIfNecessary(packageInfo) { plugin.getApkOutputStream(packageInfo) }
|
|
||||||
val outputStream = plugin.getMetadataOutputStream()
|
val outputStream = plugin.getMetadataOutputStream()
|
||||||
metadataManager.onPackageBackedUp(packageName, outputStream)
|
metadataManager.onPackageBackedUp(packageName, outputStream)
|
||||||
} catch (e: IOException) {
|
} 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 {
|
private fun getBackupBackoff(): Long {
|
||||||
val noBackoff = 0L
|
val noBackoff = 0L
|
||||||
val defaultBackoff = DAYS.toMillis(30)
|
val defaultBackoff = DAYS.toMillis(30)
|
||||||
|
|
|
@ -5,7 +5,7 @@ import org.koin.dsl.module
|
||||||
|
|
||||||
val backupModule = module {
|
val backupModule = module {
|
||||||
single { InputFactory() }
|
single { InputFactory() }
|
||||||
single { ApkBackup(androidContext().packageManager, get(), get(), get()) }
|
single { ApkBackup(androidContext().packageManager, get(), get()) }
|
||||||
single { KVBackup(get<BackupPlugin>().kvBackupPlugin, get(), get(), get()) }
|
single { KVBackup(get<BackupPlugin>().kvBackupPlugin, get(), get(), get()) }
|
||||||
single { FullBackup(get<BackupPlugin>().fullBackupPlugin, get(), get(), get()) }
|
single { FullBackup(get<BackupPlugin>().fullBackupPlugin, get(), get(), get()) }
|
||||||
single { BackupCoordinator(androidContext(), get(), get(), get(), get(), get(), get(), get(), get()) }
|
single { BackupCoordinator(androidContext(), get(), get(), get(), get(), get(), get(), get(), get()) }
|
||||||
|
|
|
@ -45,7 +45,7 @@ internal class FullBackup(
|
||||||
Log.i(TAG, "Check full backup size of $size bytes.")
|
Log.i(TAG, "Check full backup size of $size bytes.")
|
||||||
return when {
|
return when {
|
||||||
size <= 0 -> TRANSPORT_PACKAGE_REJECTED
|
size <= 0 -> TRANSPORT_PACKAGE_REJECTED
|
||||||
size > plugin.getQuota() -> TRANSPORT_QUOTA_EXCEEDED
|
size > getQuota() -> TRANSPORT_QUOTA_EXCEEDED
|
||||||
else -> TRANSPORT_OK
|
else -> TRANSPORT_OK
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,6 +6,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import com.stevesoltys.seedvault.Clock
|
import com.stevesoltys.seedvault.Clock
|
||||||
import com.stevesoltys.seedvault.getRandomByteArray
|
import com.stevesoltys.seedvault.getRandomByteArray
|
||||||
import com.stevesoltys.seedvault.getRandomString
|
import com.stevesoltys.seedvault.getRandomString
|
||||||
|
import com.stevesoltys.seedvault.metadata.PackageState.APK_AND_DATA
|
||||||
import io.mockk.Runs
|
import io.mockk.Runs
|
||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
import io.mockk.just
|
import io.mockk.just
|
||||||
|
@ -46,8 +47,8 @@ class MetadataManagerTest {
|
||||||
@Test
|
@Test
|
||||||
fun `test onDeviceInitialization()`() {
|
fun `test onDeviceInitialization()`() {
|
||||||
every { clock.time() } returns time
|
every { clock.time() } returns time
|
||||||
every { metadataWriter.write(initialMetadata, storageOutputStream) } just Runs
|
expectReadFromCache()
|
||||||
expectWriteToCache(initialMetadata)
|
expectModifyMetadata(initialMetadata)
|
||||||
|
|
||||||
manager.onDeviceInitialization(token, storageOutputStream)
|
manager.onDeviceInitialization(token, storageOutputStream)
|
||||||
|
|
||||||
|
@ -58,15 +59,16 @@ class MetadataManagerTest {
|
||||||
@Test
|
@Test
|
||||||
fun `test onApkBackedUp() with no prior package metadata`() {
|
fun `test onApkBackedUp() with no prior package metadata`() {
|
||||||
val packageMetadata = PackageMetadata(
|
val packageMetadata = PackageMetadata(
|
||||||
time = time + 1,
|
time = 0L,
|
||||||
version = Random.nextLong(Long.MAX_VALUE),
|
version = Random.nextLong(Long.MAX_VALUE),
|
||||||
installer = getRandomString(),
|
installer = getRandomString(),
|
||||||
signatures = listOf("sig")
|
signatures = listOf("sig")
|
||||||
)
|
)
|
||||||
|
|
||||||
expectReadFromCache()
|
expectReadFromCache()
|
||||||
|
expectModifyMetadata(initialMetadata)
|
||||||
|
|
||||||
manager.onApkBackedUp(packageName, packageMetadata)
|
manager.onApkBackedUp(packageName, packageMetadata, storageOutputStream)
|
||||||
|
|
||||||
assertEquals(packageMetadata, manager.getPackageMetadata(packageName))
|
assertEquals(packageMetadata, manager.getPackageMetadata(packageName))
|
||||||
}
|
}
|
||||||
|
@ -81,15 +83,16 @@ class MetadataManagerTest {
|
||||||
)
|
)
|
||||||
initialMetadata.packageMetadataMap[packageName] = packageMetadata
|
initialMetadata.packageMetadataMap[packageName] = packageMetadata
|
||||||
val updatedPackageMetadata = PackageMetadata(
|
val updatedPackageMetadata = PackageMetadata(
|
||||||
time = time + 1,
|
time = time,
|
||||||
version = packageMetadata.version!! + 1,
|
version = packageMetadata.version!! + 1,
|
||||||
installer = getRandomString(),
|
installer = getRandomString(),
|
||||||
signatures = listOf("sig foo")
|
signatures = listOf("sig foo")
|
||||||
)
|
)
|
||||||
|
|
||||||
expectReadFromCache()
|
expectReadFromCache()
|
||||||
|
expectModifyMetadata(initialMetadata)
|
||||||
|
|
||||||
manager.onApkBackedUp(packageName, updatedPackageMetadata)
|
manager.onApkBackedUp(packageName, updatedPackageMetadata, storageOutputStream)
|
||||||
|
|
||||||
assertEquals(updatedPackageMetadata, manager.getPackageMetadata(packageName))
|
assertEquals(updatedPackageMetadata, manager.getPackageMetadata(packageName))
|
||||||
}
|
}
|
||||||
|
@ -102,8 +105,7 @@ class MetadataManagerTest {
|
||||||
|
|
||||||
expectReadFromCache()
|
expectReadFromCache()
|
||||||
every { clock.time() } returns time
|
every { clock.time() } returns time
|
||||||
every { metadataWriter.write(updatedMetadata, storageOutputStream) } just Runs
|
expectModifyMetadata(updatedMetadata)
|
||||||
expectWriteToCache(updatedMetadata)
|
|
||||||
|
|
||||||
manager.onPackageBackedUp(packageName, storageOutputStream)
|
manager.onPackageBackedUp(packageName, storageOutputStream)
|
||||||
|
|
||||||
|
@ -142,19 +144,18 @@ class MetadataManagerTest {
|
||||||
cachedMetadata.packageMetadataMap[packageName] = PackageMetadata(cacheTime)
|
cachedMetadata.packageMetadataMap[packageName] = PackageMetadata(cacheTime)
|
||||||
|
|
||||||
val updatedMetadata = cachedMetadata.copy(time = time)
|
val updatedMetadata = cachedMetadata.copy(time = time)
|
||||||
cachedMetadata.packageMetadataMap[cachedPackageName] = PackageMetadata(time)
|
updatedMetadata.packageMetadataMap[cachedPackageName] = PackageMetadata(time)
|
||||||
cachedMetadata.packageMetadataMap[packageName] = PackageMetadata(time)
|
updatedMetadata.packageMetadataMap[packageName] = PackageMetadata(time, state = APK_AND_DATA)
|
||||||
|
|
||||||
expectReadFromCache()
|
expectReadFromCache()
|
||||||
every { clock.time() } returns time
|
every { clock.time() } returns time
|
||||||
every { metadataWriter.write(updatedMetadata, storageOutputStream) } just Runs
|
expectModifyMetadata(updatedMetadata)
|
||||||
expectWriteToCache(updatedMetadata)
|
|
||||||
|
|
||||||
manager.onPackageBackedUp(packageName, storageOutputStream)
|
manager.onPackageBackedUp(packageName, storageOutputStream)
|
||||||
|
|
||||||
assertEquals(time, manager.getLastBackupTime())
|
assertEquals(time, manager.getLastBackupTime())
|
||||||
assertEquals(PackageMetadata(time), manager.getPackageMetadata(cachedPackageName))
|
assertEquals(PackageMetadata(time), manager.getPackageMetadata(cachedPackageName))
|
||||||
assertEquals(PackageMetadata(time), manager.getPackageMetadata(packageName))
|
assertEquals(updatedMetadata.packageMetadataMap[packageName], manager.getPackageMetadata(packageName))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
@ -181,7 +182,8 @@ class MetadataManagerTest {
|
||||||
assertEquals(initialMetadata.token, manager.getBackupToken())
|
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 { metadataWriter.encode(metadata) } returns encodedMetadata
|
||||||
every { context.openFileOutput(METADATA_CACHE_FILE, MODE_PRIVATE) } returns cacheOutputStream
|
every { context.openFileOutput(METADATA_CACHE_FILE, MODE_PRIVATE) } returns cacheOutputStream
|
||||||
every { cacheOutputStream.write(encodedMetadata) } just Runs
|
every { cacheOutputStream.write(encodedMetadata) } just Runs
|
||||||
|
|
|
@ -3,6 +3,8 @@ package com.stevesoltys.seedvault.metadata
|
||||||
import com.stevesoltys.seedvault.Utf8
|
import com.stevesoltys.seedvault.Utf8
|
||||||
import com.stevesoltys.seedvault.crypto.Crypto
|
import com.stevesoltys.seedvault.crypto.Crypto
|
||||||
import com.stevesoltys.seedvault.getRandomString
|
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 io.mockk.mockk
|
||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
|
@ -82,6 +84,7 @@ class MetadataReaderTest {
|
||||||
val packageMetadata = HashMap<String, PackageMetadata>().apply {
|
val packageMetadata = HashMap<String, PackageMetadata>().apply {
|
||||||
put("org.example", PackageMetadata(
|
put("org.example", PackageMetadata(
|
||||||
time = Random.nextLong(),
|
time = Random.nextLong(),
|
||||||
|
state = QUOTA_EXCEEDED,
|
||||||
version = Random.nextLong(),
|
version = Random.nextLong(),
|
||||||
installer = getRandomString(),
|
installer = getRandomString(),
|
||||||
sha256 = 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
|
@Test
|
||||||
fun `package metadata can only include time`() {
|
fun `package metadata can only include time`() {
|
||||||
val json = JSONObject(metadataByteArray.toString(Utf8))
|
val json = JSONObject(metadataByteArray.toString(Utf8))
|
||||||
|
@ -124,7 +143,7 @@ class MetadataReaderTest {
|
||||||
assertNull(packageMetadata.signatures)
|
assertNull(packageMetadata.signatures)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getMetadata(packageMetadata: HashMap<String, PackageMetadata> = HashMap()): BackupMetadata {
|
private fun getMetadata(packageMetadata: PackageMetadataMap = PackageMetadataMap()): BackupMetadata {
|
||||||
return BackupMetadata(
|
return BackupMetadata(
|
||||||
version = 1.toByte(),
|
version = 1.toByte(),
|
||||||
token = Random.nextLong(),
|
token = Random.nextLong(),
|
||||||
|
|
|
@ -2,6 +2,7 @@ package com.stevesoltys.seedvault.metadata
|
||||||
|
|
||||||
import com.stevesoltys.seedvault.crypto.Crypto
|
import com.stevesoltys.seedvault.crypto.Crypto
|
||||||
import com.stevesoltys.seedvault.getRandomString
|
import com.stevesoltys.seedvault.getRandomString
|
||||||
|
import com.stevesoltys.seedvault.metadata.PackageState.*
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
|
@ -27,7 +28,7 @@ internal class MetadataWriterDecoderTest {
|
||||||
fun `encoded metadata matches decoded metadata (with package, no apk info)`() {
|
fun `encoded metadata matches decoded metadata (with package, no apk info)`() {
|
||||||
val time = Random.nextLong()
|
val time = Random.nextLong()
|
||||||
val packages = HashMap<String, PackageMetadata>().apply {
|
val packages = HashMap<String, PackageMetadata>().apply {
|
||||||
put(getRandomString(), PackageMetadata(time))
|
put(getRandomString(), PackageMetadata(time, APK_AND_DATA))
|
||||||
}
|
}
|
||||||
val metadata = getMetadata(packages)
|
val metadata = getMetadata(packages)
|
||||||
assertEquals(metadata, decoder.decode(encoder.encode(metadata), metadata.version, metadata.token))
|
assertEquals(metadata, decoder.decode(encoder.encode(metadata), metadata.version, metadata.token))
|
||||||
|
@ -38,6 +39,7 @@ internal class MetadataWriterDecoderTest {
|
||||||
val packages = HashMap<String, PackageMetadata>().apply {
|
val packages = HashMap<String, PackageMetadata>().apply {
|
||||||
put(getRandomString(), PackageMetadata(
|
put(getRandomString(), PackageMetadata(
|
||||||
time = Random.nextLong(),
|
time = Random.nextLong(),
|
||||||
|
state = APK_AND_DATA,
|
||||||
version = Random.nextLong(),
|
version = Random.nextLong(),
|
||||||
installer = getRandomString(),
|
installer = getRandomString(),
|
||||||
sha256 = getRandomString(),
|
sha256 = getRandomString(),
|
||||||
|
@ -52,6 +54,7 @@ internal class MetadataWriterDecoderTest {
|
||||||
val packages = HashMap<String, PackageMetadata>().apply {
|
val packages = HashMap<String, PackageMetadata>().apply {
|
||||||
put(getRandomString(), PackageMetadata(
|
put(getRandomString(), PackageMetadata(
|
||||||
time = Random.nextLong(),
|
time = Random.nextLong(),
|
||||||
|
state = QUOTA_EXCEEDED,
|
||||||
version = Random.nextLong(),
|
version = Random.nextLong(),
|
||||||
installer = getRandomString(),
|
installer = getRandomString(),
|
||||||
sha256 = getRandomString(),
|
sha256 = getRandomString(),
|
||||||
|
@ -59,6 +62,7 @@ internal class MetadataWriterDecoderTest {
|
||||||
))
|
))
|
||||||
put(getRandomString(), PackageMetadata(
|
put(getRandomString(), PackageMetadata(
|
||||||
time = Random.nextLong(),
|
time = Random.nextLong(),
|
||||||
|
state = NO_DATA,
|
||||||
version = Random.nextLong(),
|
version = Random.nextLong(),
|
||||||
installer = getRandomString(),
|
installer = getRandomString(),
|
||||||
sha256 = getRandomString(),
|
sha256 = getRandomString(),
|
||||||
|
|
|
@ -16,6 +16,7 @@ import com.stevesoltys.seedvault.header.HeaderReaderImpl
|
||||||
import com.stevesoltys.seedvault.header.HeaderWriterImpl
|
import com.stevesoltys.seedvault.header.HeaderWriterImpl
|
||||||
import com.stevesoltys.seedvault.header.MAX_SEGMENT_CLEARTEXT_LENGTH
|
import com.stevesoltys.seedvault.header.MAX_SEGMENT_CLEARTEXT_LENGTH
|
||||||
import com.stevesoltys.seedvault.metadata.MetadataReaderImpl
|
import com.stevesoltys.seedvault.metadata.MetadataReaderImpl
|
||||||
|
import com.stevesoltys.seedvault.metadata.PackageMetadata
|
||||||
import com.stevesoltys.seedvault.transport.backup.*
|
import com.stevesoltys.seedvault.transport.backup.*
|
||||||
import com.stevesoltys.seedvault.transport.restore.*
|
import com.stevesoltys.seedvault.transport.restore.*
|
||||||
import io.mockk.*
|
import io.mockk.*
|
||||||
|
@ -23,7 +24,6 @@ import org.junit.jupiter.api.Assertions.*
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
import java.io.ByteArrayInputStream
|
import java.io.ByteArrayInputStream
|
||||||
import java.io.ByteArrayOutputStream
|
import java.io.ByteArrayOutputStream
|
||||||
import java.io.OutputStream
|
|
||||||
import kotlin.random.Random
|
import kotlin.random.Random
|
||||||
|
|
||||||
internal class CoordinatorIntegrationTest : TransportTest() {
|
internal class CoordinatorIntegrationTest : TransportTest() {
|
||||||
|
@ -59,6 +59,7 @@ internal class CoordinatorIntegrationTest : TransportTest() {
|
||||||
private val appData = ByteArray(42).apply { Random.nextBytes(this) }
|
private val appData = ByteArray(42).apply { Random.nextBytes(this) }
|
||||||
private val appData2 = ByteArray(1337).apply { Random.nextBytes(this) }
|
private val appData2 = ByteArray(1337).apply { Random.nextBytes(this) }
|
||||||
private val metadataOutputStream = ByteArrayOutputStream()
|
private val metadataOutputStream = ByteArrayOutputStream()
|
||||||
|
private val packageMetadata = PackageMetadata(time = 0L)
|
||||||
private val key = "RestoreKey"
|
private val key = "RestoreKey"
|
||||||
private val key64 = key.encodeBase64()
|
private val key64 = key.encodeBase64()
|
||||||
private val key2 = "RestoreKey2"
|
private val key2 = "RestoreKey2"
|
||||||
|
@ -93,8 +94,9 @@ internal class CoordinatorIntegrationTest : TransportTest() {
|
||||||
appData2.size
|
appData2.size
|
||||||
}
|
}
|
||||||
every { kvBackupPlugin.getOutputStreamForRecord(packageInfo, key264) } returns bOutputStream2
|
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 { backupPlugin.getMetadataOutputStream() } returns metadataOutputStream
|
||||||
|
every { metadataManager.onApkBackedUp(packageInfo.packageName, packageMetadata, metadataOutputStream) } just Runs
|
||||||
every { metadataManager.onPackageBackedUp(packageInfo.packageName, metadataOutputStream) } just Runs
|
every { metadataManager.onPackageBackedUp(packageInfo.packageName, metadataOutputStream) } just Runs
|
||||||
|
|
||||||
// start and finish K/V backup
|
// start and finish K/V backup
|
||||||
|
@ -146,7 +148,7 @@ internal class CoordinatorIntegrationTest : TransportTest() {
|
||||||
appData.size
|
appData.size
|
||||||
}
|
}
|
||||||
every { kvBackupPlugin.getOutputStreamForRecord(packageInfo, key64) } returns bOutputStream
|
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 { backupPlugin.getMetadataOutputStream() } returns metadataOutputStream
|
||||||
every { metadataManager.onPackageBackedUp(packageInfo.packageName, metadataOutputStream) } just Runs
|
every { metadataManager.onPackageBackedUp(packageInfo.packageName, metadataOutputStream) } just Runs
|
||||||
|
|
||||||
|
@ -184,8 +186,9 @@ internal class CoordinatorIntegrationTest : TransportTest() {
|
||||||
every { fullBackupPlugin.getOutputStream(packageInfo) } returns bOutputStream
|
every { fullBackupPlugin.getOutputStream(packageInfo) } returns bOutputStream
|
||||||
every { inputFactory.getInputStream(fileDescriptor) } returns bInputStream
|
every { inputFactory.getInputStream(fileDescriptor) } returns bInputStream
|
||||||
every { fullBackupPlugin.getQuota() } returns DEFAULT_QUOTA_FULL_BACKUP
|
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 { backupPlugin.getMetadataOutputStream() } returns metadataOutputStream
|
||||||
|
every { metadataManager.onApkBackedUp(packageInfo.packageName, packageMetadata, metadataOutputStream) } just Runs
|
||||||
every { metadataManager.onPackageBackedUp(packageInfo.packageName, metadataOutputStream) } just Runs
|
every { metadataManager.onPackageBackedUp(packageInfo.packageName, metadataOutputStream) } just Runs
|
||||||
|
|
||||||
// perform backup to output stream
|
// perform backup to output stream
|
||||||
|
|
|
@ -9,6 +9,7 @@ import android.util.PackageUtils
|
||||||
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.PackageMetadata
|
import com.stevesoltys.seedvault.metadata.PackageMetadata
|
||||||
|
import com.stevesoltys.seedvault.metadata.PackageState.UNKNOWN_ERROR
|
||||||
import io.mockk.*
|
import io.mockk.*
|
||||||
import org.junit.jupiter.api.Assertions.*
|
import org.junit.jupiter.api.Assertions.*
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
|
@ -26,7 +27,7 @@ internal class ApkBackupTest : BackupTest() {
|
||||||
private val pm: PackageManager = mockk()
|
private val pm: PackageManager = mockk()
|
||||||
private val streamGetter: () -> OutputStream = 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 signatureBytes = byteArrayOf(0x01, 0x02, 0x03)
|
||||||
private val signatureHash = byteArrayOf(0x03, 0x02, 0x01)
|
private val signatureHash = byteArrayOf(0x03, 0x02, 0x01)
|
||||||
|
@ -44,14 +45,14 @@ internal class ApkBackupTest : BackupTest() {
|
||||||
@Test
|
@Test
|
||||||
fun `does not back up @pm@`() {
|
fun `does not back up @pm@`() {
|
||||||
val packageInfo = PackageInfo().apply { packageName = MAGIC_PACKAGE_MANAGER }
|
val packageInfo = PackageInfo().apply { packageName = MAGIC_PACKAGE_MANAGER }
|
||||||
assertFalse(apkBackup.backupApkIfNecessary(packageInfo, streamGetter))
|
assertNull(apkBackup.backupApkIfNecessary(packageInfo, streamGetter))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `does not back up when setting disabled`() {
|
fun `does not back up when setting disabled`() {
|
||||||
every { settingsManager.backupApks() } returns false
|
every { settingsManager.backupApks() } returns false
|
||||||
|
|
||||||
assertFalse(apkBackup.backupApkIfNecessary(packageInfo, streamGetter))
|
assertNull(apkBackup.backupApkIfNecessary(packageInfo, streamGetter))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
@ -60,7 +61,7 @@ internal class ApkBackupTest : BackupTest() {
|
||||||
|
|
||||||
every { settingsManager.backupApks() } returns true
|
every { settingsManager.backupApks() } returns true
|
||||||
|
|
||||||
assertFalse(apkBackup.backupApkIfNecessary(packageInfo, streamGetter))
|
assertNull(apkBackup.backupApkIfNecessary(packageInfo, streamGetter))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
@ -72,7 +73,7 @@ internal class ApkBackupTest : BackupTest() {
|
||||||
|
|
||||||
expectChecks(packageMetadata)
|
expectChecks(packageMetadata)
|
||||||
|
|
||||||
assertFalse(apkBackup.backupApkIfNecessary(packageInfo, streamGetter))
|
assertNull(apkBackup.backupApkIfNecessary(packageInfo, streamGetter))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
@ -82,7 +83,7 @@ internal class ApkBackupTest : BackupTest() {
|
||||||
expectChecks()
|
expectChecks()
|
||||||
|
|
||||||
assertThrows(IOException::class.java) {
|
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.hasMultipleSigners() } returns false
|
||||||
every { sigInfo.signingCertificateHistory } returns emptyArray()
|
every { sigInfo.signingCertificateHistory } returns emptyArray()
|
||||||
|
|
||||||
assertFalse(apkBackup.backupApkIfNecessary(packageInfo, streamGetter))
|
assertNull(apkBackup.backupApkIfNecessary(packageInfo, streamGetter))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
@ -106,7 +107,8 @@ internal class ApkBackupTest : BackupTest() {
|
||||||
}.absolutePath
|
}.absolutePath
|
||||||
val apkOutputStream = ByteArrayOutputStream()
|
val apkOutputStream = ByteArrayOutputStream()
|
||||||
val updatedMetadata = PackageMetadata(
|
val updatedMetadata = PackageMetadata(
|
||||||
time = Random.nextLong(),
|
time = 0L,
|
||||||
|
state = UNKNOWN_ERROR,
|
||||||
version = packageInfo.longVersionCode,
|
version = packageInfo.longVersionCode,
|
||||||
installer = getRandomString(),
|
installer = getRandomString(),
|
||||||
sha256 = "eHx5jjmlvBkQNVuubQzYejay4Q_QICqD47trAF2oNHI",
|
sha256 = "eHx5jjmlvBkQNVuubQzYejay4Q_QICqD47trAF2oNHI",
|
||||||
|
@ -116,10 +118,9 @@ internal class ApkBackupTest : BackupTest() {
|
||||||
expectChecks()
|
expectChecks()
|
||||||
every { streamGetter.invoke() } returns apkOutputStream
|
every { streamGetter.invoke() } returns apkOutputStream
|
||||||
every { pm.getInstallerPackageName(packageInfo.packageName) } returns updatedMetadata.installer
|
every { pm.getInstallerPackageName(packageInfo.packageName) } returns updatedMetadata.installer
|
||||||
every { clock.time() } returns updatedMetadata.time
|
every { metadataManager.onApkBackedUp(packageInfo.packageName, updatedMetadata, outputStream) } just Runs
|
||||||
every { metadataManager.onApkBackedUp(packageInfo.packageName, updatedMetadata) } just Runs
|
|
||||||
|
|
||||||
assertTrue(apkBackup.backupApkIfNecessary(packageInfo, streamGetter))
|
assertEquals(updatedMetadata, apkBackup.backupApkIfNecessary(packageInfo, streamGetter))
|
||||||
assertArrayEquals(apkBytes, apkOutputStream.toByteArray())
|
assertArrayEquals(apkBytes, apkOutputStream.toByteArray())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,16 +1,16 @@
|
||||||
package com.stevesoltys.seedvault.transport.backup
|
package com.stevesoltys.seedvault.transport.backup
|
||||||
|
|
||||||
import android.app.backup.BackupTransport.TRANSPORT_ERROR
|
import android.app.backup.BackupTransport.*
|
||||||
import android.app.backup.BackupTransport.TRANSPORT_OK
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.os.ParcelFileDescriptor
|
||||||
import androidx.documentfile.provider.DocumentFile
|
import androidx.documentfile.provider.DocumentFile
|
||||||
import com.stevesoltys.seedvault.BackupNotificationManager
|
import com.stevesoltys.seedvault.BackupNotificationManager
|
||||||
import com.stevesoltys.seedvault.getRandomString
|
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 com.stevesoltys.seedvault.settings.Storage
|
||||||
import io.mockk.Runs
|
import io.mockk.*
|
||||||
import io.mockk.every
|
|
||||||
import io.mockk.just
|
|
||||||
import io.mockk.mockk
|
|
||||||
import org.junit.jupiter.api.Assertions.assertEquals
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
import org.junit.jupiter.api.Assertions.assertThrows
|
import org.junit.jupiter.api.Assertions.assertThrows
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
|
@ -18,7 +18,7 @@ import java.io.IOException
|
||||||
import java.io.OutputStream
|
import java.io.OutputStream
|
||||||
import kotlin.random.Random
|
import kotlin.random.Random
|
||||||
|
|
||||||
internal class BackupCoordinatorTest: BackupTest() {
|
internal class BackupCoordinatorTest : BackupTest() {
|
||||||
|
|
||||||
private val plugin = mockk<BackupPlugin>()
|
private val plugin = mockk<BackupPlugin>()
|
||||||
private val kv = mockk<KVBackup>()
|
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 backup = BackupCoordinator(context, plugin, kv, full, apkBackup, clock, metadataManager, settingsManager, notificationManager)
|
||||||
|
|
||||||
private val metadataOutputStream = mockk<OutputStream>()
|
private val metadataOutputStream = mockk<OutputStream>()
|
||||||
|
private val fileDescriptor: ParcelFileDescriptor = mockk()
|
||||||
|
private val packageMetadata: PackageMetadata = mockk()
|
||||||
|
private val storage = Storage(Uri.EMPTY, getRandomString(), false)
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `device initialization succeeds and delegates to plugin`() {
|
fun `device initialization succeeds and delegates to plugin`() {
|
||||||
|
@ -56,8 +59,6 @@ internal class BackupCoordinatorTest: BackupTest() {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `error notification when device initialization fails`() {
|
fun `error notification when device initialization fails`() {
|
||||||
val storage = Storage(Uri.EMPTY, getRandomString(), false)
|
|
||||||
|
|
||||||
every { clock.time() } returns token
|
every { clock.time() } returns token
|
||||||
every { plugin.initializeDevice(token) } throws IOException()
|
every { plugin.initializeDevice(token) } throws IOException()
|
||||||
every { settingsManager.getStorage() } returns storage
|
every { settingsManager.getStorage() } returns storage
|
||||||
|
@ -143,7 +144,8 @@ internal class BackupCoordinatorTest: BackupTest() {
|
||||||
every { kv.hasState() } returns true
|
every { kv.hasState() } returns true
|
||||||
every { full.hasState() } returns false
|
every { full.hasState() } returns false
|
||||||
every { kv.getCurrentPackage() } returns packageInfo
|
every { kv.getCurrentPackage() } returns packageInfo
|
||||||
expectApkBackupAndMetadataWrite()
|
every { plugin.getMetadataOutputStream() } returns metadataOutputStream
|
||||||
|
every { metadataManager.onPackageBackedUp(packageInfo.packageName, metadataOutputStream) } just Runs
|
||||||
every { kv.finishBackup() } returns result
|
every { kv.finishBackup() } returns result
|
||||||
|
|
||||||
assertEquals(result, backup.finishBackup())
|
assertEquals(result, backup.finishBackup())
|
||||||
|
@ -156,16 +158,74 @@ internal class BackupCoordinatorTest: BackupTest() {
|
||||||
every { kv.hasState() } returns false
|
every { kv.hasState() } returns false
|
||||||
every { full.hasState() } returns true
|
every { full.hasState() } returns true
|
||||||
every { full.getCurrentPackage() } returns packageInfo
|
every { full.getCurrentPackage() } returns packageInfo
|
||||||
expectApkBackupAndMetadataWrite()
|
every { plugin.getMetadataOutputStream() } returns metadataOutputStream
|
||||||
|
every { metadataManager.onPackageBackedUp(packageInfo.packageName, metadataOutputStream) } just Runs
|
||||||
every { full.finishBackup() } returns result
|
every { full.finishBackup() } returns result
|
||||||
|
|
||||||
assertEquals(result, backup.finishBackup())
|
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() {
|
private fun expectApkBackupAndMetadataWrite() {
|
||||||
every { apkBackup.backupApkIfNecessary(packageInfo, any()) } returns true
|
every { apkBackup.backupApkIfNecessary(packageInfo, any()) } returns packageMetadata
|
||||||
every { plugin.getMetadataOutputStream() } returns metadataOutputStream
|
every { plugin.getMetadataOutputStream() } returns metadataOutputStream
|
||||||
every { metadataManager.onPackageBackedUp(packageInfo.packageName, metadataOutputStream) } just Runs
|
every { metadataManager.onApkBackedUp(packageInfo.packageName, packageMetadata, metadataOutputStream) } just Runs
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue